Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/web/e2e/mock-server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
8 changes: 6 additions & 2 deletions apps/web/src/settings/SettingsSurface.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -90,7 +91,10 @@ const AGENT_SECTIONS: Section[] = [
{ id: "behavior", label: "Behavior", icon: Brain, render: () => <SettingsCategoryPanel category="Behavior" title="Behavior" /> },
{ id: "knowledge", label: "Knowledge", icon: Database, render: () => <SettingsCategoryPanel category="Knowledge" title="Knowledge" /> },
// External secrets manager (ADR 0080) — schema fields + the status/test/sync card.
{ id: "secrets", label: "Secrets", icon: Lock, render: () => <SecretsPanel /> },
// 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: () => <SecretsPanel /> },
Comment thread
mabry1985 marked this conversation as resolved.
{ id: "plugins", label: "Plugins", icon: Puzzle, render: () => <PluginSettingsHome /> },
];

Expand Down Expand Up @@ -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) : [];
Expand Down
55 changes: 55 additions & 0 deletions apps/web/src/settings/secretsGate.test.ts
Original file line number Diff line number Diff line change
@@ -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"/);
});
});
9 changes: 9 additions & 0 deletions apps/web/src/settings/sectionGate.ts
Original file line number Diff line number Diff line change
@@ -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<T extends GatedSection>(list: T[], flagOn: (id: string) => boolean): T[] {
return list.filter((s) => !s.flag || flagOn(s.flag));
}
11 changes: 11 additions & 0 deletions runtime/flags.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
),
]


Expand Down
Loading