diff --git a/server/src/__tests__/plugin-managed-routine-guidance.test.ts b/server/src/__tests__/plugin-managed-routine-guidance.test.ts new file mode 100644 index 00000000000..1dc3ca3474e --- /dev/null +++ b/server/src/__tests__/plugin-managed-routine-guidance.test.ts @@ -0,0 +1,51 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { expect, it } from "vitest"; +import { buildOpenApiDocument, applyPluginSettingsVisibility } from "../routes/openapi.js"; +import { runnerApiCatalog, searchRunnerApi } from "../services/native-runtime/runner-api-catalog.js"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."); +const settingsLink = "[plugin settings](//company/settings/instance/plugins/)"; + +// Queries an agent searched after reading a routine whose `managedByPlugin` was set. +it.each([ + "plugin managed routine", + "plugin routine managed definition", + "managedByPlugin", + "detach plugin managed routine", + "update routine", +])("search %j explains who owns a plugin-managed routine and links its settings", (query) => { + const found = searchRunnerApi({ query, limit: 10 }).results + .filter(row => row.path === "/api/routines/{id}" && (row.method === "GET" || row.method === "PATCH")); + expect(found.length, query).toBeGreaterThan(0); + for (const operation of found) { + expect(operation.description).toContain("managedByPlugin"); + expect(operation.description).toContain(settingsLink); + expect(operation.description).toContain("Do not rewrite, detach or duplicate"); + } +}); + +it("links a plugin settings page the board UI mounts", () => { + const appRoutes = readFileSync(path.join(repoRoot, "ui/src/App.tsx"), "utf8"); + expect(appRoutes).toContain('path="company/settings/instance/plugins/:pluginId"'); +}); + +it.each(["GET /api/routines/{id}", "PATCH /api/routines/{id}"])("%s carries the full plugin-managed routine guidance", (operationId) => { + const description = runnerApiCatalog().find(row => row.operationId === operationId)?.description ?? ""; + expect(description).toContain("managedByPlugin"); + expect(description).toContain(settingsLink); + expect(description).toContain("Do not rewrite, detach or duplicate"); + expect(description).toContain("reply now with that link"); +}); + +it("names the hosting operator instead of linking plugin settings when the operator hides them", () => { + const document = applyPluginSettingsVisibility(buildOpenApiDocument(), new Set(["instance.plugins"])); + for (const operation of [document.paths["/api/routines/{id}"].get, document.paths["/api/routines/{id}"].patch]) { + expect(operation.description).not.toContain("/company/settings/instance/plugins"); + expect(operation.description).toContain("the hosting operator controls this plugin's settings"); + expect(operation.description).toContain("Do not rewrite, detach or duplicate"); + } + const visible = applyPluginSettingsVisibility(buildOpenApiDocument(), new Set()); + expect(visible.paths["/api/routines/{id}"].get.description).toContain(settingsLink); +}); diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 2f438032a26..8b66811fe9d 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -11,6 +11,7 @@ import { import { Router } from "express"; import { subscriptionPriceSchema, mergeSubscriptionsSchema } from "@paperclipai/shared"; import { z } from "zod"; +import { getHiddenSettings } from "../services/settings-visibility.js"; import { gitHubRepositoryPageQuerySchema, toggleAllGitHubRepositoriesSchema, @@ -5341,11 +5342,42 @@ registry.registerPath({ responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, }); +// A plugin-managed routine is still an ordinary routine row, so agents read it +// through the routine routes; say there who owns it and where it is changed. +const PLUGIN_MANAGED_ROUTINE_GUIDANCE = + "A routine with a non-null `managedByPlugin` is a plugin-managed routine: that plugin created it and owns its definition " + + "(title, instructions, schedule, assignee), and the plugin can reset it to its manifest defaults. " + + "It is changed through the settings that plugin exposes, which a board user opens at " + + "[plugin settings](//company/settings/instance/plugins/), with `managedByPlugin.pluginId` as " + + "and this conversation's company prefix as . " + + "Do not rewrite, detach or duplicate a plugin-managed routine to imitate a change, and do not search for another route: " + + "reply now with that link and the routine's page link."; +// When the hosting operator hides plugin management, that settings page redirects +// away, so the guidance names the operator instead of linking it. +const PLUGIN_MANAGED_ROUTINE_OPERATOR_GUIDANCE = + "A routine with a non-null `managedByPlugin` is a plugin-managed routine: that plugin created it and owns its definition " + + "(title, instructions, schedule, assignee), and the plugin can reset it to its manifest defaults. " + + "On this instance the hosting operator manages plugins, so no Paperclip page offers that change. " + + "Do not rewrite, detach or duplicate a plugin-managed routine to imitate a change, and do not search for another route: " + + "reply now that the hosting operator controls this plugin's settings, with the routine's page link."; + +/** Picks the plugin-managed routine guidance that matches plugin settings visibility. */ +export function applyPluginSettingsVisibility(document: any, hidden: ReadonlySet = getHiddenSettings()): any { + if (!hidden.has("instance.plugins")) return document; + for (const operation of [document.paths?.["/api/routines/{id}"]?.get, document.paths?.["/api/routines/{id}"]?.patch]) { + if (typeof operation?.description === "string") { + operation.description = operation.description.replace(PLUGIN_MANAGED_ROUTINE_GUIDANCE, PLUGIN_MANAGED_ROUTINE_OPERATOR_GUIDANCE); + } + } + return document; +} + registry.registerPath({ method: "get", path: "/api/routines/{id}", tags: ["routines"], summary: "Get a routine", + description: `Routine details including triggers. ${PLUGIN_MANAGED_ROUTINE_GUIDANCE}`, request: { params: z.object({ id: z.string() }) }, responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, }); @@ -5355,6 +5387,7 @@ registry.registerPath({ path: "/api/routines/{id}", tags: ["routines"], summary: "Update a routine", + description: `Agents can update only routines assigned to them and cannot reassign them. ${PLUGIN_MANAGED_ROUTINE_GUIDANCE}`, request: { params: z.object({ id: z.string() }), body: jsonBody(updateRoutineSchema), @@ -13357,7 +13390,7 @@ for (const [method, path, body] of experimentalApiPaths) { // eslint-disable-next-line @typescript-eslint/no-explicit-any export function buildOpenApiDocument(): any { - return applyDocumentFixups({ + return applyPluginSettingsVisibility(applyDocumentFixups({ openapi: "3.0.0", info: { title: "Paperclip API", @@ -13367,7 +13400,7 @@ export function buildOpenApiDocument(): any { servers: [{ url: "/" }], components: registry.buildComponents(), paths: registry.buildPaths(), - }); + })); } export const buildOpenApiSpec = buildOpenApiDocument;