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",
+ ),
]