diff --git a/packages/shared/src/types/instance.ts b/packages/shared/src/types/instance.ts index e308e0e2ab6..3cc1ce37c3c 100644 --- a/packages/shared/src/types/instance.ts +++ b/packages/shared/src/types/instance.ts @@ -54,6 +54,7 @@ export interface InstanceExperimentalSettings { enableExperimentalFileViewer: boolean; enableCloudSync: boolean; enableExternalObjects: boolean; + enableDynamicSecrets: boolean; autoRestartDevServerWhenIdle: boolean; enableIssueGraphLivenessAutoRecovery: boolean; issueGraphLivenessAutoRecoveryLookbackHours: number; diff --git a/packages/shared/src/validators/instance.ts b/packages/shared/src/validators/instance.ts index 986d0a3525b..2c7cd0d81ac 100644 --- a/packages/shared/src/validators/instance.ts +++ b/packages/shared/src/validators/instance.ts @@ -48,6 +48,7 @@ export const instanceExperimentalSettingsSchema = z.object({ enableExperimentalFileViewer: z.boolean().default(false), enableCloudSync: z.boolean().default(false), enableExternalObjects: z.boolean().default(false), + enableDynamicSecrets: z.boolean().default(false), autoRestartDevServerWhenIdle: z.boolean().default(false), enableIssueGraphLivenessAutoRecovery: z.boolean().default(false), issueGraphLivenessAutoRecoveryLookbackHours: z diff --git a/server/src/__tests__/instance-settings-routes.test.ts b/server/src/__tests__/instance-settings-routes.test.ts index 3965dacb975..628a81def06 100644 --- a/server/src/__tests__/instance-settings-routes.test.ts +++ b/server/src/__tests__/instance-settings-routes.test.ts @@ -81,6 +81,8 @@ describe("instance settings routes", () => { enableIssuePlanDecompositions: false, enableExperimentalFileViewer: false, enableCloudSync: false, + enableExternalObjects: false, + enableDynamicSecrets: false, autoRestartDevServerWhenIdle: false, enableIssueGraphLivenessAutoRecovery: true, issueGraphLivenessAutoRecoveryLookbackHours: 24, @@ -101,6 +103,7 @@ describe("instance settings routes", () => { enableTaskWatchdogs: false, enableCloudSync: false, enableExternalObjects: false, + enableDynamicSecrets: false, autoRestartDevServerWhenIdle: false, enableIssueGraphLivenessAutoRecovery: true, issueGraphLivenessAutoRecoveryLookbackHours: 24, @@ -119,6 +122,8 @@ describe("instance settings routes", () => { enableIssuePlanDecompositions: true, enableExperimentalFileViewer: true, enableCloudSync: true, + enableExternalObjects: false, + enableDynamicSecrets: false, autoRestartDevServerWhenIdle: false, enableIssueGraphLivenessAutoRecovery: true, issueGraphLivenessAutoRecoveryLookbackHours: 24, @@ -144,6 +149,7 @@ describe("instance settings routes", () => { enableTaskWatchdogs: true, enableCloudSync: true, enableExternalObjects: false, + enableDynamicSecrets: true, autoRestartDevServerWhenIdle: false, enableIssueGraphLivenessAutoRecovery: true, issueGraphLivenessAutoRecoveryLookbackHours: 24, @@ -197,6 +203,7 @@ describe("instance settings routes", () => { enableTaskWatchdogs: false, enableCloudSync: false, enableExternalObjects: false, + enableDynamicSecrets: false, autoRestartDevServerWhenIdle: false, enableIssueGraphLivenessAutoRecovery: true, issueGraphLivenessAutoRecoveryLookbackHours: 24, @@ -292,6 +299,24 @@ describe("instance settings routes", () => { }); }); + it("allows local board users to update dynamic secrets", async () => { + const app = await createApp({ + type: "board", + userId: "local-board", + source: "local_implicit", + isInstanceAdmin: true, + }); + + await request(app) + .patch("/api/instance/settings/experimental") + .send({ enableDynamicSecrets: true }) + .expect(200); + + expect(mockInstanceSettingsService.updateExperimental).toHaveBeenCalledWith({ + enableDynamicSecrets: true, + }); + }); + it("allows local board users to update issue graph liveness auto-recovery", async () => { const app = await createApp({ type: "board", diff --git a/server/src/__tests__/instance-settings-service.test.ts b/server/src/__tests__/instance-settings-service.test.ts index 4ef90da5fed..4b9262996db 100644 --- a/server/src/__tests__/instance-settings-service.test.ts +++ b/server/src/__tests__/instance-settings-service.test.ts @@ -10,6 +10,7 @@ describe("instance settings service", () => { enableExperimentalFileViewer: true, enableTaskWatchdogs: true, enableCloudSync: true, + enableDynamicSecrets: true, autoRestartDevServerWhenIdle: true, enableIssueGraphLivenessAutoRecovery: true, issueGraphLivenessAutoRecoveryLookbackHours: 48, @@ -24,6 +25,7 @@ describe("instance settings service", () => { enableExperimentalFileViewer: true, enableTaskWatchdogs: true, enableCloudSync: true, + enableDynamicSecrets: true, autoRestartDevServerWhenIdle: true, enableIssueGraphLivenessAutoRecovery: true, issueGraphLivenessAutoRecoveryLookbackHours: 48, @@ -47,6 +49,24 @@ describe("instance settings service", () => { ).toBe(false); }); + it("defaults enableDynamicSecrets to false for empty and legacy stored settings", () => { + expect(normalizeExperimentalSettings(undefined).enableDynamicSecrets).toBe(false); + expect(normalizeExperimentalSettings({}).enableDynamicSecrets).toBe(false); + expect( + normalizeExperimentalSettings({ enableExternalObjects: true }).enableDynamicSecrets, + ).toBe(false); + }); + + it("round-trips an enableDynamicSecrets patch through the update merge", () => { + const current = normalizeExperimentalSettings({}); + const enabled = normalizeExperimentalSettings({ ...current, enableDynamicSecrets: true }); + expect(enabled.enableDynamicSecrets).toBe(true); + expect(enabled.enableStreamlinedLeftNavigation).toBe(true); + + const disabled = normalizeExperimentalSettings({ ...enabled, enableDynamicSecrets: false }); + expect(disabled).toEqual(current); + }); + it("round-trips an enableConferenceRoomChat patch through the update merge", () => { // updateExperimental merges `{ ...normalize(current), ...patch }` and // re-normalizes; emulate that to prove the flag survives the roundtrip diff --git a/server/src/services/instance-settings.ts b/server/src/services/instance-settings.ts index 88656ec14d1..b67faa2d197 100644 --- a/server/src/services/instance-settings.ts +++ b/server/src/services/instance-settings.ts @@ -53,6 +53,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta enableTaskWatchdogs: parsed.data.enableTaskWatchdogs ?? false, enableCloudSync: parsed.data.enableCloudSync ?? false, enableExternalObjects: parsed.data.enableExternalObjects ?? false, + enableDynamicSecrets: parsed.data.enableDynamicSecrets ?? false, autoRestartDevServerWhenIdle: parsed.data.autoRestartDevServerWhenIdle ?? false, enableIssueGraphLivenessAutoRecovery: parsed.data.enableIssueGraphLivenessAutoRecovery ?? false, issueGraphLivenessAutoRecoveryLookbackHours: @@ -70,6 +71,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta enableExperimentalFileViewer: false, enableCloudSync: false, enableExternalObjects: false, + enableDynamicSecrets: false, autoRestartDevServerWhenIdle: false, enableIssueGraphLivenessAutoRecovery: false, issueGraphLivenessAutoRecoveryLookbackHours: diff --git a/ui/src/pages/InstanceExperimentalSettings.test.tsx b/ui/src/pages/InstanceExperimentalSettings.test.tsx index 9a8d8372265..5d2fcf296cf 100644 --- a/ui/src/pages/InstanceExperimentalSettings.test.tsx +++ b/ui/src/pages/InstanceExperimentalSettings.test.tsx @@ -44,6 +44,8 @@ const STREAMLINED_TOGGLE_SELECTOR = 'button[aria-label="Toggle streamlined left navigation experimental setting"]'; const TASK_WATCHDOGS_TOGGLE_SELECTOR = 'button[aria-label="Toggle task watchdogs experimental setting"]'; +const DYNAMIC_SECRETS_TOGGLE_SELECTOR = + 'button[aria-label="Toggle dynamic secrets experimental setting"]'; function defaultExperimentalSettings(): InstanceExperimentalSettingsPayload { return { @@ -54,6 +56,7 @@ function defaultExperimentalSettings(): InstanceExperimentalSettingsPayload { enableIssuePlanDecompositions: false, enableExperimentalFileViewer: false, enableExternalObjects: false, + enableDynamicSecrets: false, enableTaskWatchdogs: false, enableCloudSync: false, autoRestartDevServerWhenIdle: false, @@ -192,4 +195,45 @@ describe("InstanceExperimentalSettings — Conference Room Chat card (PAP-11233) enableTaskWatchdogs: false, }); }); + + it("renders and patches the Dynamic Secrets experimental toggle on and off", async () => { + await renderPage(); + + expect(container.textContent).toContain("Dynamic Secrets"); + expect(container.textContent).toContain( + "Enables experimental dynamic secret resolution for later-stage secret bindings and command-backed values.", + ); + + const toggle = container.querySelector(DYNAMIC_SECRETS_TOGGLE_SELECTOR); + expect(toggle?.getAttribute("aria-checked")).toBe("false"); + + await act(async () => { + toggle?.click(); + }); + await flushReact(); + + expect(mockInstanceSettingsApi.updateExperimental).toHaveBeenCalledWith({ + enableDynamicSecrets: true, + }); + expect(toggle?.getAttribute("aria-checked")).toBe("true"); + + flushSync(() => { + root?.unmount(); + }); + root = null; + container.textContent = ""; + await renderPage(); + + const enabledToggle = container.querySelector(DYNAMIC_SECRETS_TOGGLE_SELECTOR); + expect(enabledToggle?.getAttribute("aria-checked")).toBe("true"); + + await act(async () => { + enabledToggle?.click(); + }); + await flushReact(); + + expect(mockInstanceSettingsApi.updateExperimental).toHaveBeenLastCalledWith({ + enableDynamicSecrets: false, + }); + }); }); diff --git a/ui/src/pages/InstanceExperimentalSettings.tsx b/ui/src/pages/InstanceExperimentalSettings.tsx index 9c769817f27..3c502112fbd 100644 --- a/ui/src/pages/InstanceExperimentalSettings.tsx +++ b/ui/src/pages/InstanceExperimentalSettings.tsx @@ -244,6 +244,7 @@ export function InstanceExperimentalSettings() { const enableTaskWatchdogs = experimentalQuery.data?.enableTaskWatchdogs === true; const enableCloudSync = experimentalQuery.data?.enableCloudSync === true; const enableExternalObjects = experimentalQuery.data?.enableExternalObjects === true; + const enableDynamicSecrets = experimentalQuery.data?.enableDynamicSecrets === true; const autoRestartDevServerWhenIdle = experimentalQuery.data?.autoRestartDevServerWhenIdle === true; const enableIssueGraphLivenessAutoRecovery = experimentalQuery.data?.enableIssueGraphLivenessAutoRecovery === true; @@ -335,6 +336,23 @@ export function InstanceExperimentalSettings() { +
+
+
+

Dynamic Secrets

+

+ Enables experimental dynamic secret resolution for later-stage secret bindings and command-backed values. +

+
+ toggleMutation.mutate({ enableDynamicSecrets: checked })} + disabled={toggleMutation.isPending} + aria-label="Toggle dynamic secrets experimental setting" + /> +
+
+