diff --git a/apps/web/e2e/mock-server.mjs b/apps/web/e2e/mock-server.mjs index a427ebf9..10ca7c38 100644 --- a/apps/web/e2e/mock-server.mjs +++ b/apps/web/e2e/mock-server.mjs @@ -358,6 +358,10 @@ function handleApiGet(pathname, fleet = FLEET) { // The REAL chat.compact flag (runtime/flags.py) — enabled so commands.spec sees // /compact in the slash menu; the flag-off path is covered via ?flag:chat.compact=off. { id: "chat.compact", description: "/compact — summarize + archive a chat thread.", tier: "dev", owner: "kj", remove_by: "2026-09-01", enabled: true, source: "channel" }, + // The REAL secrets-panel flag (runtime/flags.py) — tier "dev", so it resolves ON in + // this channel "dev" mock. Settings ▸ Secrets is visible in e2e exactly as on dev; + // its flag-off (prod) path is covered by the source-level secretsGate.test unit test. + { id: "secrets-panel", description: "Settings ▸ Secrets — external secrets manager panel.", tier: "dev", owner: "kj", remove_by: "2026-10-01", enabled: true, source: "channel" }, ], }; default: diff --git a/apps/web/src/settings/SettingsSurface.tsx b/apps/web/src/settings/SettingsSurface.tsx index 9793e732..4b5ce443 100644 --- a/apps/web/src/settings/SettingsSurface.tsx +++ b/apps/web/src/settings/SettingsSurface.tsx @@ -1,5 +1,6 @@ import { BarChart3, Bot, BookMarked, Boxes, Brain, Cpu, Database, FlaskConical, Gauge, Keyboard, KeyRound, Lock, MessageSquare, Network, Palette, Plug, Puzzle, Server, Smartphone, Sparkles, Store, Wrench } from "lucide-react"; import { useFlagPredicate } from "../flags/flags"; +import { visibleSections } from "./sectionGate"; import type { LucideIcon } from "lucide-react"; import { useEffect, type ReactNode } from "react"; @@ -90,7 +91,10 @@ const AGENT_SECTIONS: Section[] = [ { id: "behavior", label: "Behavior", icon: Brain, render: () => }, { id: "knowledge", label: "Knowledge", icon: Database, render: () => }, // External secrets manager (ADR 0080) — schema fields + the status/test/sync card. - { id: "secrets", label: "Secrets", icon: Lock, render: () => }, + // Behind `secrets-panel` (ADR 0068), dev channel only — see the flag in runtime/flags.py. + // Flag-off: `shown()` drops it from the nav AND from id resolution, so a persisted "secrets" + // id falls back to the first visible section instead of a blank pane. + { id: "secrets", label: "Secrets", icon: Lock, flag: "secrets-panel", render: () => }, { id: "plugins", label: "Plugins", icon: Puzzle, render: () => }, ]; @@ -147,7 +151,7 @@ export function SettingsSurface({ initialSection }: { only?: "host" | "workspace // Drop flag-off sections everywhere they'd be reachable — nav, active-id resolution, and // the ⌘K/deep-link path that reads the same persisted id. const flagOn = useFlagPredicate(); - const shown = (list: Section[]) => list.filter((s) => !s.flag || flagOn(s.flag)); + const shown = (list: Section[]) => visibleSections(list, flagOn); const agentSections = shown(AGENT_SECTIONS); const capabilitySections = shown(CAPABILITY_SECTIONS); const boxSections = onHost ? shown(BOX_SECTIONS) : []; diff --git a/apps/web/src/settings/secretsGate.test.ts b/apps/web/src/settings/secretsGate.test.ts new file mode 100644 index 00000000..b81090e5 --- /dev/null +++ b/apps/web/src/settings/secretsGate.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; + +import { visibleSections } from "./sectionGate"; +import src from "./SettingsSurface.tsx?raw"; +import flags from "../../../../runtime/flags.py?raw"; + +// Settings ▸ Secrets is gated to the dev channel (ADR 0068). The external secrets manager's +// connect/test/sync flow stays behind `secrets-panel` until it's exercised end to end, so it +// only shows on the dev channel / via override (#2120). +// +// Behavior first (the QA panel's fix-first on the original head): exercise the REAL filter +// with the flag both ways — a grep-only test would stay green if the gating itself broke. +// Source guards second, for the failure mode of someone deleting the `flag:` key during an +// unrelated edit: that type-checks, renders fine on their machine, and silently re-exposes a +// pre-release panel on the prod channel. + +const SECTIONS = [ + { id: "identity" }, + { id: "secrets", flag: "secrets-panel" }, + { id: "plugins" }, +]; + +describe("Settings ▸ Secrets is flag-gated (#2120)", () => { + it("flag off → the secrets section is dropped; unflagged sections survive", () => { + const out = visibleSections(SECTIONS, () => false); + expect(out.find((s) => s.id === "secrets")).toBeUndefined(); + expect(out.map((s) => s.id)).toEqual(["identity", "plugins"]); + }); + + it("flag on → the secrets section is present, nothing else changes", () => { + const out = visibleSections(SECTIONS, (id) => id === "secrets-panel"); + expect(out.find((s) => s.id === "secrets")).toBeDefined(); + expect(out).toHaveLength(SECTIONS.length); + }); + + it("the real secrets Section carries the flag (order-insensitive)", () => { + // Extract the one object literal containing id: "secrets" and assert the flag key is + // inside it — survives key reordering and reformatting, unlike a cross-key regex. + const obj = src.match(/\{[^{}]*id: "secrets"[^{}]*\}/)?.[0] ?? ""; + expect(obj).not.toBe(""); + expect(obj).toContain('flag: "secrets-panel"'); + }); + + it("SettingsSurface routes every section list through the pure gate", () => { + // The component must call the SAME visibleSections this test exercises — and the + // agent group (where secrets lives) must go through shown(). + expect(src).toContain('import { visibleSections } from "./sectionGate"'); + expect(src).toMatch(/const shown = \(list: Section\[\]\) => visibleSections\(list, flagOn\)/); + expect(src).toMatch(/shown\(AGENT_SECTIONS\)/); + }); + + it("the flag ships at tier dev", () => { + expect(flags).toMatch(/id="secrets-panel"[\s\S]*?tier="dev"/); + }); +}); diff --git a/apps/web/src/settings/sectionGate.ts b/apps/web/src/settings/sectionGate.ts new file mode 100644 index 00000000..8ec64d97 --- /dev/null +++ b/apps/web/src/settings/sectionGate.ts @@ -0,0 +1,9 @@ +// Pure half of the settings flag gate (ADR 0068): drop sections whose `flag` resolves +// off. Extracted from SettingsSurface so the gating is unit-testable without importing +// the whole settings tree (secretsGate.test.ts exercises it with the flag both ways) — +// the component wires `flagOn` to useFlagPredicate(). +export type GatedSection = { id: string; flag?: string }; + +export function visibleSections(list: T[], flagOn: (id: string) => boolean): T[] { + return list.filter((s) => !s.flag || flagOn(s.flag)); +} diff --git a/runtime/flags.py b/runtime/flags.py index b6c288dd..eebc7fe9 100644 --- a/runtime/flags.py +++ b/runtime/flags.py @@ -66,6 +66,17 @@ class Flag: owner="kj", remove_by="2026-10-01", ), + Flag( + id="secrets-panel", + description=( + "Settings ▸ Secrets — external secrets manager panel (ADR 0080). Dev channel " + "only while the connect/test/sync flow stabilizes; graduate to `on` once it's " + "exercised end to end (#2120)." + ), + tier="dev", + owner="kj", + remove_by="2026-10-01", + ), ]