diff --git a/docs/guides/board-operator/experimental-features.md b/docs/guides/board-operator/experimental-features.md index 09f486e79ce..f978888b545 100644 --- a/docs/guides/board-operator/experimental-features.md +++ b/docs/guides/board-operator/experimental-features.md @@ -23,8 +23,11 @@ controls they manage; empty developer and legacy sections are omitted. Hidden controls retain their configured values. On Paperclip Cloud staging (`*.staging.paperclip.app`), instance admins can -view and change experimental settings. Cloud-managed feature values act as -defaults until changed, and saved choices survive reloads. Production Cloud +view and change experimental settings that Cloud has not enabled. A feature +enabled by Cloud stays on for every managed stack, including staging, even when +an older saved tenant value is false. Its control is locked, and attempts to +disable it through the settings API return `403`. Cloud-disabled experiments +can still be changed on staging, and saved choices survive reloads. Production Cloud keeps its managed values and hosting restrictions. Staging only permits changes to an explicit list of feature controls. Cloud access and execution policies, including managed-sandbox-only execution, remain managed on staging. diff --git a/maps/agent-access-map/instance-operations.md b/maps/agent-access-map/instance-operations.md index c82e6e1dc33..c039e8fea8a 100644 --- a/maps/agent-access-map/instance-operations.md +++ b/maps/agent-access-map/instance-operations.md @@ -88,7 +88,10 @@ actor without receiving other instance-admin powers. This uses the current stack role on each request; company membership alone does not grant it. Members, Support users, agents, and ordinary self-hosted sessions remain denied. Cloud staging exposes experimental controls to instance admins and stack Admins. -It permits saved overrides of Cloud feature defaults. This exception requires a managed instance +It permits saved overrides of Cloud-disabled experimental defaults. Cloud-enabled +features remain true on every managed stack, including staging, and the settings +service rejects attempts to disable them with 403 before saving. +This exception requires a managed instance and a provisioner-supplied `*.staging.paperclip.app` primary hostname; request headers do not establish staging. Ordinary board members and agents still cannot mutate instance settings. Only an explicit feature allowlist is editable on staging. Cloud diff --git a/maps/feature-map/instance-operations.md b/maps/feature-map/instance-operations.md index 7591aa8d079..3a8605cd30a 100644 --- a/maps/feature-map/instance-operations.md +++ b/maps/feature-map/instance-operations.md @@ -61,7 +61,11 @@ Automated: [experimental settings](../../ui/src/pages/InstanceExperimentalSettin Manual: Change a harmless experimental setting, reload, and confirm the affected route honors it. Back up a disposable instance, inspect the reported file and metadata, and restore using the documented procedure into a second isolated target. Verify a known record after restoration. Cloud staging: On a managed `*.staging.paperclip.app` instance, verify experiments -are visible and editable by a Cloud stack Admin or instance admin, and changes survive reload. +are visible, Cloud-enabled controls are locked on, and other experiments are +editable by a Cloud stack Admin or instance admin with changes surviving reload. +With an old stored false value and a Cloud true value, verify the effective +setting is true and an API attempt to disable it returns 403 without saving. +Repeat the enabled-feature check on production and managed stacks without a hostname. As a stack Admin, verify a harmless experiment saves while general instance settings and task-drain operations remain denied. Demote that user to Member and verify the next experimental write is denied. diff --git a/server/src/__tests__/instance-settings-managed-overlay.test.ts b/server/src/__tests__/instance-settings-managed-overlay.test.ts index 60e7fbc8247..5d29183a3d0 100644 --- a/server/src/__tests__/instance-settings-managed-overlay.test.ts +++ b/server/src/__tests__/instance-settings-managed-overlay.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import type { Db } from "@paperclipai/db"; +import { INSTANCE_FEATURE_CATALOG } from "@paperclipai/shared"; import { applyManagedExperimentalOverlay, instanceSettingsService, @@ -83,6 +84,40 @@ describe("applyManagedExperimentalOverlay", () => { }); describe("instanceSettingsService managed overlay", () => { + it.each(["team.staging.paperclip.app", "team.paperclip.app", undefined])( + "enforces every Cloud-enabled flag over old tenant values on %s", + async (primaryHost) => { + const features = Object.fromEntries(Object.entries(INSTANCE_FEATURE_CATALOG) + .filter(([, entry]) => entry.tier === "managed") + .map(([key]) => [key, true])); + const stored = Object.fromEntries(Object.keys(features).map((key) => [key, false])); + const { db, persistedSets } = stubDb(settingsRow(stored)); + const runtimeEnv = { + PAPERCLIP_PRIMARY_HOST: primaryHost, + ...managedEnv(JSON.stringify({ + v: 1, mode: "cloud", catalogVersion: "test", + features, plugins: { autoInstall: [] }, + })), + }; + const service = instanceSettingsService(db, { runtimeEnv }); + expect(await service.getExperimental()).toMatchObject(features); + expect((await service.get()).experimental).toMatchObject(features); + expect((await service.getExperimental()).managedKeys).toMatchObject( + Object.fromEntries(Object.keys(features) + .filter((key) => key !== "enableApps" && key !== "enableMcpAggregators") + .map((key) => [key, { managed: true, managedBy: "paperclip-cloud" }])), + ); + for (const key of Object.keys(features)) { + await expect(service.updateExperimental({ [key]: false })).rejects.toMatchObject({ + status: 403, + details: { code: "settings_operator_managed" }, + }); + } + expect(persistedSets).toHaveLength(0); + expect(await instanceSettingsService(db, { runtimeEnv }).getExperimental()).toMatchObject(features); + }, + ); + it("keeps sandbox policy enforced on staging after a tenant tries to disable it", async () => { const row = settingsRow({ enableManagedSandboxOnly: false }); const { db, persistedSets } = stubDb(row); @@ -94,10 +129,10 @@ describe("instanceSettingsService managed overlay", () => { })), }; const service = instanceSettingsService(db, { runtimeEnv }); - const updated = await service.updateExperimental({ enableManagedSandboxOnly: false }); - Object.assign(row, persistedSets.at(-1)); + await expect(service.updateExperimental({ enableManagedSandboxOnly: false })).rejects.toMatchObject({ status: 403 }); + expect(persistedSets).toHaveLength(0); const restored = await instanceSettingsService(db, { runtimeEnv }).getExperimental(); - for (const experimental of [updated.experimental, restored]) { + for (const experimental of [await service.getExperimental(), restored]) { expect(experimental).toMatchObject({ enableManagedSandboxOnly: true, managedKeys: { enableManagedSandboxOnly: { managed: true, managedBy: "paperclip-cloud" } }, @@ -109,7 +144,7 @@ describe("instanceSettingsService managed overlay", () => { } }); - it("lets staging override managed defaults, persists toggles, and preserves other defaults", async () => { + it("lets staging override disabled defaults while keeping Cloud-enabled features managed", async () => { const row = settingsRow({}); const { db, persistedSets } = stubDb(row); const runtimeEnv = { @@ -121,16 +156,20 @@ describe("instanceSettingsService managed overlay", () => { PAPERCLIP_PRIMARY_HOST: "team.staging.paperclip.app", }; const service = instanceSettingsService(db, { runtimeEnv }); + const managedKeys = { + enableStatusCards: { managed: true, managedBy: "paperclip-cloud" }, + enableNativeRunner: { managed: true, managedBy: "paperclip-cloud" }, + }; expect(await service.getExperimental()).toMatchObject({ - enableStatusCards: true, enableChatConnectors: false, enableNativeRunner: true, managedKeys: {}, + enableStatusCards: true, enableChatConnectors: false, enableNativeRunner: true, managedKeys, }); expect(persistedSets).toHaveLength(0); for (const enabled of [true, false]) { - const updated = await service.updateExperimental({ enableChatConnectors: enabled, enableNativeRunner: enabled }); + const updated = await service.updateExperimental({ enableChatConnectors: enabled }); Object.assign(row, persistedSets.at(-1)); const expected = { - enableStatusCards: true, enableChatConnectors: enabled, enableNativeRunner: enabled, managedKeys: {}, + enableStatusCards: true, enableChatConnectors: enabled, enableNativeRunner: true, managedKeys, }; expect(updated.experimental).toMatchObject(expected); const restored = instanceSettingsService(db, { runtimeEnv }); @@ -139,6 +178,7 @@ describe("instanceSettingsService managed overlay", () => { expect(row.experimental).not.toHaveProperty("managedKeys"); expect(row.experimental).not.toHaveProperty("enableStatusCards"); } + expect((await service.updateExperimental({ enableNativeRunner: true })).experimental.enableNativeRunner).toBe(true); const changedDefaults = instanceSettingsService(db, { runtimeEnv: { ...runtimeEnv, @@ -149,7 +189,7 @@ describe("instanceSettingsService managed overlay", () => { }), } }); expect(await changedDefaults.getExperimental()).toMatchObject({ - enableStatusCards: false, enableChatConnectors: false, enableNativeRunner: false, + enableStatusCards: false, enableChatConnectors: true, enableNativeRunner: true, }); const production = instanceSettingsService(db, { runtimeEnv: { ...runtimeEnv, PAPERCLIP_PRIMARY_HOST: "team.paperclip.app" } }); @@ -172,7 +212,7 @@ describe("instanceSettingsService managed overlay", () => { enableOwnerInstanceAdmin: true, managedKeys: { enableOwnerInstanceAdmin: { managed: true, managedBy: "paperclip-cloud" } }, }); - expect((await service.updateExperimental({ enableOwnerInstanceAdmin: false })).experimental.enableOwnerInstanceAdmin).toBe(true); + await expect(service.updateExperimental({ enableOwnerInstanceAdmin: false })).rejects.toMatchObject({ status: 403 }); }); it.each([{}, { enableMcpAggregators: false }])("keeps aggregators on with legacy stored and managed values: %j", async (stored) => { diff --git a/server/src/__tests__/instance-settings-routes.test.ts b/server/src/__tests__/instance-settings-routes.test.ts index dbc3524c0cb..4078d9ad6f8 100644 --- a/server/src/__tests__/instance-settings-routes.test.ts +++ b/server/src/__tests__/instance-settings-routes.test.ts @@ -912,6 +912,46 @@ describe("instance settings routes", () => { }); describe("Cloud staging experiments", () => { + it.each(["team.staging.paperclip.app", "team.paperclip.app"])( + "reads Cloud-enabled isolation and rejects disabling it through the real service on %s", + async (primaryHost) => { + const { instanceSettingsService } = await vi.importActual( + "../services/instance-settings.js", + ); + const update = vi.fn(); + const db = { + select: () => ({ from: () => ({ where: async () => [{ + id: "settings-1", + experimental: { enableIsolatedWorkspaces: false }, + }] }) }), + update, + } as unknown as Parameters[0]; + const service = instanceSettingsService(db, { runtimeEnv: { + PAPERCLIP_PRIMARY_HOST: primaryHost, + PAPERCLIP_MANAGED_CONFIG: JSON.stringify({ + v: 1, mode: "cloud", catalogVersion: "test", + features: { enableIsolatedWorkspaces: true }, plugins: { autoInstall: [] }, + }), + } }); + mockInstanceSettingsService.getExperimental.mockImplementation(service.getExperimental); + mockInstanceSettingsService.updateExperimental.mockImplementation(service.updateExperimental); + const app = createApp({ + type: "board", userId: "stack-admin-1", source: "cloud_tenant", + companyIds: ["company-1"], isInstanceAdmin: false, canManageExperimentalSettings: true, + }); + const current = await request(app).get("/api/instance/settings/experimental").expect(200); + expect(current.body).toMatchObject({ + enableIsolatedWorkspaces: true, + managedKeys: { enableIsolatedWorkspaces: { managed: true } }, + }); + const denied = await request(app).patch("/api/instance/settings/experimental") + .send({ enableIsolatedWorkspaces: false }).expect(403); + expect(denied.body.details).toMatchObject({ code: "settings_operator_managed" }); + expect(update).not.toHaveBeenCalled(); + expect(mockLogActivity).not.toHaveBeenCalled(); + }, + ); + afterEach(() => vi.unstubAllEnvs()); it.each(["instance.experimental", "instance.experimental.*"])( diff --git a/server/src/services/instance-settings.ts b/server/src/services/instance-settings.ts index 642c67a31f5..77e6e32dd43 100644 --- a/server/src/services/instance-settings.ts +++ b/server/src/services/instance-settings.ts @@ -36,6 +36,7 @@ import { eq } from "drizzle-orm"; import { getManagedInstanceConfig, type ManagedInstanceConfig } from "./managed-config.js"; import { getOperatorSettingDefaults } from "./setting-defaults.js"; import { isCloudStagingExperimentalSetting, isCloudStagingInstance } from "./cloud-instance.js"; +import { forbidden } from "../errors.js"; const DEFAULT_SINGLETON_KEY = "default"; const instanceGeneralSettingsStorageSchema = instanceGeneralSettingsSchema.strip(); @@ -440,7 +441,8 @@ export function instanceSettingsService(db: Db, options: InstanceSettingsService const enforcedManagedConfig = staging && managedConfig ? { ...managedConfig, - features: Object.fromEntries(Object.entries(managedConfig.features).filter(([key]) => !isCloudStagingExperimentalSetting(key))), + features: Object.fromEntries(Object.entries(managedConfig.features) + .filter(([key, enabled]) => enabled || !isCloudStagingExperimentalSetting(key))), } : managedConfig; // Same posture for PAPERCLIP_SETTING_DEFAULTS: parsed once, applied per @@ -575,6 +577,13 @@ export function instanceSettingsService(db: Db, options: InstanceSettingsService }, updateExperimental: async (patch: PatchInstanceExperimentalSettings): Promise => { + for (const [key, enabled] of Object.entries(managedConfig?.features ?? {})) { + if (enabled && patch[key as ManagedExperimentalFeatureKey] === false) { + throw forbidden(`${key} is enabled by Paperclip Cloud and cannot be disabled on this instance`, { + code: "settings_operator_managed", + }); + } + } const current = await getOrCreateRow(); // Guarded Cloud flags stay absent from the row unless chosen, so the // read-time catalog default keeps applying (see stripCloudCatalogDefaultEchoes).