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.
This commit is contained in:
Devika Verma
2026-08-12 17:25:12 -07:00
parent 42a1fa1fb7
commit 71c786ab45
7 changed files with 290 additions and 8 deletions
+18 -4
View File
@@ -218,6 +218,11 @@ def build_engine(
connector_filter: Optional[set[str]] = None, connector_filter: Optional[set[str]] = None,
# A set (static snapshot) or a zero-arg callable (live, re-evaluated per load_skill). # A set (static snapshot) or a zero-arg callable (live, re-evaluated per load_skill).
skill_filter: Optional[set[str] | Callable[[], set[str]]] = None, 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: ) -> TurnEngine:
ws = Path(workspace).expanduser().resolve() if workspace else None ws = Path(workspace).expanduser().resolve() if workspace else None
if agent.needs_workspace and ws is 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 # 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 # provider and model: no second key, and if it's trusted to drive the agent it's
# strong enough to review it (§1.5). # 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 from .reviewer import Reviewer
engine.reviewer = 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 # 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 # 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. # 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 = { engine.audit_context = {
"session_id": session_id or "", "session_id": session_id or "",
"agent": agent.name, "agent": agent.name,
+13
View File
@@ -1459,6 +1459,19 @@ def create_app(manager: SessionManager) -> FastAPI:
# Composer: show the context-window fill bar, or just the popover (owner ask). # Composer: show the context-window fill bar, or just the popover (owner ask).
return manager.set_context_bar((body or {}).get("context_bar", True)) 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") @app.post("/v1/settings/pdf")
def settings_set_pdf(body: dict) -> dict[str, Any]: def settings_set_pdf(body: dict) -> dict[str, Any]:
# Token savings (owner ask, 2026-07-17): fallback mode for models without native # Token savings (owner ask, 2026-07-17): fallback mode for models without native
+44
View File
@@ -513,6 +513,10 @@ class SessionManager:
# Per-session skill menu, LIVE (SKILLS-SPEC §3): a callable so load_skill sees # 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. # 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), 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 # 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. # carries its task's standing allowances — the rules live on the task record.
@@ -1896,6 +1900,10 @@ class SessionManager:
"nav_layout": self._nav_layout(), "nav_layout": self._nav_layout(),
"sessions_peek": self.sessions_peek(), "sessions_peek": self.sessions_peek(),
"context_bar": self.context_bar(), "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") "scratch_base": self._prefs.get("scratch_base")
or self.DEFAULT_SCRATCH_BASE, or self.DEFAULT_SCRATCH_BASE,
# Real on-disk secrets location, so the UI shows the OS-native path instead of a # 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() self._save_prefs()
return {"ok": True, "context_bar": self.context_bar()} 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) ---------------- # -- PDF attachments / token savings (owner ask, 2026-07-17) ----------------
DEFAULT_PDF_MAX_PAGES = 20 DEFAULT_PDF_MAX_PAGES = 20
DEFAULT_PDF_MAX_MB = 10 DEFAULT_PDF_MAX_MB = 10
+32
View File
@@ -697,6 +697,11 @@ export interface ModelSettings {
// Composer: show the context-window fill bar (default FALSE; absent → the chip shows // Composer: show the context-window fill bar (default FALSE; absent → the chip shows
// the session total). The usage popover keeps both numbers regardless. // the session total). The usage popover keeps both numbers regardless.
context_bar?: boolean; 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. // Curated-matrix display names ({full id → "GLM-5.2 · via Together"}); custom models absent.
model_labels?: Record<string, string>; model_labels?: Record<string, string>;
// {full id → context window in tokens}, verified matrix entries only — drives the // {full id → context window in tokens}, verified matrix entries only — drives the
@@ -775,6 +780,33 @@ export async function setContextBar(
return res.json(); 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<AutoApproveResult> {
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<AutoApproveResult> {
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". */ /** Persist how many sessions a sidebar group shows before "Show more". */
export async function setSessionsPeek( export async function setSessionsPeek(
n: number, n: number,
+28 -4
View File
@@ -24,13 +24,24 @@ import {
// kept so saved sessions and configs keep working. Auto-Approve ("auto-approve") is the // 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 // 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. // 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 // `caution` prefixes the label with a warning triangle; `gated` hides the entry unless the
// Dropdown's Option. // server's auto_approve flag is on. Picker-local extensions of Dropdown's Option.
type ModeOption = Option & { caution?: boolean }; 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[] = [ const PERMISSION_OPTIONS: ModeOption[] = [
{ value: "discuss", label: "Discuss", description: "Chat and explore — no edits or commands" }, { 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: "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", value: "auto",
label: "Bypass approvals", label: "Bypass approvals",
@@ -847,6 +858,19 @@ function ModeMenu({
onUnattendedChange?: (on: boolean) => void; onUnattendedChange?: (on: boolean) => void;
}) { }) {
const [open, setOpen] = useState(false); 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); const current = PERMISSION_OPTIONS.find((o) => o.value === mode);
return ( return (
<div className="relative"> <div className="relative">
@@ -875,7 +899,7 @@ function ModeMenu({
role="menu" role="menu"
data-testid="mode-menu" data-testid="mode-menu"
> >
{PERMISSION_OPTIONS.map((o) => ( {options.map((o) => (
<button <button
key={o.value} key={o.value}
className="w-full flex flex-col items-start px-2.5 py-1.5 rounded-lg text-left hover:bg-paper" className="w-full flex flex-col items-start px-2.5 py-1.5 rounded-lg text-left hover:bg-paper"
@@ -2,6 +2,8 @@ import { useEffect, useState } from "react";
import { import {
getSettings, getSettings,
getTrustedWorkspaces, getTrustedWorkspaces,
setAutoApprove,
setAutoApproveShadow,
setCompactionSettings, setCompactionSettings,
setContextBar, setContextBar,
setOnboarded, setOnboarded,
@@ -447,6 +449,8 @@ function AppearanceSection() {
<ContextBarCard /> <ContextBarCard />
<AutoApproveCard />
<FilesCard /> <FilesCard />
<TrustedWorkspacesCard /> <TrustedWorkspacesCard />
@@ -851,6 +855,77 @@ function ContextBarCard() {
); );
} }
// Auto-Approve (spec §1.5): the experimental feature flag that adds the "Auto-Approve" mode
// to the composer's mode picker, plus its shadow-evaluation sibling. Both default off and are
// user-global (a cloned repo can't turn either on). Shadow is nested under the main flag — it
// only makes sense to measure the reviewer once you know what it is.
function AutoApproveCard() {
const [on, setOn] = useState<boolean | null>(null);
const [shadow, setShadow] = useState(false);
useEffect(() => {
getSettings()
.then((s) => {
setOn(s.auto_approve === true);
setShadow(s.auto_approve_shadow === true);
})
.catch(() => setOn(false));
}, []);
const saveOn = async (next: boolean) => {
setOn(next);
await setAutoApprove(next);
};
const saveShadow = async (next: boolean) => {
setShadow(next);
await setAutoApproveShadow(next);
};
if (on === null) return null;
return (
<div className={CARD + " p-4 mb-4"} data-testid="auto-approve-card">
<div className={FIELD_LABEL}>Auto-Approve (experimental)</div>
<label className="flex items-start gap-3 py-2">
<input
type="checkbox"
className="mt-0.5"
data-testid="auto-approve-toggle"
checked={on}
onChange={(e) => saveOn(e.target.checked)}
/>
<span>
<span className="block text-[13px] text-ink">Enable Auto-Approve mode</span>
<span className="block text-[12px] text-muted">
Adds an <em>Auto-Approve</em> option to the mode picker. In that mode, your session
model reviews each action that would normally need approval and clears the routine
ones; anything doubtful still asks you. It can never allow something the rules
block. One extra model call per check, billed to your usage.
</span>
</span>
</label>
<label className="flex items-start gap-3 py-2 pl-7">
<input
type="checkbox"
className="mt-0.5"
data-testid="auto-approve-shadow-toggle"
checked={shadow}
onChange={(e) => saveShadow(e.target.checked)}
/>
<span>
<span className="block text-[13px] text-ink">
Shadow evaluation <span className="text-faint">(for measuring)</span>
</span>
<span className="block text-[12px] text-muted">
On any mode, the reviewer records what it <em>would</em> have decided next to your
own choice without changing anything. Lets you see how it would behave before
trusting it. Also costs one model call per approval.
</span>
</span>
</label>
</div>
);
}
function SidebarCard() { function SidebarCard() {
const [peek, setPeek] = useState<number | null>(null); const [peek, setPeek] = useState<number | null>(null);
+80
View File
@@ -0,0 +1,80 @@
"""Settings wiring for Auto-Approve (spec §1.5 / Part 6 step 3).
Covers the prefs-backed feature flag + shadow toggle: default off, REST round-trip,
persistence across a manager restart, config.toml fallback, and the build-engine override
so a flag flip takes effect on the next session build without a config edit.
"""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from coworker.server.app import create_app
from coworker.server.manager import SessionManager
@pytest.fixture
def client(tmp_path, monkeypatch):
# Isolate config.toml: no hand-set flag leaking in from the dev machine.
monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path / "state"))
return TestClient(create_app(SessionManager(data_dir=tmp_path / "data")))
def test_flags_default_off(client):
s = client.get("/v1/settings").json()
assert s["auto_approve"] is False
assert s["auto_approve_shadow"] is False
def test_set_auto_approve_roundtrip(client):
r = client.post("/v1/settings/auto-approve", json={"auto_approve": True}).json()
assert r["ok"] and r["auto_approve"] is True
assert client.get("/v1/settings").json()["auto_approve"] is True
# Off again.
client.post("/v1/settings/auto-approve", json={"auto_approve": False})
assert client.get("/v1/settings").json()["auto_approve"] is False
def test_shadow_is_independent_of_the_live_flag(client):
client.post("/v1/settings/auto-approve-shadow", json={"auto_approve_shadow": True})
s = client.get("/v1/settings").json()
assert s["auto_approve"] is False # untouched
assert s["auto_approve_shadow"] is True
def test_flags_persist_across_restart(tmp_path, monkeypatch):
monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path / "state"))
data_dir = tmp_path / "data"
c1 = TestClient(create_app(SessionManager(data_dir=data_dir)))
c1.post("/v1/settings/auto-approve", json={"auto_approve": True})
reborn = SessionManager(data_dir=data_dir)
assert reborn.auto_approve() is True
assert TestClient(create_app(reborn)).get("/v1/settings").json()["auto_approve"] is True
def test_prefs_falls_back_to_config_when_unset(tmp_path, monkeypatch):
# No prefs key set → the manager reads config.toml. Point config at a file that enables it.
state = tmp_path / "state"
state.mkdir(parents=True)
(state / "config.toml").write_text("auto_approve = true\n")
monkeypatch.setenv("COWORKER_STATE_DIR", str(state))
mgr = SessionManager(data_dir=tmp_path / "data")
assert mgr.auto_approve() is True # config value, no prefs entry
# A prefs write then wins over config.
mgr.set_auto_approve(False)
assert mgr.auto_approve() is False
def test_build_engine_override_beats_config(tmp_path, monkeypatch):
# build_engine's auto_approve arg (what the server passes) overrides the config value.
monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path / "state"))
from coworker.agent import build_engine
from coworker.agents.chat import chat_agent
# config has it off by default; override to on → a reviewer is attached.
engine = build_engine(agent=chat_agent(), auto_approve=True, auto_approve_shadow=False)
assert engine.reviewer is not None
engine2 = build_engine(agent=chat_agent(), auto_approve=False, auto_approve_shadow=False)
assert engine2.reviewer is None