diff --git a/server/src/__tests__/board-access-denial.test.ts b/server/src/__tests__/board-access-denial.test.ts index 2d9f681769f..8372b1825f0 100644 --- a/server/src/__tests__/board-access-denial.test.ts +++ b/server/src/__tests__/board-access-denial.test.ts @@ -53,6 +53,8 @@ describe("board-only denial", () => { ["post", `/api/environments/${AGENT_ID}/probe`, "[Open environment settings](//company/settings/instance/environments)"], ["get", `/api/environment-leases/${AGENT_ID}`, "[Open environment settings](//company/settings/instance/environments)"], ["get", `/api/environment-custom-image-setup-sessions/${AGENT_ID}`, "[Open environment settings](//company/settings/instance/environments)"], + ["get", "/api/instance/settings/experimental", "[Open Experimental settings](//company/settings/instance/experimental)"], + ["patch", "/api/instance/settings/experimental", "[Open Experimental settings](//company/settings/instance/experimental)"], ] as const)("tells an agent %s %s is final and names the board page", async (method, path, link) => { const res = await request(createApp(agentActor))[method](path).expect(403); expect(res.body.error).toBe("Board access required"); @@ -149,6 +151,19 @@ describe("board-only denial", () => { } }); + it("does not link Experimental settings while the operator hides that page", async () => { + const previous = process.env.PAPERCLIP_HIDDEN_SETTINGS; + process.env.PAPERCLIP_HIDDEN_SETTINGS = "instance.experimental"; + try { + const res = await request(createApp(agentActor)).patch("/api/instance/settings/experimental").expect(403); + expect(res.body.remediation).toContain("this denial is final"); + expect(res.body.remediation).not.toContain("/instance/experimental"); + } finally { + if (previous === undefined) delete process.env.PAPERCLIP_HIDDEN_SETTINGS; + else process.env.PAPERCLIP_HIDDEN_SETTINGS = previous; + } + }); + it("applies through the org-access guard", async () => { const res = await request(createApp(agentActor, assertBoardOrgAccess)).delete(`/api/companies/${COMPANY_ID}`).expect(403); expect(res.body.code).toBe(BOARD_ACCESS_REQUIRED_CODE); diff --git a/server/src/__tests__/cases-routes.test.ts b/server/src/__tests__/cases-routes.test.ts index 8aefa71e864..8d095a25153 100644 --- a/server/src/__tests__/cases-routes.test.ts +++ b/server/src/__tests__/cases-routes.test.ts @@ -202,7 +202,22 @@ describeEmbeddedPostgres("cases routes", () => { }).returning(); const http = request(app(boardActor)); - await http.get(`/api/companies/${company.id}/cases`).expect(403); + const denied = await http.get(`/api/companies/${company.id}/cases`).expect(403); + // The denial is final and names the page where an instance admin turns Cases on. + expect(denied.body).toMatchObject({ error: "Cases are disabled", code: "cases_disabled" }); + expect(denied.body.remediation).toContain("[Experimental settings](//company/settings/instance/experimental)"); + expect(denied.body.remediation).toContain("Agent credentials cannot change that setting"); + // Hiding only the Cases switch keeps the page, so the denial must not send the person there. + const previousHidden = process.env.PAPERCLIP_HIDDEN_SETTINGS; + process.env.PAPERCLIP_HIDDEN_SETTINGS = "instance.experimental.enableCases"; + try { + const hiddenDenial = await http.get(`/api/companies/${company.id}/cases`).expect(403); + expect(hiddenDenial.body.remediation).toContain("An instance operator controls it."); + expect(hiddenDenial.body.remediation).not.toContain("/instance/experimental"); + } finally { + if (previousHidden === undefined) delete process.env.PAPERCLIP_HIDDEN_SETTINGS; + else process.env.PAPERCLIP_HIDDEN_SETTINGS = previousHidden; + } await http.get(`/api/companies/${company.id}/cases/types`).expect(403); await http.post(`/api/companies/${company.id}/cases`).send({ caseType: "bug", title: "Bug" }).expect(403); await http.get(`/api/cases/${caseRow!.id}`).expect(403); diff --git a/server/src/__tests__/openapi-routes.test.ts b/server/src/__tests__/openapi-routes.test.ts index 1678e78207e..862587653c3 100644 --- a/server/src/__tests__/openapi-routes.test.ts +++ b/server/src/__tests__/openapi-routes.test.ts @@ -7,7 +7,7 @@ import { describe, expect, it } from "vitest"; import { MUSE_ASSET_VERSION, MUSE_PUBLIC_ROUTES } from "@paperclipai/shared"; import { COMPANY_IMPORT_TRANSFERS_ROUTE_PATH } from "@paperclipai/shared/company-import-transfer"; import { errorHandler } from "../middleware/index.js"; -import { buildOpenApiSpec, openApiRoutes } from "../routes/openapi.js"; +import { buildOpenApiSpec, openApiRoutes, applyPluginSettingsVisibility, buildOpenApiDocument } from "../routes/openapi.js"; import { mcpDiscoveryDocuments } from "../services/public-mcp/discovery.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -653,6 +653,25 @@ describe("openapi routes", () => { } }); + it("names the Experimental settings page where an instance admin turns a feature on", async () => { + const res = await request(createApp()).get("/api/openapi.json"); + const experimental = res.body.paths["/api/instance/settings/experimental"]; + for (const operation of [experimental.get, experimental.patch]) { + expect(operation.description).toContain("[Experimental settings](//company/settings/instance/experimental)"); + expect(operation.description).toContain("Agent credentials cannot read or change these settings"); + expect(operation.description).toContain("Cases via enableCases"); + } + }); + + it("names the hosting operator instead of a hidden Experimental settings page", () => { + const hidden = applyPluginSettingsVisibility(buildOpenApiDocument(), new Set(["instance.experimental"])); + const experimental = hidden.paths["/api/instance/settings/experimental"]; + for (const operation of [experimental.get, experimental.patch]) { + expect(operation.description).toContain("controlled by the hosting operator"); + expect(operation.description).not.toContain("/instance/experimental"); + } + }); + it("tells agents that archiving keeps a status card and where a person restores it", async () => { const res = await request(createApp()).get("/api/openapi.json"); const patch = res.body.paths["/api/status-cards/{id}"].patch; diff --git a/server/src/routes/authz.ts b/server/src/routes/authz.ts index 83dce412aa6..4862ca61593 100644 --- a/server/src/routes/authz.ts +++ b/server/src/routes/authz.ts @@ -73,6 +73,9 @@ const BOARD_CONTROL_PAGES: Array<[RegExp, (match: RegExpExecArray, req: Request) return tab ? [`the agent's ${tab[0]}`, `//agents/${match[1]}/${tab[1]}`] : ["the agent's profile", `//agents/${match[1]}`]; }], [/^\/companies\/[^/]+(?:\/(?:archive|branding|settings))?\/?$/, () => ["company settings", "//company/settings"]], + // An operator can hide the Experimental page, so link it only while it is visible. + [/^\/instance\/settings\/experimental\/?$/, () => getHiddenSettings().has("instance.experimental") + ? null : ["Experimental settings", "//company/settings/instance/experimental"]], ]; // Plugin management pages. A plugin id or key segment opens that plugin's settings diff --git a/server/src/routes/cases.ts b/server/src/routes/cases.ts index e6897b69a57..db9b0399f0f 100644 --- a/server/src/routes/cases.ts +++ b/server/src/routes/cases.ts @@ -49,6 +49,7 @@ import { } from "@paperclipai/shared"; import { formatAttachmentSize, MAX_ATTACHMENT_BYTES, normalizeContentType } from "../attachment-types.js"; import { HttpError, badRequest, conflict, forbidden, notFound, unprocessable } from "../errors.js"; +import { getHiddenSettings } from "../services/settings-visibility.js"; import { validate } from "../middleware/validate.js"; import { instanceSettingsService } from "../services/instance-settings.js"; import { documentAnnotationService, logActivity } from "../services/index.js"; @@ -80,7 +81,18 @@ function eventActorValues(actor: CaseActor) { async function assertCasesEnabled(db: Db) { const experimental = await instanceSettingsService(db).getExperimental(); if (!experimental.enableCases) { - throw forbidden("Cases are disabled"); + // An operator can hide the Experimental page, so link it only while it is visible. + // Hiding only the Cases key keeps the page but drops its switch, so check both. + const hidden = getHiddenSettings(); + const where = hidden.has("instance.experimental") || hidden.has("instance.experimental.enableCases") + ? "An instance operator controls it." + : "An instance admin turns them on with Cases in [Experimental settings](//company/settings/instance/experimental), " + + "with this conversation's company prefix as ."; + throw forbidden("Cases are disabled", { + code: "cases_disabled", + remediation: "Cases are an experimental feature that is turned off on this instance, so this denial is final and no other route reaches cases. " + + `${where} Agent credentials cannot change that setting. Reply now.`, + }); } } diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index a621ddb0e65..eda397c295c 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -5422,12 +5422,21 @@ const PLUGIN_MANAGED_ROUTINE_OPERATOR_GUIDANCE = + "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. */ +/** Picks the plugin-managed routine and experimental settings guidance that matches 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); + if (hidden.has("instance.plugins")) { + 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); + } + } + } + if (hidden.has("instance.experimental")) { + const experimental = document.paths?.["/api/instance/settings/experimental"]; + for (const operation of [experimental?.get, experimental?.patch]) { + if (typeof operation?.description === "string") { + operation.description = operation.description.replace(EXPERIMENTAL_SETTINGS_PAGE, EXPERIMENTAL_SETTINGS_OPERATOR_GUIDANCE); + } } } return document; @@ -7261,6 +7270,15 @@ registry.registerPath({ // ─── Instance settings ──────────────────────────────────────────────────────── +// Agents cannot read or write experimental settings; the person turns a feature on in the UI. +const EXPERIMENTAL_SETTINGS_PAGE = "Instance experimental features (for example Cases via enableCases) are turned on or off by an instance admin in " + + "Experimental settings at //company/settings/instance/experimental. Agent credentials cannot read or change these settings, " + + "so do not claim a change: link that page as [Experimental settings](//company/settings/instance/experimental), name the feature's setting, and reply."; +// When the hosting operator hides the Experimental page, it redirects away, so name the operator instead. +const EXPERIMENTAL_SETTINGS_OPERATOR_GUIDANCE = "Instance experimental features (for example Cases via enableCases) are controlled by the hosting operator on this instance, " + + "so no Paperclip page offers that change. Agent credentials cannot read or change these settings, " + + "so do not claim a change or search for another route: reply that the hosting operator controls it."; + registry.registerPath({ method: "get", path: "/api/instance/settings", @@ -7300,7 +7318,7 @@ registry.registerPath({ path: "/api/instance/settings/experimental", tags: ["instance"], summary: "Get experimental instance settings", - description: "Agent Chat is always on for every person on every instance and needs no setup. enableAgentChat is a retired compatibility key: it always reads true and written values are ignored. If someone asks to enable Agent Chat, tell them it is already on, since they are using it now; do not send them to a settings toggle.", + description: `${EXPERIMENTAL_SETTINGS_PAGE} Agent Chat is always on for every person on every instance and needs no setup. enableAgentChat is a retired compatibility key: it always reads true and written values are ignored. If someone asks to enable Agent Chat, tell them it is already on, since they are using it now; do not send them to a settings toggle.`, responses: { 200: r.ok(), 401: r.unauthorized }, }); @@ -7309,7 +7327,7 @@ registry.registerPath({ path: "/api/instance/settings/experimental", tags: ["instance"], summary: "Update experimental instance settings", - description: "Requires instance-admin access or a trusted Cloud stack admin. Operator-hidden settings and Cloud-managed feature restrictions still apply. This does not grant access to other instance administration operations. Agent Chat is always on for every person on every instance and needs no setup. enableAgentChat is a retired compatibility key: it always reads true and written values are ignored. If someone asks to enable Agent Chat, tell them it is already on, since they are using it now; do not send them to a settings toggle.", + description: `${EXPERIMENTAL_SETTINGS_PAGE} Requires instance-admin access or a trusted Cloud stack admin. Operator-hidden settings and Cloud-managed feature restrictions still apply. This does not grant access to other instance administration operations. Agent Chat is always on for every person on every instance and needs no setup. enableAgentChat is a retired compatibility key: it always reads true and written values are ignored. If someone asks to enable Agent Chat, tell them it is already on, since they are using it now; do not send them to a settings toggle.`, request: { body: jsonBody(patchInstanceExperimentalSettingsSchema) }, responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden }, });