From 71c786ab455a1068325f4fb8abf9b5768a028e2c Mon Sep 17 00:00:00 2001 From: Devika Verma Date: Wed, 12 Aug 2026 17:25:12 -0700 Subject: [PATCH] Feature 2: settings pass - flag exposure, gated picker entry, toggles The auto_approve flag (and its shadow sibling) become first-class settings instead of hand-edited TOML, and the Auto-Approve mode entry appears in the picker only when the flag is on. Server: - manager: auto_approve()/auto_approve_shadow() read prefs.json first, falling back to the config.toml value a power user may have set; both writers persist to prefs. Both stores are user-global, so a cloned repo still can't enable either (the 1.5 invariant, unchanged). - get_settings() exposes both; POST /v1/settings/auto-approve and /auto-approve-shadow write them (same shape as context-bar). - Session builds pass the prefs-backed values into build_engine via new optional auto_approve/auto_approve_shadow overrides (None = config value), so a Settings flip takes effect on the next session build with no restart. Scheduled runs keep reading config only - they are unattended, so the live reviewer can never fire there regardless. GUI: - Mode picker: the Auto-Approve entry is `gated` - shown when getSettings().auto_approve is true, fetched on menu open. A session already IN auto-approve always shows its own entry so the current mode stays legible even if the flag was later turned off. This replaces the TEST-ONLY unconditional entry. - Settings: AutoApproveCard with the feature toggle and the nested shadow- evaluation toggle ("records what it would have decided next to your own choice - without changing anything"). - api.ts: ModelSettings.auto_approve/auto_approve_shadow + setters. Verified live against the running sidecar: flag off hides the entry on an interactive session, flag on shows it, the Settings toggles round-trip and persist. tests/test_auto_approve_settings.py (6): defaults, REST round- trip, restart persistence, config fallback, prefs-beats-config, and the build_engine override. tsc clean; 111 GUI unit tests pass. --- coworker/agent.py | 22 +++++- coworker/server/app.py | 13 ++++ coworker/server/manager.py | 44 +++++++++++ surfaces/gui/src/api.ts | 32 ++++++++ surfaces/gui/src/components/Composer.tsx | 32 +++++++- surfaces/gui/src/components/SettingsView.tsx | 75 ++++++++++++++++++ tests/test_auto_approve_settings.py | 80 ++++++++++++++++++++ 7 files changed, 290 insertions(+), 8 deletions(-) create mode 100644 tests/test_auto_approve_settings.py diff --git a/coworker/agent.py b/coworker/agent.py index 54e92094..615ec085 100644 --- a/coworker/agent.py +++ b/coworker/agent.py @@ -218,6 +218,11 @@ def build_engine( connector_filter: Optional[set[str]] = None, # A set (static snapshot) or a zero-arg callable (live, re-evaluated per load_skill). skill_filter: Optional[set[str] | Callable[[], set[str]]] = None, + # Auto-Approve flags (spec Part 8 / §1.5). None ⇒ read the config.toml value; the server + # passes its prefs-backed booleans so the GUI Settings toggle takes effect. Both stores + # are user-global, preserving the "a repo can't enable this" invariant. + auto_approve: Optional[bool] = None, + auto_approve_shadow: Optional[bool] = None, ) -> TurnEngine: ws = Path(workspace).expanduser().resolve() if workspace else None if agent.needs_workspace and ws is None: @@ -509,9 +514,18 @@ def build_engine( # per-turn retry guard trips (engine._reviewer_active). Uses the session's own # provider and model: no second key, and if it's trusted to drive the agent it's # strong enough to review it (§1.5). - if getattr(config, "auto_approve", False) or getattr( - config, "auto_approve_shadow", False - ): + # + # The two flags may be overridden by the caller (the GUI Settings toggle persists them + # to the user-global prefs store, which the server reads and passes here); None ⇒ take + # the config.toml value. Both stores are user-global, so a repo still can't turn either + # on regardless of which path set it. + live_on = auto_approve if auto_approve is not None else getattr(config, "auto_approve", False) + shadow_on = ( + auto_approve_shadow + if auto_approve_shadow is not None + else getattr(config, "auto_approve_shadow", False) + ) + if live_on or shadow_on: from .reviewer import Reviewer engine.reviewer = Reviewer( @@ -522,7 +536,7 @@ def build_engine( # Shadow evaluation (Part 6 step 3): with only the shadow flag on, the reviewer is # attached but the LIVE path stays off unless the session is actually in # Mode.AUTO_APPROVE — shadow verdicts are recorded on approval cards in any mode. - engine.reviewer_shadow = bool(getattr(config, "auto_approve_shadow", False)) + engine.reviewer_shadow = bool(shadow_on) engine.audit_context = { "session_id": session_id or "", "agent": agent.name, diff --git a/coworker/server/app.py b/coworker/server/app.py index 84da4d7e..3011ae38 100644 --- a/coworker/server/app.py +++ b/coworker/server/app.py @@ -1459,6 +1459,19 @@ def create_app(manager: SessionManager) -> FastAPI: # Composer: show the context-window fill bar, or just the popover (owner ask). return manager.set_context_bar((body or {}).get("context_bar", True)) + @app.post("/v1/settings/auto-approve") + def settings_set_auto_approve(body: dict) -> dict[str, Any]: + # Auto-Approve feature flag (spec §1.5): when on, Mode.AUTO_APPROVE gets an LLM + # reviewer. Takes effect on the next session build. Turning it off leaves any + # shadow-eval setting alone (they are independent switches). + return manager.set_auto_approve((body or {}).get("auto_approve", False)) + + @app.post("/v1/settings/auto-approve-shadow") + def settings_set_auto_approve_shadow(body: dict) -> dict[str, Any]: + # Shadow evaluation (Part 6 step 3): the reviewer records what it WOULD decide on + # every approval card while the human still decides. Independent of the live flag. + return manager.set_auto_approve_shadow((body or {}).get("auto_approve_shadow", False)) + @app.post("/v1/settings/pdf") def settings_set_pdf(body: dict) -> dict[str, Any]: # Token savings (owner ask, 2026-07-17): fallback mode for models without native diff --git a/coworker/server/manager.py b/coworker/server/manager.py index b9f9c282..f9fa98eb 100644 --- a/coworker/server/manager.py +++ b/coworker/server/manager.py @@ -513,6 +513,10 @@ class SessionManager: # Per-session skill menu, LIVE (SKILLS-SPEC §3): a callable so load_skill sees # disables/new skills immediately; the catalog snapshot is taken at build. skill_filter=lambda sid=session_id, w=ws: self.effective_skill_names(sid, w), + # Auto-Approve (spec §1.5): prefs-backed, so the Settings toggle takes effect on + # the next session build without a config.toml edit. + auto_approve=self.auto_approve(), + auto_approve_shadow=self.auto_approve_shadow(), ) # An automation run rebuilt here (manual "Run now" over WS, durable resume) still # carries its task's standing allowances — the rules live on the task record. @@ -1896,6 +1900,10 @@ class SessionManager: "nav_layout": self._nav_layout(), "sessions_peek": self.sessions_peek(), "context_bar": self.context_bar(), + # Auto-Approve feature flag + its shadow-eval sibling (spec §1.5). Drive the + # Settings toggles and gate the composer's Auto-Approve mode entry. + "auto_approve": self.auto_approve(), + "auto_approve_shadow": self.auto_approve_shadow(), "scratch_base": self._prefs.get("scratch_base") or self.DEFAULT_SCRATCH_BASE, # Real on-disk secrets location, so the UI shows the OS-native path instead of a @@ -1965,6 +1973,42 @@ class SessionManager: self._save_prefs() return {"ok": True, "context_bar": self.context_bar()} + # -- Auto-Approve (spec §1.5, Part 6 step 3) -------------------------------- + # The feature flag and its shadow-eval sibling live in prefs (GUI-writable), falling + # back to the config.toml value a power user may have hand-set. Prefs is user-global, + # so a cloned repo still can't enable either — same guarantee as the config path. + def auto_approve(self) -> bool: + from ..config import load_config + + if "auto_approve" in self._prefs: + return bool(self._prefs["auto_approve"]) + return bool(load_config().auto_approve) + + def auto_approve_shadow(self) -> bool: + from ..config import load_config + + if "auto_approve_shadow" in self._prefs: + return bool(self._prefs["auto_approve_shadow"]) + return bool(load_config().auto_approve_shadow) + + def set_auto_approve(self, on: Any) -> dict[str, Any]: + self._prefs["auto_approve"] = bool(on) + self._save_prefs() + return { + "ok": True, + "auto_approve": self.auto_approve(), + "auto_approve_shadow": self.auto_approve_shadow(), + } + + def set_auto_approve_shadow(self, on: Any) -> dict[str, Any]: + self._prefs["auto_approve_shadow"] = bool(on) + self._save_prefs() + return { + "ok": True, + "auto_approve": self.auto_approve(), + "auto_approve_shadow": self.auto_approve_shadow(), + } + # -- PDF attachments / token savings (owner ask, 2026-07-17) ---------------- DEFAULT_PDF_MAX_PAGES = 20 DEFAULT_PDF_MAX_MB = 10 diff --git a/surfaces/gui/src/api.ts b/surfaces/gui/src/api.ts index 13cca52e..3df369b2 100644 --- a/surfaces/gui/src/api.ts +++ b/surfaces/gui/src/api.ts @@ -697,6 +697,11 @@ export interface ModelSettings { // Composer: show the context-window fill bar (default FALSE; absent → the chip shows // the session total). The usage popover keeps both numbers regardless. context_bar?: boolean; + // Auto-Approve mode (spec §1.5): the feature flag that offers the reviewer mode, and its + // shadow-eval sibling. Both default FALSE and are absent on older backends — the composer + // hides the Auto-Approve mode entry unless auto_approve is explicitly true. + auto_approve?: boolean; + auto_approve_shadow?: boolean; // Curated-matrix display names ({full id → "GLM-5.2 · via Together"}); custom models absent. model_labels?: Record; // {full id → context window in tokens}, verified matrix entries only — drives the @@ -775,6 +780,33 @@ export async function setContextBar( return res.json(); } +type AutoApproveResult = { + ok: boolean; + auto_approve?: boolean; + auto_approve_shadow?: boolean; + error?: string; +}; + +/** Toggle the Auto-Approve feature flag (spec §1.5); applies to the next session build. */ +export async function setAutoApprove(on: boolean): Promise { + const res = await fetch(`${httpBase()}/v1/settings/auto-approve`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ auto_approve: on }), + }); + return res.json(); +} + +/** Toggle shadow evaluation (Part 6 step 3): the reviewer records but never decides. */ +export async function setAutoApproveShadow(on: boolean): Promise { + const res = await fetch(`${httpBase()}/v1/settings/auto-approve-shadow`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ auto_approve_shadow: on }), + }); + return res.json(); +} + /** Persist how many sessions a sidebar group shows before "Show more". */ export async function setSessionsPeek( n: number, diff --git a/surfaces/gui/src/components/Composer.tsx b/surfaces/gui/src/components/Composer.tsx index a7d115b0..e233690c 100644 --- a/surfaces/gui/src/components/Composer.tsx +++ b/surfaces/gui/src/components/Composer.tsx @@ -24,13 +24,24 @@ import { // kept so saved sessions and configs keep working. Auto-Approve ("auto-approve") is the // reviewer mode (spec: reviewed-auto-mode.md); it appears only when the server says the // feature flag is on, wired in the settings pass — until then the picker omits it. -// `caution` prefixes the label with a warning triangle — a picker-local extension of -// Dropdown's Option. -type ModeOption = Option & { caution?: boolean }; +// `caution` prefixes the label with a warning triangle; `gated` hides the entry unless the +// server's auto_approve flag is on. Picker-local extensions of Dropdown's Option. +type ModeOption = Option & { caution?: boolean; gated?: boolean }; +// "auto" is the legacy wire value for Bypass approvals (server: Mode.BYPASS_APPROVALS). +// Auto-Approve is `gated`: shown only when getSettings().auto_approve is true (the feature +// flag, off by default). Copy (owner, 2026-08-12): two lines like every other entry — +// "your session model" carries the who-judges fact inline; per-check cost surfaces in the +// metering badge, not as picker text. const PERMISSION_OPTIONS: ModeOption[] = [ { value: "discuss", label: "Discuss", description: "Chat and explore — no edits or commands" }, { value: "interactive", label: "Ask for approval", description: "Ask before edits and commands" }, + { + value: "auto-approve", + label: "Auto-Approve", + description: "Your session model clears routine actions; doubtful ones still ask", + gated: true, + }, { value: "auto", label: "Bypass approvals", @@ -847,6 +858,19 @@ function ModeMenu({ onUnattendedChange?: (on: boolean) => void; }) { const [open, setOpen] = useState(false); + // The Auto-Approve entry is gated on the server flag. Fetch once on first open; a session + // already IN auto-approve mode always shows its own entry so the current mode is legible + // even if the flag was later turned off. + const [autoApproveEnabled, setAutoApproveEnabled] = useState(false); + useEffect(() => { + if (!open) return; + getSettings() + .then((s) => setAutoApproveEnabled(s.auto_approve === true)) + .catch(() => {}); + }, [open]); + const options = PERMISSION_OPTIONS.filter( + (o) => !o.gated || autoApproveEnabled || o.value === mode, + ); const current = PERMISSION_OPTIONS.find((o) => o.value === mode); return (
@@ -875,7 +899,7 @@ function ModeMenu({ role="menu" data-testid="mode-menu" > - {PERMISSION_OPTIONS.map((o) => ( + {options.map((o) => (