mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-15 02:20:38 +09:00
Expand plugin host surface (#5205)
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - The plugin system is the extension boundary for optional product capabilities > - Rich plugins need more than a worker entrypoint: they need scoped database storage, local project folders, managed agents/routines, host navigation, and reusable UI components > - The LLM Wiki work exposed those missing host surfaces while keeping plugin code outside the core control plane > - This pull request expands the core plugin host, SDK, server APIs, and UI bridge so plugins can declare and use those surfaces > - The benefit is that future plugins can integrate with Paperclip through documented, validated contracts instead of bespoke server or UI imports ## What Changed - Added plugin-managed database namespaces and migration tracking, including Drizzle schema/migration files and SQL validation for namespace isolation. - Added server support for plugin local folders, managed agents, managed routines, scoped plugin APIs, and plugin operation visibility. - Expanded shared plugin manifest/types/validators and SDK host/testing/UI exports for richer plugin surfaces. - Added reusable UI pieces for file trees, managed routines, resizable sidebars, route sidebars, and plugin bridge initialization. - Updated plugin docs and example plugins to use the expanded host and SDK surface. ## Verification - `pnpm install --frozen-lockfile` - `pnpm run preflight:workspace-links && pnpm exec vitest run packages/shared/src/validators/plugin.test.ts server/src/__tests__/plugin-database.test.ts server/src/__tests__/plugin-local-folders.test.ts server/src/__tests__/plugin-managed-agents.test.ts server/src/__tests__/plugin-managed-routines.test.ts server/src/__tests__/plugin-orchestration-apis.test.ts ui/src/api/plugins.test.ts ui/src/components/FileTree.test.tsx ui/src/components/ResizableSidebarPane.test.tsx ui/src/pages/PluginPage.test.tsx ui/src/plugins/bridge.test.ts` passed: 11 files, 67 tests. - Confirmed this PR changes 89 files and does not include `pnpm-lock.yaml` or `.github/workflows/*`. ## Risks - Medium: this expands plugin host contracts across db/shared/server/ui and includes a new core migration (`0076_useful_elektra.sql`). - The plugin database namespace validator is intentionally restrictive; plugin authors may need follow-up affordances for SQL patterns that remain blocked. - Merge this before the LLM Wiki plugin PR so the plugin can resolve the new SDK and host APIs. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-5 coding agent, tool-enabled shell/git/GitHub workflow. Context window size was not exposed by the runtime. ## 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 run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
d6bee62f02
commit
3c73ed26b5
89 changed files with 27516 additions and 914 deletions
|
|
@ -657,6 +657,143 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => {
|
|||
expect(projectResult.map((issue) => issue.id).sort()).toEqual([executionLinkedIssueId, projectLinkedIssueId].sort());
|
||||
});
|
||||
|
||||
it("hides plugin operation issues from default lists and inbox-style filters while preserving explicit retrieval", async () => {
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
const normalIssueId = randomUUID();
|
||||
const pluginVisibleIssueId = randomUUID();
|
||||
const operationIssueId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "Plugin Runner",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
await db.insert(projects).values({
|
||||
id: projectId,
|
||||
companyId,
|
||||
name: "Plugin operations",
|
||||
status: "in_progress",
|
||||
});
|
||||
await db.insert(issues).values([
|
||||
{
|
||||
id: normalIssueId,
|
||||
companyId,
|
||||
title: "Normal issue",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
assigneeAgentId: agentId,
|
||||
},
|
||||
{
|
||||
id: pluginVisibleIssueId,
|
||||
companyId,
|
||||
title: "Plugin-visible issue",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
assigneeAgentId: agentId,
|
||||
originKind: "plugin:paperclip.missions:feature",
|
||||
},
|
||||
{
|
||||
id: operationIssueId,
|
||||
companyId,
|
||||
projectId,
|
||||
title: "Plugin operation issue",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
assigneeAgentId: agentId,
|
||||
originKind: "plugin:paperclip.missions:operation",
|
||||
originId: "mission-alpha:operation-1",
|
||||
},
|
||||
]);
|
||||
|
||||
const defaultIssueIds = (await svc.list(companyId)).map((issue) => issue.id);
|
||||
expect(defaultIssueIds).toContain(normalIssueId);
|
||||
expect(defaultIssueIds).toContain(pluginVisibleIssueId);
|
||||
expect(defaultIssueIds).not.toContain(operationIssueId);
|
||||
|
||||
const inboxIssueIds = (await svc.list(companyId, {
|
||||
assigneeAgentId: agentId,
|
||||
status: "todo,in_progress,blocked",
|
||||
includeRoutineExecutions: true,
|
||||
})).map((issue) => issue.id);
|
||||
expect(inboxIssueIds).toContain(normalIssueId);
|
||||
expect(inboxIssueIds).not.toContain(operationIssueId);
|
||||
|
||||
await expect(svc.list(companyId, { originKind: "plugin:paperclip.missions:operation" }))
|
||||
.resolves.toEqual([expect.objectContaining({ id: operationIssueId })]);
|
||||
await expect(svc.list(companyId, { originId: "mission-alpha:operation-1" }))
|
||||
.resolves.toEqual([expect.objectContaining({ id: operationIssueId })]);
|
||||
|
||||
const projectIssueIds = (await svc.list(companyId, { projectId })).map((issue) => issue.id);
|
||||
expect(projectIssueIds).toContain(operationIssueId);
|
||||
|
||||
const advancedIssueIds = (await svc.list(companyId, { includePluginOperations: true })).map((issue) => issue.id);
|
||||
expect(advancedIssueIds).toContain(operationIssueId);
|
||||
});
|
||||
|
||||
it("excludes plugin operation issues from unread inbox counts", async () => {
|
||||
const companyId = randomUUID();
|
||||
const userId = "board-user";
|
||||
const otherUserId = "other-user";
|
||||
const normalIssueId = randomUUID();
|
||||
const operationIssueId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(issues).values([
|
||||
{
|
||||
id: normalIssueId,
|
||||
companyId,
|
||||
title: "Normal touched issue",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
createdByUserId: userId,
|
||||
},
|
||||
{
|
||||
id: operationIssueId,
|
||||
companyId,
|
||||
title: "Plugin operation touched issue",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
createdByUserId: userId,
|
||||
originKind: "plugin:paperclip.missions:operation",
|
||||
},
|
||||
]);
|
||||
await db.insert(issueComments).values([
|
||||
{
|
||||
companyId,
|
||||
issueId: normalIssueId,
|
||||
authorUserId: otherUserId,
|
||||
body: "Unread normal update.",
|
||||
},
|
||||
{
|
||||
companyId,
|
||||
issueId: operationIssueId,
|
||||
authorUserId: otherUserId,
|
||||
body: "Unread operation update.",
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(svc.countUnreadTouchedByUser(companyId, userId, "todo")).resolves.toBe(1);
|
||||
});
|
||||
|
||||
it("hides archived inbox issues until new external activity arrives", async () => {
|
||||
const companyId = randomUUID();
|
||||
const userId = "user-1";
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { mkdtemp, rm, mkdir, writeFile } from "node:fs/promises";
|
|||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
companies,
|
||||
createDb,
|
||||
|
|
@ -25,9 +25,11 @@ import {
|
|||
validatePluginRuntimeExecute,
|
||||
validatePluginRuntimeQuery,
|
||||
} from "../services/plugin-database.js";
|
||||
import { pluginLoader } from "../services/plugin-loader.js";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
const multiMigrationPluginKey = "paperclip.dbfixture";
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
|
|
@ -93,7 +95,7 @@ describeEmbeddedPostgres("plugin database namespaces", () => {
|
|||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
for (const pluginKey of ["paperclip.dbtest", "paperclip.escape"]) {
|
||||
for (const pluginKey of ["paperclip.dbtest", "paperclip.escape", "paperclip.refresh", multiMigrationPluginKey]) {
|
||||
const namespace = derivePluginDatabaseNamespace(pluginKey);
|
||||
await db.execute(sql.raw(`DROP SCHEMA IF EXISTS "${namespace}" CASCADE`));
|
||||
}
|
||||
|
|
@ -120,6 +122,31 @@ describeEmbeddedPostgres("plugin database namespaces", () => {
|
|||
return packageRoot;
|
||||
}
|
||||
|
||||
async function createInstallablePluginPackage(
|
||||
pluginManifest: PaperclipPluginManifestV1,
|
||||
migrationSql: string,
|
||||
) {
|
||||
const packageRoot = await createPluginPackage(pluginManifest, migrationSql);
|
||||
await writeFile(
|
||||
path.join(packageRoot, "package.json"),
|
||||
JSON.stringify({
|
||||
name: pluginManifest.id,
|
||||
version: pluginManifest.version,
|
||||
type: "module",
|
||||
paperclipPlugin: { manifest: "./manifest.js" },
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(
|
||||
path.join(packageRoot, "manifest.js"),
|
||||
`export default ${JSON.stringify(pluginManifest, null, 2)};\n`,
|
||||
"utf8",
|
||||
);
|
||||
await mkdir(path.join(packageRoot, "dist"), { recursive: true });
|
||||
await writeFile(path.join(packageRoot, "dist", "worker.js"), "export {};\n", "utf8");
|
||||
return packageRoot;
|
||||
}
|
||||
|
||||
async function installPluginRecord(manifest: PaperclipPluginManifestV1) {
|
||||
const pluginId = randomUUID();
|
||||
await db.insert(plugins).values({
|
||||
|
|
@ -158,6 +185,31 @@ describeEmbeddedPostgres("plugin database namespaces", () => {
|
|||
};
|
||||
}
|
||||
|
||||
it("applies multi-file plugin migrations through the production validator", async () => {
|
||||
const pluginManifest = manifest(multiMigrationPluginKey);
|
||||
const namespace = derivePluginDatabaseNamespace(pluginManifest.id);
|
||||
const packageRoot = await createPluginPackage(
|
||||
pluginManifest,
|
||||
`CREATE TABLE ${namespace}.source_rows (id uuid PRIMARY KEY, label text NOT NULL);`,
|
||||
);
|
||||
await writeFile(
|
||||
path.join(packageRoot, pluginManifest.database!.migrationsDir, "002_derived.sql"),
|
||||
`CREATE TABLE ${namespace}.derived_rows (
|
||||
id uuid PRIMARY KEY,
|
||||
source_id uuid NOT NULL REFERENCES ${namespace}.source_rows(id)
|
||||
);`,
|
||||
"utf8",
|
||||
);
|
||||
const pluginId = await installPluginRecord(pluginManifest);
|
||||
await pluginDatabaseService(db).applyMigrations(pluginId, pluginManifest, packageRoot);
|
||||
|
||||
const migrations = await db
|
||||
.select()
|
||||
.from(pluginMigrations)
|
||||
.where(and(eq(pluginMigrations.pluginId, pluginId), eq(pluginMigrations.status, "applied")));
|
||||
expect(migrations).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("applies migrations once and allows whitelisted core joins at runtime", async () => {
|
||||
const pluginManifest = manifest();
|
||||
const namespace = derivePluginDatabaseNamespace(pluginManifest.id);
|
||||
|
|
@ -246,6 +298,131 @@ describeEmbeddedPostgres("plugin database namespaces", () => {
|
|||
expect(migration?.status).toBe("failed");
|
||||
});
|
||||
|
||||
it("rolls back plugin install when migration validation fails", async () => {
|
||||
const pluginManifest = manifest("paperclip.escape");
|
||||
const namespace = derivePluginDatabaseNamespace(pluginManifest.id);
|
||||
const packageRoot = await createInstallablePluginPackage(
|
||||
pluginManifest,
|
||||
"CREATE TABLE public.plugin_escape (id uuid PRIMARY KEY);",
|
||||
);
|
||||
const loader = pluginLoader(db, {
|
||||
enableLocalFilesystem: false,
|
||||
enableNpmDiscovery: false,
|
||||
});
|
||||
|
||||
await expect(loader.installPlugin({ localPath: packageRoot }))
|
||||
.rejects.toThrow(/public\.plugin_escape|public/i);
|
||||
|
||||
const installedPlugins = await db
|
||||
.select()
|
||||
.from(plugins)
|
||||
.where(eq(plugins.pluginKey, pluginManifest.id));
|
||||
const namespaces = await db
|
||||
.select()
|
||||
.from(pluginDatabaseNamespaces)
|
||||
.where(eq(pluginDatabaseNamespaces.pluginKey, pluginManifest.id));
|
||||
const migrations = await db
|
||||
.select()
|
||||
.from(pluginMigrations)
|
||||
.where(eq(pluginMigrations.pluginKey, pluginManifest.id));
|
||||
const schemaRows = Array.from(
|
||||
await db.execute(
|
||||
sql<{ schema_name: string }>`SELECT schema_name FROM information_schema.schemata WHERE schema_name = ${namespace}`,
|
||||
) as Iterable<{ schema_name: string }>,
|
||||
);
|
||||
|
||||
expect(installedPlugins).toHaveLength(0);
|
||||
expect(namespaces).toHaveLength(0);
|
||||
expect(migrations).toHaveLength(0);
|
||||
expect(schemaRows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("refreshes persisted manifests from disk before activation", async () => {
|
||||
const staleManifest = manifest("paperclip.refresh");
|
||||
const refreshedManifest: PaperclipPluginManifestV1 = {
|
||||
...staleManifest,
|
||||
database: {
|
||||
...staleManifest.database!,
|
||||
coreReadTables: ["companies"],
|
||||
},
|
||||
};
|
||||
const namespace = derivePluginDatabaseNamespace(refreshedManifest.id);
|
||||
const packageRoot = await createInstallablePluginPackage(
|
||||
refreshedManifest,
|
||||
`
|
||||
CREATE TABLE ${namespace}.company_refs (
|
||||
id uuid PRIMARY KEY,
|
||||
company_id uuid NOT NULL REFERENCES public.companies(id)
|
||||
);
|
||||
`,
|
||||
);
|
||||
const pluginId = await installPluginRecord(staleManifest);
|
||||
await db
|
||||
.update(plugins)
|
||||
.set({
|
||||
packagePath: packageRoot,
|
||||
status: "ready",
|
||||
})
|
||||
.where(eq(plugins.id, pluginId));
|
||||
|
||||
const workerManager = {
|
||||
startWorker: vi.fn().mockResolvedValue(undefined),
|
||||
stopAll: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const loader = pluginLoader(db, {
|
||||
enableLocalFilesystem: false,
|
||||
enableNpmDiscovery: false,
|
||||
}, {
|
||||
workerManager,
|
||||
eventBus: {
|
||||
forPlugin: vi.fn(() => ({})),
|
||||
subscriptionCount: vi.fn(() => 0),
|
||||
},
|
||||
jobScheduler: {
|
||||
registerPlugin: vi.fn().mockResolvedValue(undefined),
|
||||
stop: vi.fn(),
|
||||
},
|
||||
jobStore: {
|
||||
syncJobDeclarations: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
toolDispatcher: {
|
||||
registerPluginTools: vi.fn(),
|
||||
},
|
||||
lifecycleManager: {
|
||||
markError: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
buildHostHandlers: vi.fn(() => ({})),
|
||||
instanceInfo: {
|
||||
instanceId: "test-instance",
|
||||
hostVersion: "1.0.0",
|
||||
deploymentMode: "authenticated",
|
||||
deploymentExposure: "public",
|
||||
},
|
||||
} as never);
|
||||
|
||||
const result = await loader.loadSingle(pluginId);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(workerManager.startWorker).toHaveBeenCalledWith(
|
||||
pluginId,
|
||||
expect.objectContaining({
|
||||
databaseNamespace: namespace,
|
||||
env: {
|
||||
PAPERCLIP_DEPLOYMENT_MODE: "authenticated",
|
||||
PAPERCLIP_DEPLOYMENT_EXPOSURE: "public",
|
||||
},
|
||||
manifest: expect.objectContaining({
|
||||
database: expect.objectContaining({ coreReadTables: ["companies"] }),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const [plugin] = await db
|
||||
.select()
|
||||
.from(plugins)
|
||||
.where(eq(plugins.id, pluginId));
|
||||
expect(plugin?.manifestJson.database?.coreReadTables).toEqual(["companies"]);
|
||||
});
|
||||
|
||||
it("rejects checksum changes for already applied migrations", async () => {
|
||||
const pluginManifest = manifest();
|
||||
const namespace = derivePluginDatabaseNamespace(pluginManifest.id);
|
||||
|
|
|
|||
263
server/src/__tests__/plugin-local-folders.test.ts
Normal file
263
server/src/__tests__/plugin-local-folders.test.ts
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { promises as fs } from "node:fs";
|
||||
import {
|
||||
assertConfiguredLocalFolder,
|
||||
assertWritableConfiguredLocalFolder,
|
||||
inspectPluginLocalFolder,
|
||||
listPluginLocalFolderEntries,
|
||||
preparePluginLocalFolder,
|
||||
readPluginLocalFolderText,
|
||||
resolvePluginLocalFolderPath,
|
||||
writePluginLocalFolderTextAtomic,
|
||||
} from "../services/plugin-local-folders.js";
|
||||
|
||||
describe("plugin local folders", () => {
|
||||
const tempRoots: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempRoots.map((root) => fs.rm(root, { recursive: true, force: true })));
|
||||
tempRoots.length = 0;
|
||||
});
|
||||
|
||||
async function makeRoot() {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-plugin-folder-"));
|
||||
tempRoots.push(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
it("reports a healthy generic folder when required paths exist", async () => {
|
||||
const root = await makeRoot();
|
||||
await fs.mkdir(path.join(root, "sources"));
|
||||
await fs.writeFile(path.join(root, "schema.md"), "schema", "utf8");
|
||||
|
||||
const status = await inspectPluginLocalFolder({
|
||||
folderKey: "content-root",
|
||||
storedConfig: {
|
||||
path: root,
|
||||
access: "readWrite",
|
||||
requiredDirectories: ["sources"],
|
||||
requiredFiles: ["schema.md"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(status.healthy).toBe(true);
|
||||
expect(status.problems).toEqual([]);
|
||||
expect(status.requiredDirectories).toEqual(["sources"]);
|
||||
expect(status.requiredFiles).toEqual(["schema.md"]);
|
||||
});
|
||||
|
||||
it("reports missing required folders and files without using product-specific branches", async () => {
|
||||
const root = await makeRoot();
|
||||
|
||||
const status = await inspectPluginLocalFolder({
|
||||
folderKey: "content-root",
|
||||
storedConfig: {
|
||||
path: root,
|
||||
requiredDirectories: ["sources"],
|
||||
requiredFiles: ["schema.md"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(status.healthy).toBe(false);
|
||||
expect(status.missingDirectories).toEqual(["sources"]);
|
||||
expect(status.missingFiles).toEqual(["schema.md"]);
|
||||
expect(status.problems.map((item) => item.code)).toEqual(
|
||||
expect.arrayContaining(["missing_directory", "missing_file"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("reports all required paths as missing when the configured root does not exist", async () => {
|
||||
const root = await makeRoot();
|
||||
const missingRoot = path.join(root, "missing-root");
|
||||
|
||||
const status = await inspectPluginLocalFolder({
|
||||
folderKey: "content-root",
|
||||
storedConfig: {
|
||||
path: missingRoot,
|
||||
requiredDirectories: ["sources"],
|
||||
requiredFiles: ["schema.md"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(status.healthy).toBe(false);
|
||||
expect(status.configured).toBe(true);
|
||||
expect(status.readable).toBe(false);
|
||||
expect(status.missingDirectories).toEqual(["sources"]);
|
||||
expect(status.missingFiles).toEqual(["schema.md"]);
|
||||
expect(status.problems.map((item) => item.code)).toContain("missing");
|
||||
});
|
||||
|
||||
it("uses manifest declaration access and required paths over stored or caller overrides", async () => {
|
||||
const root = await makeRoot();
|
||||
await fs.mkdir(path.join(root, "manifest-dir"));
|
||||
await fs.writeFile(path.join(root, "manifest.md"), "schema", "utf8");
|
||||
|
||||
const status = await inspectPluginLocalFolder({
|
||||
folderKey: "content-root",
|
||||
declaration: {
|
||||
folderKey: "content-root",
|
||||
displayName: "Content root",
|
||||
access: "read",
|
||||
requiredDirectories: ["manifest-dir"],
|
||||
requiredFiles: ["manifest.md"],
|
||||
},
|
||||
storedConfig: {
|
||||
path: root,
|
||||
access: "readWrite",
|
||||
requiredDirectories: ["stored-dir"],
|
||||
requiredFiles: ["stored.md"],
|
||||
},
|
||||
overrideConfig: {
|
||||
access: "readWrite",
|
||||
requiredDirectories: ["override-dir"],
|
||||
requiredFiles: ["override.md"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(status.access).toBe("read");
|
||||
expect(status.writable).toBe(false);
|
||||
expect(status.requiredDirectories).toEqual(["manifest-dir"]);
|
||||
expect(status.requiredFiles).toEqual(["manifest.md"]);
|
||||
expect(status.healthy).toBe(true);
|
||||
});
|
||||
|
||||
it("prepares required directories for a read-write folder without creating required files", async () => {
|
||||
const root = await makeRoot();
|
||||
|
||||
await preparePluginLocalFolder({
|
||||
folderKey: "content-root",
|
||||
storedConfig: {
|
||||
path: root,
|
||||
access: "readWrite",
|
||||
requiredDirectories: ["sources", "wiki/concepts"],
|
||||
requiredFiles: ["schema.md"],
|
||||
},
|
||||
});
|
||||
|
||||
await expect(fs.stat(path.join(root, "sources"))).resolves.toMatchObject({});
|
||||
await expect(fs.stat(path.join(root, "wiki/concepts"))).resolves.toMatchObject({});
|
||||
await expect(fs.stat(path.join(root, "schema.md"))).rejects.toMatchObject({ code: "ENOENT" });
|
||||
|
||||
const status = await inspectPluginLocalFolder({
|
||||
folderKey: "content-root",
|
||||
storedConfig: {
|
||||
path: root,
|
||||
access: "readWrite",
|
||||
requiredDirectories: ["sources", "wiki/concepts"],
|
||||
requiredFiles: ["schema.md"],
|
||||
},
|
||||
});
|
||||
expect(status.missingDirectories).toEqual([]);
|
||||
expect(status.missingFiles).toEqual(["schema.md"]);
|
||||
});
|
||||
|
||||
it("allows write access to repair folders that are only missing required paths", async () => {
|
||||
const root = await makeRoot();
|
||||
const status = await inspectPluginLocalFolder({
|
||||
folderKey: "content-root",
|
||||
storedConfig: {
|
||||
path: root,
|
||||
access: "readWrite",
|
||||
requiredFiles: ["schema.md"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(status.healthy).toBe(false);
|
||||
expect(() => assertConfiguredLocalFolder(status)).toThrow("Local folder is not healthy");
|
||||
expect(() => assertWritableConfiguredLocalFolder(status)).not.toThrow();
|
||||
|
||||
await writePluginLocalFolderTextAtomic(root, "schema.md", "schema");
|
||||
const repaired = await inspectPluginLocalFolder({
|
||||
folderKey: "content-root",
|
||||
storedConfig: {
|
||||
path: root,
|
||||
access: "readWrite",
|
||||
requiredFiles: ["schema.md"],
|
||||
},
|
||||
});
|
||||
expect(repaired.healthy).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects traversal outside the configured folder", async () => {
|
||||
const root = await makeRoot();
|
||||
|
||||
await expect(resolvePluginLocalFolderPath(root, "../outside.txt")).rejects.toMatchObject({
|
||||
status: 403,
|
||||
});
|
||||
});
|
||||
|
||||
it("detects required symlinks that escape the configured folder", async () => {
|
||||
const root = await makeRoot();
|
||||
const outside = await makeRoot();
|
||||
await fs.writeFile(path.join(outside, "secret.txt"), "nope", "utf8");
|
||||
await fs.symlink(path.join(outside, "secret.txt"), path.join(root, "linked.txt"));
|
||||
|
||||
const status = await inspectPluginLocalFolder({
|
||||
folderKey: "content-root",
|
||||
storedConfig: {
|
||||
path: root,
|
||||
requiredFiles: ["linked.txt"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(status.healthy).toBe(false);
|
||||
expect(status.problems.some((item) => item.code === "symlink_escape")).toBe(true);
|
||||
});
|
||||
|
||||
it("writes files atomically under the root and can read them back", async () => {
|
||||
const root = await makeRoot();
|
||||
await fs.mkdir(path.join(root, "nested"));
|
||||
|
||||
await writePluginLocalFolderTextAtomic(root, "nested/page.md", "hello");
|
||||
await writePluginLocalFolderTextAtomic(root, "nested/page.md", "updated");
|
||||
|
||||
await expect(readPluginLocalFolderText(root, "nested/page.md")).resolves.toBe("updated");
|
||||
const leftovers = await fs.readdir(path.join(root, "nested"));
|
||||
expect(leftovers.filter((name) => name.includes(".paperclip-"))).toEqual([]);
|
||||
});
|
||||
|
||||
it("lists nested local folder entries without following symlink escapes", async () => {
|
||||
const root = await makeRoot();
|
||||
const outside = await makeRoot();
|
||||
await fs.mkdir(path.join(root, "wiki/concepts"), { recursive: true });
|
||||
await fs.writeFile(path.join(root, "wiki/concepts/live.md"), "# Live\n", "utf8");
|
||||
await fs.writeFile(path.join(outside, "secret.md"), "# Secret\n", "utf8");
|
||||
await fs.symlink(outside, path.join(root, "wiki/outside"));
|
||||
|
||||
const listing = await listPluginLocalFolderEntries(root, {
|
||||
relativePath: "wiki",
|
||||
recursive: true,
|
||||
maxEntries: 20,
|
||||
});
|
||||
|
||||
expect(listing.entries.map((entry) => entry.path)).toContain("wiki/concepts/live.md");
|
||||
expect(listing.entries.map((entry) => entry.path)).not.toContain("wiki/outside/secret.md");
|
||||
expect(listing.truncated).toBe(false);
|
||||
});
|
||||
|
||||
it("revalidates temp-file containment before writing atomic contents", async () => {
|
||||
const root = await makeRoot();
|
||||
const outside = await makeRoot();
|
||||
const nested = path.join(root, "nested");
|
||||
await fs.mkdir(nested);
|
||||
const originalOpen = fs.open.bind(fs);
|
||||
const openSpy = vi.spyOn(fs, "open");
|
||||
openSpy.mockImplementationOnce(async (file, flags, mode) => {
|
||||
await fs.rm(nested, { recursive: true, force: true });
|
||||
await fs.symlink(outside, nested);
|
||||
return originalOpen(file, flags, mode);
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(writePluginLocalFolderTextAtomic(root, "nested/page.md", "secret")).rejects.toMatchObject({
|
||||
status: 403,
|
||||
});
|
||||
await expect(fs.readFile(path.join(outside, "page.md"), "utf8")).rejects.toMatchObject({ code: "ENOENT" });
|
||||
expect(await fs.readdir(outside)).toEqual([]);
|
||||
} finally {
|
||||
openSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
365
server/src/__tests__/plugin-managed-agents.test.ts
Normal file
365
server/src/__tests__/plugin-managed-agents.test.ts
Normal file
|
|
@ -0,0 +1,365 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { promises as fs } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
activityLog,
|
||||
agentConfigRevisions,
|
||||
agents,
|
||||
approvals,
|
||||
companies,
|
||||
createDb,
|
||||
pluginEntities,
|
||||
pluginCompanySettings,
|
||||
pluginManagedResources,
|
||||
plugins,
|
||||
} from "@paperclipai/db";
|
||||
import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import { buildHostServices } from "../services/plugin-host-services.js";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
function createEventBusStub() {
|
||||
return {
|
||||
forPlugin() {
|
||||
return {
|
||||
emit: async () => {},
|
||||
subscribe: () => {},
|
||||
};
|
||||
},
|
||||
} as any;
|
||||
}
|
||||
|
||||
function issuePrefix(id: string) {
|
||||
return `T${id.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
|
||||
}
|
||||
|
||||
function manifest(): PaperclipPluginManifestV1 {
|
||||
return {
|
||||
id: "paperclip.managed-agents-test",
|
||||
apiVersion: 1,
|
||||
version: "0.1.0",
|
||||
displayName: "Managed Agents Test",
|
||||
description: "Test plugin",
|
||||
author: "Paperclip",
|
||||
categories: ["automation"],
|
||||
capabilities: ["agents.managed"],
|
||||
entrypoints: { worker: "./dist/worker.js" },
|
||||
agents: [
|
||||
{
|
||||
agentKey: "wiki-maintainer",
|
||||
displayName: "Wiki Maintainer",
|
||||
role: "engineer",
|
||||
title: "Maintains plugin-owned knowledge",
|
||||
capabilities: "Maintains a plugin-owned wiki.",
|
||||
adapterType: "process",
|
||||
adapterConfig: { command: "pnpm wiki:maintain" },
|
||||
runtimeConfig: { modelProfiles: { cheap: { enabled: true, adapterConfig: { model: "small" } } } },
|
||||
permissions: { canCreateAgents: false },
|
||||
budgetMonthlyCents: 1234,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
`Skipping embedded Postgres plugin-managed agent tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
||||
);
|
||||
}
|
||||
|
||||
describeEmbeddedPostgres("plugin-managed agents", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-plugin-managed-agents-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(agentConfigRevisions);
|
||||
await db.delete(activityLog);
|
||||
await db.delete(pluginEntities);
|
||||
await db.delete(pluginManagedResources);
|
||||
await db.delete(pluginCompanySettings);
|
||||
await db.delete(approvals);
|
||||
await db.delete(agents);
|
||||
await db.delete(plugins);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
async function seedCompanyAndPlugin(options: { requireApproval?: boolean; manifest?: PaperclipPluginManifestV1 } = {}) {
|
||||
const companyId = randomUUID();
|
||||
const pluginId = randomUUID();
|
||||
const pluginManifest = options.manifest ?? manifest();
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: issuePrefix(companyId),
|
||||
requireBoardApprovalForNewAgents: options.requireApproval ?? false,
|
||||
});
|
||||
await db.insert(plugins).values({
|
||||
id: pluginId,
|
||||
pluginKey: pluginManifest.id,
|
||||
packageName: "@paperclipai/plugin-managed-agents-test",
|
||||
version: pluginManifest.version,
|
||||
apiVersion: pluginManifest.apiVersion,
|
||||
categories: pluginManifest.categories,
|
||||
manifestJson: pluginManifest,
|
||||
status: "ready",
|
||||
installOrder: 1,
|
||||
});
|
||||
const services = buildHostServices(db, pluginId, pluginManifest.id, createEventBusStub(), undefined, {
|
||||
manifest: pluginManifest,
|
||||
});
|
||||
return { companyId, pluginId, pluginManifest, services };
|
||||
}
|
||||
|
||||
it("creates and resolves managed agents by stable resource key", async () => {
|
||||
const { companyId, services } = await seedCompanyAndPlugin();
|
||||
|
||||
const created = await services.agents.managedReconcile({
|
||||
companyId,
|
||||
agentKey: "wiki-maintainer",
|
||||
});
|
||||
|
||||
expect(created.status).toBe("created");
|
||||
expect(created.agentId).toBeTruthy();
|
||||
expect(created.agent).toMatchObject({
|
||||
name: "Wiki Maintainer",
|
||||
role: "engineer",
|
||||
adapterConfig: { command: "pnpm wiki:maintain" },
|
||||
});
|
||||
|
||||
const resolved = await services.agents.managedGet({
|
||||
companyId,
|
||||
agentKey: "wiki-maintainer",
|
||||
});
|
||||
expect(resolved.status).toBe("resolved");
|
||||
expect(resolved.agentId).toBe(created.agentId);
|
||||
|
||||
const [binding] = await db.select().from(pluginEntities);
|
||||
expect(binding?.entityType).toBe("managed_agent");
|
||||
expect(binding?.scopeKind).toBe("company");
|
||||
expect(binding?.scopeId).toBe(companyId);
|
||||
expect(binding?.data).toMatchObject({
|
||||
resourceKind: "agent",
|
||||
resourceKey: "wiki-maintainer",
|
||||
agentId: created.agentId,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves user edits during reconcile and resets only on explicit reset", async () => {
|
||||
const { companyId, services } = await seedCompanyAndPlugin();
|
||||
const created = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
|
||||
expect(created.agentId).toBeTruthy();
|
||||
|
||||
await db
|
||||
.update(agents)
|
||||
.set({
|
||||
name: "Knowledge Lead",
|
||||
adapterConfig: { command: "custom" },
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(agents.id, created.agentId!));
|
||||
|
||||
const reconciled = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
|
||||
expect(reconciled.status).toBe("resolved");
|
||||
expect(reconciled.agent).toMatchObject({
|
||||
name: "Knowledge Lead",
|
||||
adapterConfig: { command: "custom" },
|
||||
});
|
||||
|
||||
const reset = await services.agents.managedReset({ companyId, agentKey: "wiki-maintainer" });
|
||||
expect(reset.status).toBe("reset");
|
||||
expect(reset.agent).toMatchObject({
|
||||
name: "Wiki Maintainer",
|
||||
adapterConfig: { command: "pnpm wiki:maintain" },
|
||||
});
|
||||
});
|
||||
|
||||
it("creates managed agents with the most-used compatible company adapter", async () => {
|
||||
const pluginManifest = manifest();
|
||||
pluginManifest.agents![0] = {
|
||||
...pluginManifest.agents![0]!,
|
||||
adapterType: "claude_local",
|
||||
adapterPreference: ["claude_local", "codex_local"],
|
||||
adapterConfig: {},
|
||||
};
|
||||
const { companyId, services } = await seedCompanyAndPlugin({ manifest: pluginManifest });
|
||||
await db.insert(agents).values([
|
||||
{
|
||||
id: randomUUID(),
|
||||
companyId,
|
||||
name: "Codex One",
|
||||
role: "engineer",
|
||||
status: "idle",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
},
|
||||
{
|
||||
id: randomUUID(),
|
||||
companyId,
|
||||
name: "Codex Two",
|
||||
role: "engineer",
|
||||
status: "idle",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
},
|
||||
{
|
||||
id: randomUUID(),
|
||||
companyId,
|
||||
name: "Claude One",
|
||||
role: "engineer",
|
||||
status: "idle",
|
||||
adapterType: "claude_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
},
|
||||
]);
|
||||
|
||||
const created = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
|
||||
|
||||
expect(created.status).toBe("created");
|
||||
expect(created.agent?.adapterType).toBe("codex_local");
|
||||
});
|
||||
|
||||
it("materializes declared managed agent instructions with local folder paths", async () => {
|
||||
const previousHome = process.env.PAPERCLIP_HOME;
|
||||
const previousInstance = process.env.PAPERCLIP_INSTANCE_ID;
|
||||
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-managed-agent-home-"));
|
||||
const wikiRoot = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-managed-agent-wiki-")));
|
||||
process.env.PAPERCLIP_HOME = tempHome;
|
||||
process.env.PAPERCLIP_INSTANCE_ID = "test";
|
||||
try {
|
||||
const pluginManifest = manifest();
|
||||
pluginManifest.localFolders = [
|
||||
{
|
||||
folderKey: "wiki-root",
|
||||
displayName: "Wiki root",
|
||||
access: "readWrite",
|
||||
requiredDirectories: [],
|
||||
requiredFiles: ["AGENTS.md"],
|
||||
},
|
||||
];
|
||||
pluginManifest.agents![0] = {
|
||||
...pluginManifest.agents![0]!,
|
||||
adapterType: "claude_local",
|
||||
adapterConfig: {},
|
||||
instructions: {
|
||||
entryFile: "AGENTS.md",
|
||||
content: [
|
||||
"# LLM Wiki Maintainer",
|
||||
"",
|
||||
"You are the LLM Wiki Maintainer.",
|
||||
"Wiki root: `{{localFolders.wiki-root.path}}`",
|
||||
"Wiki schema: `{{localFolders.wiki-root.agentsPath}}`",
|
||||
"",
|
||||
].join("\n"),
|
||||
},
|
||||
};
|
||||
const { companyId, pluginId, services } = await seedCompanyAndPlugin({ manifest: pluginManifest });
|
||||
await fs.writeFile(path.join(wikiRoot, "AGENTS.md"), "# Wiki schema\n", "utf8");
|
||||
await db.insert(pluginCompanySettings).values({
|
||||
companyId,
|
||||
pluginId,
|
||||
enabled: true,
|
||||
settingsJson: {
|
||||
localFolders: {
|
||||
"wiki-root": {
|
||||
path: wikiRoot,
|
||||
access: "readWrite",
|
||||
requiredDirectories: [],
|
||||
requiredFiles: ["AGENTS.md"],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const created = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
|
||||
|
||||
const instructionsFilePath = created.agent?.adapterConfig.instructionsFilePath;
|
||||
expect(typeof instructionsFilePath).toBe("string");
|
||||
const content = await fs.readFile(instructionsFilePath as string, "utf8");
|
||||
expect(content).toContain("You are the LLM Wiki Maintainer.");
|
||||
expect(content).toContain(`Wiki root: \`${wikiRoot}\``);
|
||||
expect(content).toContain(`Wiki schema: \`${path.join(wikiRoot, "AGENTS.md")}\``);
|
||||
} finally {
|
||||
if (previousHome === undefined) delete process.env.PAPERCLIP_HOME;
|
||||
else process.env.PAPERCLIP_HOME = previousHome;
|
||||
if (previousInstance === undefined) delete process.env.PAPERCLIP_INSTANCE_ID;
|
||||
else process.env.PAPERCLIP_INSTANCE_ID = previousInstance;
|
||||
await fs.rm(tempHome, { recursive: true, force: true });
|
||||
await fs.rm(wikiRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("repairs a missing binding by relinking a same-company managed agent marker", async () => {
|
||||
const { companyId, pluginId, pluginManifest, services } = await seedCompanyAndPlugin();
|
||||
const agentId = randomUUID();
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "Renamed Wiki Agent",
|
||||
role: "engineer",
|
||||
status: "idle",
|
||||
adapterType: "process",
|
||||
adapterConfig: { command: "custom" },
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
metadata: {
|
||||
paperclipManagedResource: {
|
||||
pluginId,
|
||||
pluginKey: pluginManifest.id,
|
||||
resourceKind: "agent",
|
||||
resourceKey: "wiki-maintainer",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const relinked = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
|
||||
expect(relinked.status).toBe("relinked");
|
||||
expect(relinked.agentId).toBe(agentId);
|
||||
|
||||
const [binding] = await db.select().from(pluginEntities);
|
||||
expect(binding?.data).toMatchObject({ agentId });
|
||||
});
|
||||
|
||||
it("respects board approval policy for new managed agents", async () => {
|
||||
const { companyId, services } = await seedCompanyAndPlugin({ requireApproval: true });
|
||||
|
||||
const created = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
|
||||
|
||||
expect(created.status).toBe("created");
|
||||
expect(created.agent?.status).toBe("pending_approval");
|
||||
expect(created.approvalId).toBeTruthy();
|
||||
|
||||
const [approval] = await db.select().from(approvals).where(eq(approvals.id, created.approvalId!));
|
||||
expect(approval).toMatchObject({
|
||||
type: "hire_agent",
|
||||
status: "pending",
|
||||
});
|
||||
expect(approval?.payload).toMatchObject({
|
||||
agentId: created.agentId,
|
||||
sourcePluginKey: "paperclip.managed-agents-test",
|
||||
managedResourceKey: "wiki-maintainer",
|
||||
});
|
||||
});
|
||||
});
|
||||
249
server/src/__tests__/plugin-managed-routines.test.ts
Normal file
249
server/src/__tests__/plugin-managed-routines.test.ts
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
activityLog,
|
||||
agentConfigRevisions,
|
||||
agents,
|
||||
companies,
|
||||
createDb,
|
||||
issues,
|
||||
pluginManagedResources,
|
||||
plugins,
|
||||
projects,
|
||||
routineRuns,
|
||||
routineTriggers,
|
||||
routines,
|
||||
} 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 { routineService } from "../services/routines.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-routines-test",
|
||||
apiVersion: 1,
|
||||
version: "0.1.0",
|
||||
displayName: "Managed Routines Test",
|
||||
description: "Test plugin",
|
||||
author: "Paperclip",
|
||||
categories: ["automation"],
|
||||
capabilities: ["agents.managed", "projects.managed", "routines.managed"],
|
||||
entrypoints: { worker: "./dist/worker.js" },
|
||||
agents: [{
|
||||
agentKey: "wiki-maintainer",
|
||||
displayName: "Wiki Maintainer",
|
||||
role: "engineer",
|
||||
adapterType: "process",
|
||||
adapterConfig: { command: "pnpm wiki:maintain" },
|
||||
}],
|
||||
projects: [{
|
||||
projectKey: "operations",
|
||||
displayName: "Plugin Operations",
|
||||
description: "Plugin operation inspection",
|
||||
status: "in_progress",
|
||||
}],
|
||||
routines: [{
|
||||
routineKey: "nightly-lint",
|
||||
title: "Nightly lint",
|
||||
description: "Lint plugin state",
|
||||
assigneeRef: { resourceKind: "agent", resourceKey: "wiki-maintainer" },
|
||||
projectRef: { resourceKind: "project", resourceKey: "operations" },
|
||||
status: "active",
|
||||
priority: "medium",
|
||||
concurrencyPolicy: "coalesce_if_active",
|
||||
catchUpPolicy: "skip_missed",
|
||||
triggers: [{
|
||||
kind: "schedule",
|
||||
label: "Nightly",
|
||||
cronExpression: "0 3 * * *",
|
||||
timezone: "UTC",
|
||||
}],
|
||||
issueTemplate: {
|
||||
surfaceVisibility: "plugin_operation",
|
||||
originId: "operation:nightly-lint",
|
||||
billingCode: "plugin-test:nightly-lint",
|
||||
},
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
`Skipping embedded Postgres plugin-managed routine tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
||||
);
|
||||
}
|
||||
|
||||
describeEmbeddedPostgres("plugin-managed routines", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-plugin-managed-routines-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(routineRuns);
|
||||
await db.delete(routineTriggers);
|
||||
await db.delete(routines);
|
||||
await db.delete(issues);
|
||||
await db.delete(agentConfigRevisions);
|
||||
await db.delete(activityLog);
|
||||
await db.delete(pluginManagedResources);
|
||||
await db.delete(agents);
|
||||
await db.delete(projects);
|
||||
await db.delete(plugins);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
async function seedCompanyAndPlugin(pluginManifest = manifest()) {
|
||||
const companyId = randomUUID();
|
||||
const pluginId = randomUUID();
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: issuePrefix(companyId),
|
||||
});
|
||||
await db.insert(plugins).values({
|
||||
id: pluginId,
|
||||
pluginKey: pluginManifest.id,
|
||||
packageName: "@paperclipai/plugin-managed-routines-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("resolves routine agent and project refs by stable managed keys", async () => {
|
||||
const { companyId, services } = await seedCompanyAndPlugin();
|
||||
const agent = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
|
||||
const project = await services.projects.reconcileManaged({ companyId, projectKey: "operations" });
|
||||
|
||||
const created = await services.routines.managedReconcile({ companyId, routineKey: "nightly-lint" });
|
||||
|
||||
expect(created.status).toBe("created");
|
||||
expect(created.routine).toMatchObject({
|
||||
title: "Nightly lint",
|
||||
assigneeAgentId: agent.agentId,
|
||||
projectId: project.projectId,
|
||||
managedByPlugin: expect.objectContaining({
|
||||
pluginKey: "paperclip.managed-routines-test",
|
||||
resourceKind: "routine",
|
||||
resourceKey: "nightly-lint",
|
||||
}),
|
||||
});
|
||||
|
||||
const [trigger] = await db.select().from(routineTriggers).where(eq(routineTriggers.routineId, created.routineId!));
|
||||
expect(trigger).toMatchObject({
|
||||
kind: "schedule",
|
||||
cronExpression: "0 3 * * *",
|
||||
timezone: "UTC",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns missing refs until the operator repairs them and preserves routine edits on reconcile", async () => {
|
||||
const { companyId, services } = await seedCompanyAndPlugin();
|
||||
|
||||
const missing = await services.routines.managedReconcile({ companyId, routineKey: "nightly-lint" });
|
||||
expect(missing.status).toBe("missing_refs");
|
||||
expect(missing.missingRefs).toEqual([
|
||||
expect.objectContaining({ resourceKind: "agent", resourceKey: "wiki-maintainer" }),
|
||||
expect.objectContaining({ resourceKind: "project", resourceKey: "operations" }),
|
||||
]);
|
||||
|
||||
const [agent] = await db.insert(agents).values({
|
||||
companyId,
|
||||
name: "Operator-selected maintainer",
|
||||
role: "engineer",
|
||||
status: "idle",
|
||||
adapterType: "process",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
}).returning();
|
||||
const [project] = await db.insert(projects).values({
|
||||
companyId,
|
||||
name: "Operator-selected project",
|
||||
status: "in_progress",
|
||||
}).returning();
|
||||
|
||||
const repaired = await services.routines.managedReconcile({
|
||||
companyId,
|
||||
routineKey: "nightly-lint",
|
||||
assigneeAgentId: agent.id,
|
||||
projectId: project.id,
|
||||
});
|
||||
expect(repaired.status).toBe("created");
|
||||
expect(repaired.routine).toMatchObject({
|
||||
assigneeAgentId: agent.id,
|
||||
projectId: project.id,
|
||||
});
|
||||
|
||||
await db
|
||||
.update(routines)
|
||||
.set({ title: "Operator renamed lint", updatedAt: new Date() })
|
||||
.where(eq(routines.id, repaired.routineId!));
|
||||
|
||||
const reconciled = await services.routines.managedReconcile({ companyId, routineKey: "nightly-lint" });
|
||||
expect(reconciled.status).toBe("resolved");
|
||||
expect(reconciled.routine?.title).toBe("Operator renamed lint");
|
||||
});
|
||||
|
||||
it("creates routine operation issues with plugin visibility and managed project scoping", async () => {
|
||||
const { companyId, services } = await seedCompanyAndPlugin();
|
||||
const agent = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
|
||||
const project = await services.projects.reconcileManaged({ companyId, projectKey: "operations" });
|
||||
const routine = await services.routines.managedReconcile({ companyId, routineKey: "nightly-lint" });
|
||||
const wakeup = vi.fn(async () => ({ id: randomUUID() }));
|
||||
const routinesSvc = routineService(db, { heartbeat: { wakeup } });
|
||||
|
||||
const run = await routinesSvc.runRoutine(routine.routineId!, { source: "manual" }, { userId: "board-user" });
|
||||
|
||||
expect(run.status).toBe("issue_created");
|
||||
const [issue] = await db.select().from(issues).where(eq(issues.id, run.linkedIssueId!));
|
||||
expect(issue).toMatchObject({
|
||||
originKind: "plugin:paperclip.managed-routines-test:operation",
|
||||
originId: "operation:nightly-lint",
|
||||
billingCode: "plugin-test:nightly-lint",
|
||||
projectId: project.projectId,
|
||||
assigneeAgentId: agent.agentId,
|
||||
});
|
||||
expect(wakeup).toHaveBeenCalledWith(agent.agentId, expect.objectContaining({
|
||||
reason: "issue_assigned",
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
|
@ -1,4 +1,7 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { promises as fs } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
|
|
@ -11,6 +14,9 @@ import {
|
|||
heartbeatRuns,
|
||||
issueRelations,
|
||||
issues,
|
||||
pluginManagedResources,
|
||||
plugins,
|
||||
projects,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
|
|
@ -45,6 +51,7 @@ if (!embeddedPostgresSupport.supported) {
|
|||
describeEmbeddedPostgres("plugin orchestration APIs", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
const tempRoots: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-plugin-orchestration-");
|
||||
|
|
@ -52,12 +59,17 @@ describeEmbeddedPostgres("plugin orchestration APIs", () => {
|
|||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempRoots.map((root) => fs.rm(root, { recursive: true, force: true })));
|
||||
tempRoots.length = 0;
|
||||
await db.delete(activityLog);
|
||||
await db.delete(costEvents);
|
||||
await db.delete(heartbeatRuns);
|
||||
await db.delete(agentWakeupRequests);
|
||||
await db.delete(issueRelations);
|
||||
await db.delete(issues);
|
||||
await db.delete(pluginManagedResources);
|
||||
await db.delete(projects);
|
||||
await db.delete(plugins);
|
||||
await db.delete(agents);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
|
@ -89,6 +101,12 @@ describeEmbeddedPostgres("plugin orchestration APIs", () => {
|
|||
return { companyId, agentId };
|
||||
}
|
||||
|
||||
async function makeLocalRoot() {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-plugin-host-folder-"));
|
||||
tempRoots.push(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
it("creates plugin-origin issues with full orchestration fields and audit activity", async () => {
|
||||
const { companyId, agentId } = await seedCompanyAndAgent();
|
||||
const blockerIssueId = randomUUID();
|
||||
|
|
@ -189,6 +207,293 @@ describeEmbeddedPostgres("plugin orchestration APIs", () => {
|
|||
).rejects.toThrow("Plugin may only use originKind values under plugin:paperclip.missions");
|
||||
});
|
||||
|
||||
it("creates plugin operation issues with the generic operation origin", async () => {
|
||||
const { companyId } = await seedCompanyAndAgent();
|
||||
const services = buildHostServices(db, "plugin-record-id", "paperclip.missions", createEventBusStub());
|
||||
|
||||
const issue = await services.issues.create({
|
||||
companyId,
|
||||
title: "Background operation",
|
||||
surfaceVisibility: "plugin_operation",
|
||||
originId: "mission-alpha:operation-1",
|
||||
});
|
||||
|
||||
expect(issue.originKind).toBe("plugin:paperclip.missions:operation");
|
||||
expect(issue.originId).toBe("mission-alpha:operation-1");
|
||||
});
|
||||
|
||||
it("lets bootstrap-style actions initialize required local folders from an empty root", async () => {
|
||||
const { companyId } = await seedCompanyAndAgent();
|
||||
const pluginId = randomUUID();
|
||||
await db.insert(plugins).values({
|
||||
id: pluginId,
|
||||
pluginKey: "paperclipai.plugin-llm-wiki",
|
||||
packageName: "@paperclipai/plugin-llm-wiki",
|
||||
version: "0.1.0",
|
||||
manifestJson: {
|
||||
id: "paperclipai.plugin-llm-wiki",
|
||||
apiVersion: 1,
|
||||
version: "0.1.0",
|
||||
displayName: "LLM Wiki",
|
||||
description: "Local-file LLM Wiki plugin",
|
||||
author: "Paperclip",
|
||||
categories: ["automation"],
|
||||
capabilities: ["local.folders"],
|
||||
entrypoints: { worker: "./dist/worker.js" },
|
||||
localFolders: [
|
||||
{
|
||||
folderKey: "wiki-root",
|
||||
displayName: "Wiki root",
|
||||
access: "readWrite",
|
||||
requiredDirectories: ["raw", "wiki", "wiki/concepts", ".paperclip"],
|
||||
requiredFiles: ["WIKI.md", "AGENTS.md"],
|
||||
},
|
||||
],
|
||||
},
|
||||
status: "ready",
|
||||
});
|
||||
const root = await makeLocalRoot();
|
||||
const services = buildHostServices(
|
||||
db,
|
||||
pluginId,
|
||||
"paperclipai.plugin-llm-wiki",
|
||||
createEventBusStub(),
|
||||
undefined,
|
||||
{
|
||||
manifest: {
|
||||
id: "paperclipai.plugin-llm-wiki",
|
||||
apiVersion: 1,
|
||||
version: "0.1.0",
|
||||
displayName: "LLM Wiki",
|
||||
description: "Local-file LLM Wiki plugin",
|
||||
author: "Paperclip",
|
||||
categories: ["automation"],
|
||||
capabilities: ["local.folders"],
|
||||
entrypoints: { worker: "./dist/worker.js" },
|
||||
localFolders: [
|
||||
{
|
||||
folderKey: "wiki-root",
|
||||
displayName: "Wiki root",
|
||||
access: "readWrite",
|
||||
requiredDirectories: ["raw", "wiki", "wiki/concepts", ".paperclip"],
|
||||
requiredFiles: ["WIKI.md", "AGENTS.md"],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const configured = await services.localFolders.configure({
|
||||
companyId,
|
||||
folderKey: "wiki-root",
|
||||
path: root,
|
||||
access: "readWrite",
|
||||
requiredDirectories: ["raw", "wiki", "wiki/concepts", ".paperclip"],
|
||||
requiredFiles: ["WIKI.md", "AGENTS.md"],
|
||||
});
|
||||
expect(configured.healthy).toBe(false);
|
||||
expect(configured.missingDirectories).toEqual([]);
|
||||
expect(configured.missingFiles).toEqual(["WIKI.md", "AGENTS.md"]);
|
||||
|
||||
await fs.rm(path.join(root, "raw"), { recursive: true, force: true });
|
||||
await fs.rm(path.join(root, "wiki"), { recursive: true, force: true });
|
||||
await expect(services.localFolders.readText({ companyId, folderKey: "wiki-root", relativePath: "WIKI.md" }))
|
||||
.rejects.toThrow("Local folder is not healthy");
|
||||
await services.localFolders.writeTextAtomic({
|
||||
companyId,
|
||||
folderKey: "wiki-root",
|
||||
relativePath: "WIKI.md",
|
||||
contents: "# Wiki\n",
|
||||
});
|
||||
await services.localFolders.writeTextAtomic({
|
||||
companyId,
|
||||
folderKey: "wiki-root",
|
||||
relativePath: "AGENTS.md",
|
||||
contents: "# Agents\n",
|
||||
});
|
||||
|
||||
const finalStatus = await services.localFolders.status({ companyId, folderKey: "wiki-root" });
|
||||
expect(finalStatus.healthy).toBe(true);
|
||||
await expect(fs.stat(path.join(root, "raw"))).resolves.toMatchObject({});
|
||||
await expect(fs.stat(path.join(root, "wiki/concepts"))).resolves.toMatchObject({});
|
||||
await expect(fs.readFile(path.join(root, "WIKI.md"), "utf8")).resolves.toBe("# Wiki\n");
|
||||
});
|
||||
|
||||
it("rejects worker local-folder access for undeclared manifest keys", async () => {
|
||||
const { companyId } = await seedCompanyAndAgent();
|
||||
const pluginId = randomUUID();
|
||||
await db.insert(plugins).values({
|
||||
id: pluginId,
|
||||
pluginKey: "paperclip.local-folders",
|
||||
packageName: "@paperclip/plugin-local-folders",
|
||||
version: "0.1.0",
|
||||
manifestJson: {
|
||||
id: "paperclip.local-folders",
|
||||
apiVersion: 1,
|
||||
version: "0.1.0",
|
||||
displayName: "Local Folders",
|
||||
description: "Local folder fixture",
|
||||
author: "Paperclip",
|
||||
categories: ["automation"],
|
||||
capabilities: ["local.folders"],
|
||||
entrypoints: { worker: "./dist/worker.js" },
|
||||
localFolders: [
|
||||
{
|
||||
folderKey: "content-root",
|
||||
displayName: "Content root",
|
||||
access: "readWrite",
|
||||
},
|
||||
],
|
||||
},
|
||||
status: "ready",
|
||||
});
|
||||
const services = buildHostServices(
|
||||
db,
|
||||
pluginId,
|
||||
"paperclip.local-folders",
|
||||
createEventBusStub(),
|
||||
undefined,
|
||||
{
|
||||
manifest: {
|
||||
id: "paperclip.local-folders",
|
||||
apiVersion: 1,
|
||||
version: "0.1.0",
|
||||
displayName: "Local Folders",
|
||||
description: "Local folder fixture",
|
||||
author: "Paperclip",
|
||||
categories: ["automation"],
|
||||
capabilities: ["local.folders"],
|
||||
entrypoints: { worker: "./dist/worker.js" },
|
||||
localFolders: [
|
||||
{
|
||||
folderKey: "content-root",
|
||||
displayName: "Content root",
|
||||
access: "readWrite",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
await expect(services.localFolders.configure({
|
||||
companyId,
|
||||
folderKey: "ssh",
|
||||
path: "/tmp",
|
||||
access: "read",
|
||||
})).rejects.toThrow("Local folder key is not declared");
|
||||
await expect(services.localFolders.status({ companyId, folderKey: "ssh" }))
|
||||
.rejects.toThrow("Local folder key is not declared");
|
||||
await expect(services.localFolders.readText({ companyId, folderKey: "ssh", relativePath: "id_rsa" }))
|
||||
.rejects.toThrow("Local folder key is not declared");
|
||||
await expect(services.localFolders.writeTextAtomic({
|
||||
companyId,
|
||||
folderKey: "ssh",
|
||||
relativePath: "id_rsa",
|
||||
contents: "secret",
|
||||
})).rejects.toThrow("Local folder key is not declared");
|
||||
});
|
||||
|
||||
it("resolves plugin-managed projects by stable key without overwriting user edits", async () => {
|
||||
const { companyId } = await seedCompanyAndAgent();
|
||||
const pluginId = randomUUID();
|
||||
await db.insert(plugins).values({
|
||||
id: pluginId,
|
||||
pluginKey: "paperclip.missions",
|
||||
packageName: "@paperclip/plugin-missions",
|
||||
version: "0.1.0",
|
||||
apiVersion: 1,
|
||||
categories: ["automation"],
|
||||
status: "ready",
|
||||
manifestJson: {
|
||||
id: "paperclip.missions",
|
||||
apiVersion: 1,
|
||||
version: "0.1.0",
|
||||
displayName: "Missions",
|
||||
description: "Mission orchestration",
|
||||
author: "Paperclip",
|
||||
categories: ["automation"],
|
||||
capabilities: ["projects.managed"],
|
||||
entrypoints: { worker: "./dist/worker.js" },
|
||||
projects: [{
|
||||
projectKey: "operations",
|
||||
displayName: "Mission Operations",
|
||||
description: "Plugin operation inspection area",
|
||||
status: "in_progress",
|
||||
color: "#14b8a6",
|
||||
settings: { surface: "operations" },
|
||||
}],
|
||||
},
|
||||
});
|
||||
|
||||
const services = buildHostServices(db, pluginId, "paperclip.missions", createEventBusStub());
|
||||
const missing = await services.projects.getManaged({ companyId, projectKey: "operations" });
|
||||
expect(missing.status).toBe("missing");
|
||||
expect(missing.projectId).toBeNull();
|
||||
await expect(
|
||||
db
|
||||
.select()
|
||||
.from(pluginManagedResources)
|
||||
.where(and(
|
||||
eq(pluginManagedResources.companyId, companyId),
|
||||
eq(pluginManagedResources.pluginId, pluginId),
|
||||
eq(pluginManagedResources.resourceKind, "project"),
|
||||
eq(pluginManagedResources.resourceKey, "operations"),
|
||||
)),
|
||||
).resolves.toHaveLength(0);
|
||||
|
||||
const created = await services.projects.reconcileManaged({ companyId, projectKey: "operations" });
|
||||
|
||||
expect(created.status).toBe("created");
|
||||
expect(created.projectId).toEqual(expect.any(String));
|
||||
expect(created.project?.managedByPlugin).toMatchObject({
|
||||
pluginId,
|
||||
pluginKey: "paperclip.missions",
|
||||
pluginDisplayName: "Missions",
|
||||
resourceKind: "project",
|
||||
resourceKey: "operations",
|
||||
});
|
||||
|
||||
await db
|
||||
.update(projects)
|
||||
.set({ name: "Renamed by operator", description: "User-owned text", updatedAt: new Date() })
|
||||
.where(eq(projects.id, created.projectId!));
|
||||
await db
|
||||
.update(plugins)
|
||||
.set({
|
||||
manifestJson: {
|
||||
id: "paperclip.missions",
|
||||
apiVersion: 1,
|
||||
version: "0.2.0",
|
||||
displayName: "Missions",
|
||||
description: "Mission orchestration",
|
||||
author: "Paperclip",
|
||||
categories: ["automation"],
|
||||
capabilities: ["projects.managed"],
|
||||
entrypoints: { worker: "./dist/worker.js" },
|
||||
projects: [{
|
||||
projectKey: "operations",
|
||||
displayName: "Upgraded Default Name",
|
||||
description: "Upgraded default description",
|
||||
status: "planned",
|
||||
color: "#f97316",
|
||||
settings: { surface: "operations", upgraded: true },
|
||||
}],
|
||||
},
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(plugins.id, pluginId));
|
||||
|
||||
const resolved = await services.projects.reconcileManaged({ companyId, projectKey: "operations" });
|
||||
|
||||
expect(resolved.status).toBe("resolved");
|
||||
expect(resolved.projectId).toBe(created.projectId);
|
||||
expect(resolved.project?.name).toBe("Renamed by operator");
|
||||
expect(resolved.project?.description).toBe("User-owned text");
|
||||
expect(resolved.project?.managedByPlugin?.defaultsJson).toMatchObject({
|
||||
displayName: "Upgraded Default Name",
|
||||
settings: { upgraded: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("asserts checkout ownership for run-scoped plugin actions", async () => {
|
||||
const { companyId, agentId } = await seedCompanyAndAgent();
|
||||
const issueId = randomUUID();
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ const mockRegistry = vi.hoisted(() => ({
|
|||
getById: vi.fn(),
|
||||
getByKey: vi.fn(),
|
||||
upsertConfig: vi.fn(),
|
||||
getCompanySettings: vi.fn(),
|
||||
upsertCompanySettings: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockLifecycle = vi.hoisted(() => ({
|
||||
|
|
@ -317,6 +319,61 @@ describe.sequential("scoped plugin API routes", () => {
|
|||
}, 20_000);
|
||||
});
|
||||
|
||||
describe.sequential("plugin local folder routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockRegistry.getCompanySettings.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
function readyLocalFolderPlugin() {
|
||||
mockRegistry.getById.mockResolvedValue({
|
||||
id: pluginId,
|
||||
pluginKey: "paperclip.example",
|
||||
version: "1.0.0",
|
||||
status: "ready",
|
||||
manifestJson: {
|
||||
id: "paperclip.example",
|
||||
capabilities: ["local.folders"],
|
||||
localFolders: [
|
||||
{
|
||||
folderKey: "content-root",
|
||||
displayName: "Content root",
|
||||
access: "readWrite",
|
||||
requiredDirectories: ["docs"],
|
||||
requiredFiles: ["README.md"],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
it("rejects validation for undeclared local folder keys", async () => {
|
||||
readyLocalFolderPlugin();
|
||||
const { app } = await createApp(boardActor());
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/plugins/${pluginId}/companies/${companyA}/local-folders/ssh/validate`)
|
||||
.send({ path: "/tmp" });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("Local folder key is not declared");
|
||||
expect(mockRegistry.upsertCompanySettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects saving undeclared local folder keys", async () => {
|
||||
readyLocalFolderPlugin();
|
||||
const { app } = await createApp(boardActor());
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/plugins/${pluginId}/companies/${companyA}/local-folders/ssh`)
|
||||
.send({ path: "/tmp" });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("Local folder key is not declared");
|
||||
expect(mockRegistry.upsertCompanySettings).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe.sequential("plugin tool and bridge authz", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
|
|
|||
|
|
@ -98,6 +98,7 @@ describe.sequential("plugin scoped API routes", () => {
|
|||
const pluginId = "11111111-1111-4111-8111-111111111111";
|
||||
const companyId = "22222222-2222-4222-8222-222222222222";
|
||||
const agentId = "33333333-3333-4333-8333-333333333333";
|
||||
const peerAgentId = "33333333-3333-4333-8333-333333333334";
|
||||
const runId = "44444444-4444-4444-8444-444444444444";
|
||||
const issueId = "55555555-5555-4555-8555-555555555555";
|
||||
|
||||
|
|
@ -252,6 +253,55 @@ describe.sequential("plugin scoped API routes", () => {
|
|||
}));
|
||||
});
|
||||
|
||||
it("allows non-assignee agents on in-progress required checkout routes without claiming checkout ownership", async () => {
|
||||
const apiRoutes = manifest([
|
||||
{
|
||||
routeKey: "issue.advance",
|
||||
method: "POST",
|
||||
path: "/issues/:issueId/advance",
|
||||
auth: "agent",
|
||||
capability: "api.routes.register",
|
||||
checkoutPolicy: "required-for-agent-in-progress",
|
||||
companyResolution: { from: "issue", param: "issueId" },
|
||||
},
|
||||
]);
|
||||
mockIssueService.getById.mockResolvedValue({
|
||||
id: issueId,
|
||||
companyId,
|
||||
status: "in_progress",
|
||||
assigneeAgentId: agentId,
|
||||
});
|
||||
const { app, workerManager } = await createApp({
|
||||
actor: {
|
||||
type: "agent",
|
||||
agentId: peerAgentId,
|
||||
companyId,
|
||||
runId,
|
||||
source: "agent_key",
|
||||
},
|
||||
plugin: {
|
||||
id: pluginId,
|
||||
pluginKey: apiRoutes.id,
|
||||
status: "ready",
|
||||
manifestJson: apiRoutes,
|
||||
},
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/plugins/${pluginId}/api/issues/${issueId}/advance`)
|
||||
.send({ step: "next" });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockIssueService.assertCheckoutOwner).not.toHaveBeenCalled();
|
||||
expect(workerManager.call).toHaveBeenCalledWith(pluginId, "handleApiRequest", expect.objectContaining({
|
||||
routeKey: "issue.advance",
|
||||
params: { issueId },
|
||||
body: { step: "next" },
|
||||
actor: expect.objectContaining({ actorType: "agent", agentId: peerAgentId, runId }),
|
||||
companyId,
|
||||
}));
|
||||
});
|
||||
|
||||
it("rejects checkout-protected agent routes without a run id before worker dispatch", async () => {
|
||||
const apiRoutes = manifest([
|
||||
{
|
||||
|
|
|
|||
|
|
@ -150,6 +150,23 @@ describe("plugin SDK orchestration contract", () => {
|
|||
).rejects.toThrow("Plugin may only use originKind values under plugin:paperclip.test-orchestration");
|
||||
});
|
||||
|
||||
it("supports generic plugin operation issue visibility in the test harness", async () => {
|
||||
const companyId = randomUUID();
|
||||
const harness = createTestHarness({
|
||||
manifest: manifest(["issues.create"]),
|
||||
});
|
||||
|
||||
const created = await harness.ctx.issues.create({
|
||||
companyId,
|
||||
title: "Background operation",
|
||||
surfaceVisibility: "plugin_operation",
|
||||
originId: "operation-1",
|
||||
});
|
||||
|
||||
expect(created.originKind).toBe("plugin:paperclip.test-orchestration:operation");
|
||||
expect(created.originId).toBe("operation-1");
|
||||
});
|
||||
|
||||
it("enforces checkout and wakeup capabilities in the test harness", async () => {
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue