diff --git a/server/src/__tests__/board-access-denial.test.ts b/server/src/__tests__/board-access-denial.test.ts index 04da1f29281..603dc21fb9c 100644 --- a/server/src/__tests__/board-access-denial.test.ts +++ b/server/src/__tests__/board-access-denial.test.ts @@ -48,6 +48,8 @@ describe("board-only denial", () => { ["get", "/api/plugins/ui-contributions", "[Open plugin settings](//company/settings/instance/plugins)"], ["post", `/api/projects/${AGENT_ID}/access-members`, `[Open the project's access settings](//projects/${AGENT_ID}/configuration)`], ["delete", `/api/projects/${AGENT_ID}/access-members/m-1`, `[Open the project's access settings](//projects/${AGENT_ID}/configuration)`], + ["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"); @@ -122,6 +124,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..4877bb06567 100644 --- a/server/src/__tests__/cases-routes.test.ts +++ b/server/src/__tests__/cases-routes.test.ts @@ -202,7 +202,11 @@ 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"); 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..fa4d72949ac 100644 --- a/server/src/__tests__/openapi-routes.test.ts +++ b/server/src/__tests__/openapi-routes.test.ts @@ -653,6 +653,16 @@ 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("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 4fab0967fa7..117a384b436 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..070be9828bb 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,16 @@ 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. + const where = getHiddenSettings().has("instance.experimental") + ? "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 76d45918345..a44ebb223ab 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -7261,6 +7261,11 @@ 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."; + registry.registerPath({ method: "get", path: "/api/instance/settings", @@ -7300,7 +7305,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 +7314,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 }, });