mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-13 15:50:02 +00:00
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:
+18
-4
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, string>;
|
||||
// {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<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". */
|
||||
export async function setSessionsPeek(
|
||||
n: number,
|
||||
|
||||
@@ -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 (
|
||||
<div className="relative">
|
||||
@@ -875,7 +899,7 @@ function ModeMenu({
|
||||
role="menu"
|
||||
data-testid="mode-menu"
|
||||
>
|
||||
{PERMISSION_OPTIONS.map((o) => (
|
||||
{options.map((o) => (
|
||||
<button
|
||||
key={o.value}
|
||||
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 {
|
||||
getSettings,
|
||||
getTrustedWorkspaces,
|
||||
setAutoApprove,
|
||||
setAutoApproveShadow,
|
||||
setCompactionSettings,
|
||||
setContextBar,
|
||||
setOnboarded,
|
||||
@@ -447,6 +449,8 @@ function AppearanceSection() {
|
||||
|
||||
<ContextBarCard />
|
||||
|
||||
<AutoApproveCard />
|
||||
|
||||
<FilesCard />
|
||||
|
||||
<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() {
|
||||
const [peek, setPeek] = useState<number | null>(null);
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user