diff --git a/server/src/__tests__/cases-routes.test.ts b/server/src/__tests__/cases-routes.test.ts index 8d095a25153..d9b5088cc17 100644 --- a/server/src/__tests__/cases-routes.test.ts +++ b/server/src/__tests__/cases-routes.test.ts @@ -229,6 +229,16 @@ describeEmbeddedPostgres("cases routes", () => { await http.get(`/api/cases/${caseRow!.id}/events`).expect(403); }); + it("tells a caller that Cases is off for good and names the setting instead of a substitute", async () => { + const company = await seedCompany("OFFX"); + const response = await request(app(boardActor)).get(`/api/companies/${company.id}/cases`).expect(403); + expect(response.body).toMatchObject({ error: "Cases are disabled", code: "cases_disabled" }); + expect(response.body.remediation).toContain("this denial is final"); + expect(response.body.remediation).toContain("Do not create a task, project or other record in place of the case before the person chooses"); + expect(response.body.remediation).toContain("[Experimental settings](//company/settings/instance/experimental)"); + expect(response.body.remediation).toContain("offer to track the request as an ordinary task instead"); + }); + it("returns not found for unknown case IDs on the former shared routes", async () => { await enableCases(); const http = request(app(boardActor)); diff --git a/server/src/routes/cases.ts b/server/src/routes/cases.ts index db9b0399f0f..ef318a6e835 100644 --- a/server/src/routes/cases.ts +++ b/server/src/routes/cases.ts @@ -48,8 +48,8 @@ import { isUuidLike, } 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 { HttpError, badRequest, conflict, notFound, unprocessable } from "../errors.js"; +import { casesDisabledError } from "../services/cases-disabled.js"; import { validate } from "../middleware/validate.js"; import { instanceSettingsService } from "../services/instance-settings.js"; import { documentAnnotationService, logActivity } from "../services/index.js"; @@ -81,18 +81,7 @@ function eventActorValues(actor: CaseActor) { async function assertCasesEnabled(db: Db) { const experimental = await instanceSettingsService(db).getExperimental(); if (!experimental.enableCases) { - // 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.`, - }); + throw casesDisabledError(); } } diff --git a/server/src/services/case-handoffs.ts b/server/src/services/case-handoffs.ts index 3ca6f2d1767..1883e8ea2fc 100644 --- a/server/src/services/case-handoffs.ts +++ b/server/src/services/case-handoffs.ts @@ -5,6 +5,7 @@ import type { Db } from "@paperclipai/db"; import { agents, caseAccessSources, caseEvents, caseHandoffs, caseIssueLinks, cases, chatConversations, companies, heartbeatRuns, issues, projects } from "@paperclipai/db"; import { handoffCaseSchema, isUuidLike, type DoneCaseHandoffInput, type HandoffCaseInput, type HandoffCaseResult } from "@paperclipai/shared"; import { conflict, forbidden, HttpError, notFound, unprocessable } from "../errors.js"; +import { casesDisabledError } from "./cases-disabled.js"; import { assertAgentRunWriteAllowed } from "../agent-run-cancellation.js"; import { withExternalAdmissionGuard, type ExternalAdmissionTransaction } from "../modules/external-agents/index.js"; import { parseNativeExecutionInput } from "../vendor/paperclip-runner/index.js"; @@ -51,7 +52,7 @@ function companyAllowed(actor: AuthorizationActor, companyId: string) { } async function assertEnabled(db: Db) { - if (!(await instanceSettingsService(db).getExperimental()).enableCases) throw forbidden("Cases are disabled"); + if (!(await instanceSettingsService(db).getExperimental()).enableCases) throw casesDisabledError(); } function result(row: CaseHandoffRow, issue: Pick, actor: AuthorizationActor, replayed: boolean): HandoffCaseResult { diff --git a/server/src/services/cases-disabled.ts b/server/src/services/cases-disabled.ts new file mode 100644 index 00000000000..5e27b199f72 --- /dev/null +++ b/server/src/services/cases-disabled.ts @@ -0,0 +1,29 @@ +import { forbidden } from "../errors.js"; +import { getHiddenSettings } from "./settings-visibility.js"; + +export const CASES_DISABLED_CODE = "cases_disabled"; + +function casesDisabledRemediation() { + // 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 ."; + return "Cases are an experimental feature that is turned off on this instance, so this denial is final and no other route reaches cases or workflows. " + + `${where} Agent credentials cannot change that setting. Do not create a task, project or other record in place of the case before the person chooses. ` + + "Reply now: tell the person Cases is off, say where it is turned on, and offer to track the request as an ordinary task instead."; +} + +/** + * The denial for any case or workflow operation while the instance has Cases off. + * Native tool results carry only the error message, so those callers inline the remediation. + */ +export function casesDisabledError(options: { inlineRemediation?: boolean } = {}) { + const remediation = casesDisabledRemediation(); + return forbidden(options.inlineRemediation ? `Cases are disabled. ${remediation}` : "Cases are disabled", { + code: CASES_DISABLED_CODE, + remediation, + }); +} diff --git a/server/src/services/native-runtime/native-case-tools.integration.test.ts b/server/src/services/native-runtime/native-case-tools.integration.test.ts index b191a4091d0..248d13a858f 100644 --- a/server/src/services/native-runtime/native-case-tools.integration.test.ts +++ b/server/src/services/native-runtime/native-case-tools.integration.test.ts @@ -511,6 +511,9 @@ describe("native case tools through authenticated production routes", () => { const disabled = new PaperclipRunnerToolAuthority(server.db, { ...f, casesEnabled: false }); expect(disabled.definitions().map(tool => tool.name)).not.toContain("read_case"); await expect(disabled.execute({ tool: "read_case", callId: randomUUID(), arguments: {} })).rejects.toThrow("Cases are disabled"); + // Native tool results carry only the message, so it must hold the final, actionable remediation. + await expect(disabled.execute({ tool: "list_workflows", callId: randomUUID(), arguments: {} })).rejects.toThrow( + /^Cases are disabled\. .*this denial is final.*\[Experimental settings\]\(\/\/company\/settings\/instance\/experimental\).*offer to track the request as an ordinary task instead\.$/); const review = new PaperclipRunnerToolAuthority(server.db, { ...f, casesEnabled: true, nativeReview: { nativeReviewInteractionId: randomUUID(), nativeReviewDecisionId: randomUUID() } }); expect(review.definitions().map(tool => tool.name)).not.toContain("read_case"); diff --git a/server/src/services/native-runtime/native-case-tools.ts b/server/src/services/native-runtime/native-case-tools.ts index 26960c3a6c4..b493d332a06 100644 --- a/server/src/services/native-runtime/native-case-tools.ts +++ b/server/src/services/native-runtime/native-case-tools.ts @@ -4,7 +4,8 @@ import { cases, type Db } from "@paperclipai/db"; import { z } from "zod"; import { caseInitialDocumentsSchema, caseWorkflowDocumentTextSchema, isUuidLike } from "@paperclipai/shared"; import { patchCaseSchema } from "../../routes/cases-schemas.js"; -import { badRequest, forbidden, notFound } from "../../errors.js"; +import { badRequest, notFound } from "../../errors.js"; +import { casesDisabledError } from "../cases-disabled.js"; import { assertActorCanReadCase } from "../case-access.js"; import type { AuthorizationActor } from "../authorization.js"; import { instanceSettingsService } from "../instance-settings.js"; @@ -58,7 +59,7 @@ export async function executeNativeCaseTool(input: { callApi: (receiptKey: string, request: RunnerApiCall) => Promise; }) { if (input.binding.casesEnabled !== true || !(await instanceSettingsService(input.db).getExperimental()).enableCases) { - throw forbidden("Cases are disabled"); + throw casesDisabledError({ inlineRemediation: true }); } const schema = schemas[input.tool as keyof typeof schemas]; if (!schema) throw badRequest("Unknown native case operation"); diff --git a/server/src/services/native-runtime/native-workflow-tools.ts b/server/src/services/native-runtime/native-workflow-tools.ts index d769a50012f..54cc3fbcffa 100644 --- a/server/src/services/native-runtime/native-workflow-tools.ts +++ b/server/src/services/native-runtime/native-workflow-tools.ts @@ -2,7 +2,8 @@ import { createHash } from "node:crypto"; import { z } from "zod"; import { createWorkflowSchema, updateWorkflowSchema, listWorkflowsSchema, readWorkflowQuerySchema } from "@paperclipai/shared"; import type { Db } from "@paperclipai/db"; -import { forbidden, badRequest } from "../../errors.js"; +import { badRequest } from "../../errors.js"; +import { casesDisabledError } from "../cases-disabled.js"; import { requireCasesEnabled } from "../workflows.js"; import { runnerApiCallSchema, type RunnerApiCall } from "./runner-api-client.js"; @@ -20,7 +21,7 @@ export async function executeNativeWorkflowTool(input: { db: Db; binding: { companyId: string; casesEnabled?: boolean }; tool: string; arguments: unknown; callApi: (receiptKey: string, request: RunnerApiCall) => Promise; }) { - if (input.binding.casesEnabled !== true) throw forbidden("Cases are disabled"); + if (input.binding.casesEnabled !== true) throw casesDisabledError({ inlineRemediation: true }); await requireCasesEnabled(input.db); const schema = schemas[input.tool as keyof typeof schemas]; if (!schema) throw badRequest("Unknown workflow operation"); diff --git a/server/src/services/native-runtime/paperclip-runner-tool-authority.ts b/server/src/services/native-runtime/paperclip-runner-tool-authority.ts index 11fe6b14140..1afe09e4ea1 100644 --- a/server/src/services/native-runtime/paperclip-runner-tool-authority.ts +++ b/server/src/services/native-runtime/paperclip-runner-tool-authority.ts @@ -73,6 +73,7 @@ import { getSecretProvider } from "../../secrets/provider-registry.js"; import { runnerApiSensitiveResponse } from "./runner-api-sensitive-response.js"; import { workspaceFileResourceService } from "../workspace-file-resources.js"; import { badRequest, forbidden, notFound, HttpError } from "../../errors.js"; +import { casesDisabledError } from "../cases-disabled.js"; import { runnerApiCatalog, searchRunnerApi } from "./runner-api-catalog.js"; import { assertRunnerApiDispatchable, executeRunnerApi, validateRunnerApiCall, RUNNER_API_MAX_BYTES, type RunnerApiCall, type RunnerApiFile, type RunnerApiIo } from "./runner-api-client.js"; import { acquireRunnerApiResponseSlot, runnerApiCompanyCaptureMaxBytes, RUNNER_API_RESPONSE_MAX_BYTES, RUNNER_API_RESPONSE_RUN_MAX_BYTES, RunnerApiResponseLimitError } from "./runner-api-response-limits.js"; @@ -629,7 +630,7 @@ export class PaperclipRunnerToolAuthority { if ((call.tool === "handoff_case" || call.tool === "create_case") && !this.binding.nativeReview) { if (this.binding.workMode && this.binding.workMode !== "standard") throw forbidden("Case tools require standard work mode"); const create = call.tool === "create_case" ? createNativeCaseSchema.parse(call.arguments) : null; - if (this.binding.casesEnabled !== true) throw forbidden("Cases are disabled"); + if (this.binding.casesEnabled !== true) throw casesDisabledError({ inlineRemediation: true }); if (this.binding.museRuntime) await this.binding.assertBridgeAuthority?.(); // Both operations use the principal-scoped handoff ledger. A committed // receipt stays replayable after ownership moved; fresh work reauthorizes. diff --git a/server/src/services/workflows.ts b/server/src/services/workflows.ts index d2a3fb7e875..b73b0ef0493 100644 --- a/server/src/services/workflows.ts +++ b/server/src/services/workflows.ts @@ -3,14 +3,15 @@ import { createHash } from "node:crypto"; import { workflows, type Db } from "@paperclipai/db"; import type { z } from "zod"; import type { createWorkflowSchema, updateWorkflowSchema } from "@paperclipai/shared"; -import { conflict, forbidden, notFound } from "../errors.js"; +import { conflict, notFound } from "../errors.js"; +import { casesDisabledError } from "./cases-disabled.js"; import { instanceSettingsService } from "./instance-settings.js"; import { authorizeCaseCreation } from "./case-mutation-authorization.js"; import type { AuthorizationActor } from "./authorization.js"; import { persistActivity, publishActivity } from "./activity-log.js"; export async function requireCasesEnabled(db: Db) { - if (!(await instanceSettingsService(db).getExperimental()).enableCases) throw forbidden("Cases are disabled"); + if (!(await instanceSettingsService(db).getExperimental()).enableCases) throw casesDisabledError(); } export async function getWorkflow(db: Db, companyId: string, id: string) {