diff --git a/docs/deploy/environment-variables.md b/docs/deploy/environment-variables.md index 286f42f8f6..c21d4f34b3 100644 --- a/docs/deploy/environment-variables.md +++ b/docs/deploy/environment-variables.md @@ -100,6 +100,13 @@ Daytona snapshot for future leases. - Any experimental toggle: `instance.experimental.` (e.g. `instance.experimental.enableSmokeLab`) — the card disappears and value-changing writes are rejected. +- All current and future experimental toggles: `instance.experimental.*`. + Add `!instance.experimental.` entries to leave specific controls + available. The server expands this policy against its own feature catalog, + so new toggles stay hidden without an environment change. The Experimental + page remains available. Exceptions only apply to the wildcard; an explicit + hidden toggle or `instance.experimental` page restriction always wins, + regardless of entry order. Unknown exceptions are logged and ignored. - Any top-level company settings page: `company.members`, `company.invites`, `company.secrets`, `company.export`, `company.import` — removed from the settings sidebar, tab bar, and routing (the company General page is the @@ -132,6 +139,24 @@ is identical to earlier releases. Hiding a toggle does not change its value; pair hiding with the desired default where it matters (for general settings, see [Operator setting defaults](#operator-setting-defaults)). +For example, this allows only the Environments control and keeps the Plugins +settings page hidden: + +```sh +PAPERCLIP_HIDDEN_SETTINGS='instance.plugins,instance.experimental.*,!instance.experimental.enableEnvironments' +``` + +`GET /api/health` returns the expanded concrete keys in `hiddenSettings`. +The UI and settings API use the same restrictions. Reads and same-value +echoes remain allowed; changing a hidden value returns +`403 settings_operator_managed`. + +Older images that predate wildcard support ignore the wildcard and exceptions. +Keep their explicit hidden-toggle entries during an upgrade, or upgrade all +images before replacing an explicit list. Once every image supports this +syntax, the wildcard and its exceptions are sufficient. A recognized exception +without a wildcard has no effect. + ### Operator setting defaults `PAPERCLIP_SETTING_DEFAULTS` takes a JSON object whose fields come from the diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index d8f4029708..4b7d7c05f1 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -2675,6 +2675,7 @@ export { type InstanceFeatureKey, } from "./feature-catalog.js"; export { + EXPERIMENTAL_SETTINGS_WILDCARD, HIDEABLE_COMPANY_PAGES, HIDEABLE_COMPANY_SECTIONS, HIDEABLE_GENERAL_SECTIONS, diff --git a/packages/shared/src/settings-visibility-catalog-growth.test.ts b/packages/shared/src/settings-visibility-catalog-growth.test.ts new file mode 100644 index 0000000000..3606f66ed6 --- /dev/null +++ b/packages/shared/src/settings-visibility-catalog-growth.test.ts @@ -0,0 +1,17 @@ +import { expect, it, vi } from "vitest"; + +// Model a future Core release without changing the operator's policy. +vi.mock("./feature-catalog.js", async (importOriginal) => { + const catalog = await importOriginal(); + return { ...catalog, INSTANCE_FEATURE_KEYS: [...catalog.INSTANCE_FEATURE_KEYS, "enableFutureFeature"] }; +}); + +import { parseHiddenSettingsList } from "./settings-visibility.js"; + +it("hides an added catalog feature without an operator configuration change", () => { + const parsed = parseHiddenSettingsList("instance.plugins,instance.experimental.*,!instance.experimental.enableEnvironments"); + expect(parsed.unknown).toEqual([]); + expect(parsed.hidden).toContain("instance.experimental.enableFutureFeature"); + expect(parsed.hidden).toContain("instance.plugins"); + expect(parsed.hidden).not.toContain("instance.experimental.enableEnvironments"); +}); diff --git a/packages/shared/src/settings-visibility.test.ts b/packages/shared/src/settings-visibility.test.ts index 0a657065fa..709ca672c3 100644 --- a/packages/shared/src/settings-visibility.test.ts +++ b/packages/shared/src/settings-visibility.test.ts @@ -49,6 +49,48 @@ describe("hideable setting keys", () => { }); describe("parseHiddenSettingsList", () => { + it("expands the experimental wildcard into concrete keys without hiding the page", () => { + const parsed = parseHiddenSettingsList("instance.experimental.*"); + expect(parsed).toEqual({ hidden: INSTANCE_FEATURE_KEYS.map(experimentalSettingKey), unknown: [] }); + expect(parsed.hidden).not.toContain("instance.experimental"); + }); + + it("allows only named exceptions, independent of order or duplicates", () => { + const exception = "!instance.experimental.enableEnvironments"; + for (const raw of [`instance.experimental.*, ${exception}, ${exception}`, `${exception},instance.experimental.*`]) { + const parsed = parseHiddenSettingsList(raw); + expect(parsed).toEqual({ + hidden: INSTANCE_FEATURE_KEYS.filter((key) => key !== "enableEnvironments").map(experimentalSettingKey), + unknown: [], + }); + } + }); + + it("keeps explicit hides and parent page restrictions stronger than exceptions", () => { + for (const restriction of ["instance.experimental.enableEnvironments", "instance.experimental"]) { + for (const raw of [ + `instance.experimental.*,!instance.experimental.enableEnvironments,${restriction}`, + `${restriction},!instance.experimental.enableEnvironments,instance.experimental.*`, + ]) { + const { hidden } = parseHiddenSettingsList(raw); + expect(hidesExperimentalSetting(new Set(hidden), "enableEnvironments")).toBe(true); + expect(new Set(hidden).size).toBe(hidden.length); + } + } + }); + + it("ignores unknown or out-of-scope exceptions without opening any controls", () => { + const parsed = parseHiddenSettingsList("instance.experimental.*,!instance.experimental.enableTypo,!instance.plugins,!instance.experimental,!instance.experimental.*"); + expect(parsed.hidden).toEqual(INSTANCE_FEATURE_KEYS.map(experimentalSettingKey)); + expect(parsed.unknown).toEqual(["!instance.experimental.enableTypo", "!instance.plugins", "!instance.experimental", "!instance.experimental.*"]); + }); + + it("does not use exceptions to override individual restrictions without a wildcard", () => { + expect(parseHiddenSettingsList("!instance.experimental.enableEnvironments").hidden).toEqual([]); + expect(parseHiddenSettingsList("instance.experimental.enableEnvironments,!instance.experimental.enableEnvironments").hidden) + .toEqual(["instance.experimental.enableEnvironments"]); + }); + it("accepts workspace controls independently of experimental flags", () => { expect(parseHiddenSettingsList("workspaces.isolation")).toEqual({ hidden: ["workspaces.isolation"], unknown: [] }); expect(hidesExperimentalSetting(new Set(["workspaces.isolation"]), "enableIsolatedWorkspaces")).toBe(false); diff --git a/packages/shared/src/settings-visibility.ts b/packages/shared/src/settings-visibility.ts index 3b1e104739..31c99ae80e 100644 --- a/packages/shared/src/settings-visibility.ts +++ b/packages/shared/src/settings-visibility.ts @@ -6,7 +6,8 @@ import { INSTANCE_FEATURE_KEYS, type InstanceFeatureKey } from "./feature-catalo * A hosting operator (a managed cloud, an internal shared server) can hide * settings surfaces that do not apply to their deployment by setting the * `PAPERCLIP_HIDDEN_SETTINGS` environment variable to a comma-separated - * list of keys from this registry. Hiding a surface removes it from the UI + * list of keys from this registry. An experimental wildcard with named + * exceptions can also hide future controls automatically. Hiding a surface removes it from the UI * (nav, routes, page sections). Surfaces backed by instance-level mutation * routes are also floored with a 403 carrying * `SETTINGS_OPERATOR_MANAGED_ERROR_CODE`: the Access, Plugins, and Adapters @@ -111,7 +112,7 @@ export type HideableSettingKey = | HideableGeneralSection | HideableExperimentalSetting; -/** Every key `PAPERCLIP_HIDDEN_SETTINGS` accepts. */ +/** Concrete setting keys; the parser also accepts the experimental wildcard and exceptions. */ export const HIDEABLE_SETTING_KEYS: readonly HideableSettingKey[] = [ ...HIDEABLE_WORKSPACE_SECTIONS, ...HIDEABLE_INSTANCE_PAGES, @@ -124,30 +125,50 @@ export const HIDEABLE_SETTING_KEYS: readonly HideableSettingKey[] = [ /** Stable 403 code for writes to operator-hidden settings. */ export const SETTINGS_OPERATOR_MANAGED_ERROR_CODE = "settings_operator_managed"; +/** Hide current and future experimental controls, with optional !key exceptions. */ +export const EXPERIMENTAL_SETTINGS_WILDCARD = "instance.experimental.*"; + export interface ParsedHiddenSettings { - /** Recognized keys, deduplicated, in input order. */ + /** Concrete keys, deduplicated; wildcard-derived keys follow in catalog order. */ hidden: HideableSettingKey[]; /** Unrecognized entries, for the caller to warn about. */ unknown: string[]; } -/** Parse a `PAPERCLIP_HIDDEN_SETTINGS`-style comma-separated list. */ +/** + * Parse operator settings into concrete keys for both the UI and API. + * `instance.experimental.*` hides every catalog control except entries such as + * `!instance.experimental.enableEnvironments`. Exceptions only affect the + * wildcard; an explicit hidden key or hidden parent page always wins. + */ export function parseHiddenSettingsList(raw: string | undefined): ParsedHiddenSettings { const hidden: HideableSettingKey[] = []; const unknown: string[] = []; if (!raw) return { hidden, unknown }; const known = new Set(HIDEABLE_SETTING_KEYS); const seen = new Set(); + const exceptions = new Set(); + let hideExperimental = false; for (const part of raw.split(",")) { const key = part.trim(); if (!key || seen.has(key)) continue; seen.add(key); - if (known.has(key)) { + if (key === EXPERIMENTAL_SETTINGS_WILDCARD) { + hideExperimental = true; + } else if (key.startsWith("!instance.experimental.") && known.has(key.slice(1))) { + exceptions.add(key.slice(1)); + } else if (known.has(key)) { hidden.push(key as HideableSettingKey); } else { unknown.push(key); } } + if (hideExperimental) { + for (const feature of INSTANCE_FEATURE_KEYS) { + const key = experimentalSettingKey(feature); + if (!exceptions.has(key) && !seen.has(key)) hidden.push(key); + } + } return { hidden, unknown }; } diff --git a/server/src/__tests__/health.test.ts b/server/src/__tests__/health.test.ts index 1d97f90e0f..1ff6587693 100644 --- a/server/src/__tests__/health.test.ts +++ b/server/src/__tests__/health.test.ts @@ -136,6 +136,22 @@ describe("GET /health", () => { expect(Object.prototype.hasOwnProperty.call(res.body, "hiddenSettings")).toBe(false); }); + it("publishes concrete wildcard restrictions to the UI and refreshes changed exceptions", async () => { + const env = { PAPERCLIP_HIDDEN_SETTINGS: "instance.plugins,instance.experimental.*,!instance.experimental.enableEnvironments" }; + const app = createApp(undefined, testServerInfo, undefined, env); + const first = await request(app).get("/health"); + expect(first.status).toBe(200); + expect(first.body.hiddenSettings).toContain("instance.plugins"); + expect(first.body.hiddenSettings).toContain("instance.experimental.enableMemoryConnectors"); + expect(first.body.hiddenSettings).not.toContain("instance.experimental.enableEnvironments"); + expect(first.body.hiddenSettings.some((key: string) => key.includes("*") || key.startsWith("!"))).toBe(false); + + env.PAPERCLIP_HIDDEN_SETTINGS = "instance.experimental.*,!instance.experimental.enableMemoryConnectors"; + const second = await request(app).get("/health"); + expect(second.body.hiddenSettings).toContain("instance.experimental.enableEnvironments"); + expect(second.body.hiddenSettings).not.toContain("instance.experimental.enableMemoryConnectors"); + }); + it("returns 200 when the database probe succeeds", async () => { const db = { execute: vi.fn().mockResolvedValue([{ "?column?": 1 }]), diff --git a/server/src/__tests__/instance-settings-routes.test.ts b/server/src/__tests__/instance-settings-routes.test.ts index 2eb7a0e96a..ca3747c584 100644 --- a/server/src/__tests__/instance-settings-routes.test.ts +++ b/server/src/__tests__/instance-settings-routes.test.ts @@ -853,6 +853,40 @@ describe("instance settings routes", () => { expect(mockInstanceSettingsService.updateExperimental).not.toHaveBeenCalled(); }); + it("enforces a wildcard allowlist at the API and preserves hidden values", async () => { + process.env.PAPERCLIP_HIDDEN_SETTINGS = + "instance.experimental.*,!instance.experimental.enableIsolatedWorkspaces"; + const app = await createApp(adminActor); + + const rejected = await request(app) + .patch("/api/instance/settings/experimental") + .send({ enableEnvironments: true, enableIsolatedWorkspaces: true }); + expect(rejected.status).toBe(403); + expect(rejected.body.details).toMatchObject({ code: "settings_operator_managed" }); + expect(mockInstanceSettingsService.updateExperimental).not.toHaveBeenCalled(); + + // Existing full-form clients may echo hidden values without changing them. + const allowed = await request(app) + .patch("/api/instance/settings/experimental") + .send({ enableEnvironments: false, enableIsolatedWorkspaces: true }); + expect(allowed.status).toBe(200); + expect(mockInstanceSettingsService.updateExperimental).toHaveBeenCalledWith({ + enableEnvironments: false, enableIsolatedWorkspaces: true, + }); + }); + + it("does not let an allowlist exception bypass an explicit API restriction", async () => { + process.env.PAPERCLIP_HIDDEN_SETTINGS = + "instance.experimental.*,!instance.experimental.enableEnvironments,instance.experimental.enableEnvironments"; + const app = await createApp(adminActor); + const res = await request(app) + .patch("/api/instance/settings/experimental") + .send({ enableEnvironments: true }); + expect(res.status).toBe(403); + expect(res.body.details).toMatchObject({ code: "settings_operator_managed" }); + expect(mockInstanceSettingsService.updateExperimental).not.toHaveBeenCalled(); + }); + it("allows writes to non-hidden experimental toggles while others are hidden", async () => { process.env.PAPERCLIP_HIDDEN_SETTINGS = "instance.experimental.enableEnvironments,instance.experimental.enableServerInfoDebugView";