From 576aabe32077b6fc7056244b72bee107100b3d52 Mon Sep 17 00:00:00 2001 From: batteryshark <1574871+batteryshark@users.noreply.github.com> Date: Sun, 25 Oct 2026 18:12:53 -0400 Subject: [PATCH 1/2] Unify caller settings with durable drafts and atomic function controls --- admin/api.py | 28 ++ admin/caller_settings.py | 65 +++++ admin/server.py | 45 +++ admin/tests/settings_fixtures.py | 117 ++++++++ admin/tests/test_caller_row_activation.py | 6 +- admin/tests/test_caller_settings.py | 162 +++++++++++ admin/tests/webkit_fixtures.py | 10 +- admin/views/dashboard.py | 7 +- admin/views/layout.py | 3 +- admin/views/settings.py | 13 + admin/views/settings_assets.py | 194 +++++++++++++ broker/operations.py | 16 +- broker/policy.py | 4 + broker/store.py | 30 +- desktop/app.py | 9 +- desktop/tests/test_app.py | 3 + docs/caller-settings.md | 51 ++++ macapp/Sources/ToolstackApp/AppModel.swift | 19 ++ .../ToolstackApp/CallerSettingsEditor.swift | 259 ++++++++++++++++++ .../ToolstackApp/NativeSnapshots.swift | 49 ++++ .../Sources/ToolstackApp/ToolstackApp.swift | 11 +- macapp/Sources/ToolstackKit/ApiClient.swift | 11 + .../ToolstackKit/CallerSettingsDraft.swift | 197 +++++++++++++ .../CallerSettingsTests.swift | 117 ++++++++ .../StalePublicationTests.swift | 18 ++ 25 files changed, 1419 insertions(+), 25 deletions(-) create mode 100644 admin/caller_settings.py create mode 100644 admin/tests/settings_fixtures.py create mode 100644 admin/tests/test_caller_settings.py create mode 100644 admin/views/settings.py create mode 100644 admin/views/settings_assets.py create mode 100644 docs/caller-settings.md create mode 100644 macapp/Sources/ToolstackApp/CallerSettingsEditor.swift create mode 100644 macapp/Sources/ToolstackKit/CallerSettingsDraft.swift create mode 100644 macapp/Tests/ToolstackKitTests/CallerSettingsTests.swift diff --git a/admin/api.py b/admin/api.py index b1f3059..e0e2585 100644 --- a/admin/api.py +++ b/admin/api.py @@ -138,6 +138,34 @@ async def api_broker_action(action: str, user: str = Depends(require_user)): return supervisor.status() # --- callers / policy / tokens ------------------------------------------- + @app.get("/api/callers/{name}/settings") + async def api_get_settings(name: str, user: str = Depends(require_user)): + from .caller_settings import snapshot + from fastapi.responses import JSONResponse + config = broker_config.load() + try: + with open_store(config) as store: + result = snapshot(store, config, name) + result["operator"] = user + return JSONResponse(result, headers={"Cache-Control": "no-store"}) + except LookupError as exc: + raise HTTPException(status_code=404, detail=str(exc)) + + @app.put("/api/callers/{name}/settings") + async def api_set_settings(name: str, request: Request, user: str = Depends(require_user)): + from .caller_settings import apply, ChangedElsewhere + data = await _json_object(request) + config = broker_config.load() + try: + with open_store(config) as store: + return apply(store, config, name, data, user) + except ChangedElsewhere as exc: + raise HTTPException(status_code=409, detail=str(exc)) + except LookupError as exc: + raise HTTPException(status_code=404, detail=str(exc)) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + @app.get("/api/callers") async def api_callers(user: str = Depends(require_user)): with open_store(broker_config.load()) as store: diff --git a/admin/caller_settings.py b/admin/caller_settings.py new file mode 100644 index 0000000..d71d3f0 --- /dev/null +++ b/admin/caller_settings.py @@ -0,0 +1,65 @@ +"""One settings snapshot and atomic mutation shared by HTML and native clients.""" +import copy +import hashlib +import json + +from broker import operations +from broker.registry import Registry + + +class ChangedElsewhere(ValueError): + pass + + +def snapshot(store, config, name): + caller = operations.require_caller(store, name) + if caller["revoked_at"] is not None: + raise LookupError("Caller is revoked") + catalog = {} + registry = Registry.from_sources(config.tools_root, config.tool_dirs) + for tool in registry.tool_ids(): + catalog[tool] = [] + for op in registry.list_ops(): + catalog[op["tool"]].append({key: op[key] for key in ("op", "risk", "description")}) + policy = store.policy_for(caller["id"]) + installation = store.installation_id + material = [installation, caller["id"], policy, catalog] + revision = hashlib.sha256(json.dumps(material, sort_keys=True, separators=(",", ":")).encode()).hexdigest() + return {"name": name, "caller_id": caller["id"], "installation": installation, "revision": revision, + "policy": policy, "enabled": operations.enabled_tools(policy), "catalog": catalog} + + +def apply(store, config, name, data, operator): + current = snapshot(store, config, name) + if data.get("revision") != current["revision"]: + raise ChangedElsewhere("Changed elsewhere. Compare latest before saving.") + catalog = current["catalog"] + enabled = data.get("enabled") + disabled = data.get("disabled_functions") + tools = data.get("tools") + if not isinstance(enabled, list) or not all(isinstance(t, str) and t in catalog for t in enabled): + raise ValueError("Invalid enabled tools") + if not isinstance(disabled, dict) or not isinstance(tools, dict): + raise ValueError("Invalid function settings") + # Send precisely the observed catalog. Never replace authority the editor could not see. + if set(disabled) != set(catalog) or set(tools) != set(catalog): + raise ChangedElsewhere("Function set changed. Compare latest before saving.") + after = copy.deepcopy(current["policy"]) + after["enabled"] = sorted(set(current["enabled"]) - set(catalog) | set(enabled)) + after.setdefault("tools", {}) + after.setdefault("disabled_functions", {}) + for tool, rows in catalog.items(): + names = {op["op"] for op in rows} + excluded, effects = disabled[tool], tools[tool] + if (not isinstance(excluded, list) or not all(isinstance(op, str) and op in names for op in excluded) + or not isinstance(effects, dict) or set(effects) != names + or any(v not in ("allow", "review", "deny") for v in effects.values())): + raise ValueError("Invalid function settings") + old_excluded = set(after["disabled_functions"].get(tool, [])) - names + after["disabled_functions"][tool] = sorted(old_excluded | set(excluded)) + after["tools"].setdefault(tool, {}).update(effects) + caller = operations.require_caller(store, name) + if not store.compare_and_set_policy(caller["id"], current["policy"], after, operator): + raise ChangedElsewhere("Changed elsewhere. Compare latest before saving.") + # Receipt is tied to the exact committed policy, not a later writer's snapshot. + return {"outcome": "saved", "name": name, "policy": after} diff --git a/admin/server.py b/admin/server.py index 38d74ce..6108767 100644 --- a/admin/server.py +++ b/admin/server.py @@ -417,6 +417,51 @@ async def clear_audit(request: Request): return redirect("/#audit") # --- policy --------------------------------------------------------------- + @app.get("/callers/{name}/settings") + async def edit_settings(request: Request, name: str): + from .caller_settings import snapshot + from .views.settings import settings_view + user = current_user(request) + if not user: + return redirect("/login") + try: + config = broker_config.load() + with open_store(config) as store: + state = snapshot(store, config, name) + state["csrf"] = csrf_for(request) + state["operator"] = user + if request.headers.get("accept") == "application/json": + return JSONResponse(state, headers={"Cache-Control": "no-store"}) + return HTMLResponse(settings_view(user=user, csrf=csrf_for(request), caller=name, state=state), + headers={"Cache-Control": "no-store"}) + except (LookupError, OSError, ValueError, sqlite3.Error): + if request.headers.get("accept") == "application/json": + return JSONResponse({"error": "Settings unavailable"}, status_code=503) + return HTMLResponse(settings_view(user=user, csrf=csrf_for(request), caller=name, state=None), status_code=503) + + @app.post("/callers/{name}/settings") + async def save_settings(request: Request, name: str): + from .caller_settings import apply, ChangedElsewhere + user = current_user(request) + if not user: + return JSONResponse({"outcome": "rejected", "error": "Sign in before comparing and saving"}, status_code=401) + data = await read_form(request) + if not csrf_ok(request, data): + return JSONResponse({"outcome": "rejected", "error": "Session check failed. Compare latest."}, status_code=403) + try: + submitted = json.loads(data.get("settings_json", "null")) + if not isinstance(submitted, dict): + raise ValueError("Invalid settings") + config = broker_config.load() + with open_store(config) as store: + return JSONResponse(apply(store, config, name, submitted, user)) + except ChangedElsewhere as exc: + return JSONResponse({"outcome": "rejected", "error": str(exc)}, status_code=409) + except (ValueError, LookupError) as exc: + return JSONResponse({"outcome": "rejected", "error": str(exc)}, status_code=400) + except Exception: + return JSONResponse({"outcome": "unknown", "error": "Save outcome unknown. Compare latest before retrying."}, status_code=503) + def policy_recovery(request, user, name, data, message, status): try: config = broker_config.load() diff --git a/admin/tests/settings_fixtures.py b/admin/tests/settings_fixtures.py new file mode 100644 index 0000000..1c8469c --- /dev/null +++ b/admin/tests/settings_fixtures.py @@ -0,0 +1,117 @@ +"""600-function production editor, entirely fake and offscreen.""" +from admin.views.settings import settings_view + + +def state(): + return dict(name="writer-demo", caller_id=1, operator="fake-admin", installation="fake-installation", revision="one", csrf="fake", + enabled=["tool00"], policy={"tools": {"tool00": {"fn01": "review"}}}, + catalog={f"tool{t:02}": [{"op": f"fn{o:02}", "risk": "read", "description": "Fake function"} + for o in range(20)] for t in range(30)}) + + +CHECKS = r""" +const initial=JSON.parse(document.getElementById('caller-settings-state').textContent); +let remote=JSON.parse(JSON.stringify(initial)),posts=0,mode='read'; +window.fetch=async(url,options={})=>{ + if(options.method==='POST'){posts++;if(mode==='unknown')throw Error('lost receipt');return {ok:false,status:409,json:async()=>({outcome:'rejected'})};} + return {ok:true,status:200,json:async()=>remote}; +}; +const dlg=()=>document.querySelector('.caller-settings'),get=(s)=>dlg().querySelector(s); +const named=(s)=>[...document.querySelectorAll('dialog[open] button')].find(n=>n.textContent===s); +const focus=(s)=>get('[data-focus="'+s+'"]'); +const type=q=>{const s=get('input[type=search]');s.value=q;s.dispatchEvent(new Event('input',{bubbles:true}));}; +check(!!dlg()&&dlg().getAttribute('aria-label')==='Caller settings for writer-demo','named modal popup'); +check(document.activeElement.getAttribute('role')==='tab','focus enters selected tab'); +check(get('[role=status]').textContent.includes('600'),'600 functions counted'); +check(dlg().querySelectorAll('.settings-group').length===30,'30 collapsed tool groups'); +check(dlg().querySelectorAll('.function-row').length===0,'collapsed groups do not create 600 controls'); +focus('tool-tool01').click();focus('group-tool01').click(); +check(dlg().querySelectorAll('.function-row input:checked').length===20,'whole tool enables all 20 functions'); +focus('tab-policy').click(); +const pick=focus('effect-tool01.fn01');pick.value='review';pick.dispatchEvent(new Event('change',{bubbles:true})); +check(document.activeElement===get('input[type=search]')||document.activeElement.dataset.focus==='tab-policy','tab switch retains keyboard focus'); +type('fn01');check(dlg().querySelectorAll('.function-row').length===30,'search opens matching groups across all tools'); +check(focus('effect-tool01.fn01').value==='review','policy edit survives search'); +focus('tab-enabled').click();focus('bulk-tool00-false').focus();focus('bulk-tool00-false').click(); +check(!focus('enable-tool00.fn01').checked,'None matches disables filtered function'); +check(document.activeElement.dataset.focus==='bulk-tool00-false','bulk rerender retains focus'); +type('fn02');check(focus('enable-tool00.fn02').checked,'None matches leaves hidden functions unchanged'); +type('fn01');focus('tab-policy').click();check(focus('effect-tool01.fn01').value==='review','both tabs share one draft'); +const selected=get('[role=tab][aria-selected=true]');selected.dispatchEvent(new KeyboardEvent('keydown',{key:'ArrowRight',bubbles:true})); +check(get('[role=tab][aria-selected=true]').id==='settings-tab-enabled','Right arrow wraps tabs'); +get('[role=tab][aria-selected=true]').dispatchEvent(new KeyboardEvent('keydown',{key:'End',bubbles:true})); +check(get('[role=tab][aria-selected=true]').id==='settings-tab-policy','End selects last tab'); +type('no-such-function');check(dlg().textContent.includes('No matching functions'),'empty search recovery'); +named('Clear filters').click();check(get('input[type=search]').value==='','clear filters restores groups'); +check(document.activeElement===get('input[type=search]'),'empty recovery focuses search'); +remote.revision='two';remote.policy.tools.tool00.fn02='allow'; +named('Save changes').click();await wait(100); +check(posts===1&&named('Save changes').disabled,'stale rejection blocks Save without replay'); +named('Compare latest').click();await wait(100); +let comparison=document.querySelector('.settings-compare'); +check(!!comparison&&comparison.textContent.includes('Your draft')&&comparison.textContent.includes('Latest saved'),'side-by-side comparison'); +check(document.activeElement.textContent==='Stay','comparison defaults to Stay'); +named('Keep my edits on latest').click(); +type('fn02');check(focus('effect-tool00.fn02').value==='allow','explicit rebase keeps remote unedited policy'); +type('fn01');check(focus('effect-tool01.fn01').value==='review','explicit rebase retains local policy edit'); +focus('tab-enabled').click();check(!focus('enable-tool00.fn01').checked,'explicit rebase retains function exclusion'); +mode='unknown';named('Save changes').click();await wait(100); +check(posts===2&&named('Save changes').disabled&&dlg().textContent.includes('Save outcome unknown'),'lost receipt blocks replay'); +named('Save changes').click();check(posts===2,'duplicate unknown save sends no request'); +dlg().dispatchEvent(new Event('cancel',{cancelable:true})); +check(document.activeElement.textContent==='Stay','Escape dismissal defaults to Stay'); +document.activeElement.click();check(!!dlg(),'Stay retains popup'); +named('Cancel').click();named('Keep draft and close').click();await wait(100); +check(!dlg(),'explicit keep-draft close'); +ToolstackSettings.open(remote);await wait(100); +check(dlg().querySelector('input[type=search]').value==='fn01','durable draft restores shared search on reopen'); +check(named('Save changes').disabled&&dlg().textContent.includes('Save outcome unknown'),'durable unknown outcome cannot replay after reopen'); +check(!focus('enable-tool00.fn01').checked,'durable draft restores enablement'); +named('Compare latest').click();await wait(100);named('Stay').click(); +check(posts===2,'comparison never sends a mutation'); +named('Cancel').click();named('Discard draft').click();await wait(100); +ToolstackSettings.open(remote);await wait(100); +check(named('Save changes').disabled&&get('input[type=search]').value==='','discard explicitly starts clean'); +check(localStorage.length===0,'draft storage cleared after discard'); +type('fn01');focus('tab-policy').click(); +""" + + +def settings_fixtures(instrument): + html = settings_view(user="fake-admin", csrf="fake", caller="writer-demo", state=state()) + result = [dict(name=f"caller-settings-{width}", html=instrument(html, CHECKS), width=width, height=900) + for width in (1180, 620, 390)] + result.append(dict(name="caller-settings-save-1180", html=instrument(html, SAVE_CHECKS), width=1180, height=900)) + return result + + +SAVE_CHECKS = r""" +const initial=JSON.parse(document.getElementById('caller-settings-state').textContent); +const d=()=>document.querySelector('.caller-settings'); +const named=s=>[...d().querySelectorAll('button')].find(n=>n.textContent===s); +const control=s=>d().querySelector('[data-focus="'+s+'"]'); +const search=d().querySelector('[type=search]');search.value='fn01';search.dispatchEvent(new Event('input')); +const filters=d().querySelectorAll('.settings-filter [type=checkbox]');filters[1].click(); +check(d().querySelectorAll('.function-row').length===1,'Needs review shows only Review policies'); +control('tab-policy').click();const effect=control('effect-tool00.fn01');effect.focus();effect.value='allow';effect.dispatchEvent(new Event('change')); +check(document.activeElement===search,'filter removing edited row returns focus to search'); +check(d().textContent.includes('No matching functions'),'review filter responds to changed policy'); +named('Clear filters').click();control('tab-enabled').click();control('tool-tool01').click(); +let posts=0,ack; +window.fetch=async(url,options)=>{ + check(options.method==='POST','single combined mutation uses POST');posts++; + const form=new URLSearchParams(options.body),body=JSON.parse(form.get('settings_json')); + check(form.get('_csrf')==='fake'&&body.revision==='one','combined Save carries CSRF and loaded revision'); + check(body.enabled.includes('tool01')&&body.tools.tool00.fn01==='allow','combined Save includes both tab edits'); + check(Object.keys(body.tools).length===30&&Object.values(body.tools).reduce((n,x)=>n+Object.keys(x).length,0)===600,'combined Save includes exactly the observed 600-function scope'); + return new Promise(resolve=>{ack=()=>resolve({ok:true,status:200,json:async()=>({outcome:'saved',name:initial.name,policy:body})});}); +}; +named('Save changes').click();named('Save changes').click(); +check(posts===1&&named('Save changes').disabled,'in-flight duplicate Save is latched'); +check([...d().querySelectorAll('.settings-list button,input,select')].every(x=>x.disabled),'in-flight edits cannot change the submitted draft'); +const entry=JSON.parse(localStorage.getItem(localStorage.key(0))); +check(entry.unknown===true,'interrupted in-flight write is durably blocked before dispatch'); +ack();await wait(100); +check(!d()&&localStorage.length===0,'exact combined receipt closes editor and clears durable draft'); +check(posts===1,'successful Save never replays'); +""" diff --git a/admin/tests/test_caller_row_activation.py b/admin/tests/test_caller_row_activation.py index 452b895..2b1340e 100644 --- a/admin/tests/test_caller_row_activation.py +++ b/admin/tests/test_caller_row_activation.py @@ -23,9 +23,9 @@ def test_rows_keep_named_keyboard_path_and_existing_destination(self): tags = Tags(html).tags row = next(attrs for tag, attrs in tags if tag == 'tr' and 'data-caller-policy' in attrs) self.assertEqual(row['tabindex'], '0') - self.assertEqual(row['aria-label'], 'Open policy settings for writer-demo') + self.assertEqual(row['aria-label'], 'Open caller settings for writer-demo') link = next(attrs for tag, attrs in tags if 'data-policy-link' in attrs) - self.assertEqual(link['href'], '/callers/writer-demo/policy') + self.assertEqual(link['href'], '/callers/writer-demo/settings') self.assertEqual(sum(tag == 'button' for tag, _ in tags), 3) def test_empty_and_escaped_names(self): @@ -33,7 +33,7 @@ def test_empty_and_escaped_names(self): html = _callers_section([dict(name="a'<>\"", revoked_at=None)], 'fake') rows = [attrs for tag, attrs in Tags(html).tags if 'data-caller-policy' in attrs] self.assertEqual(len(rows), 1) - self.assertEqual(rows[0]['aria-label'], "Open policy settings for a'<>\"") + self.assertEqual(rows[0]['aria-label'], "Open caller settings for a'<>\"") def test_generated_script_guards_nested_actions_and_repeat(self): html = page('Callers', _callers_section([], 'fake'), user=None) diff --git a/admin/tests/test_caller_settings.py b/admin/tests/test_caller_settings.py new file mode 100644 index 0000000..6f1eb58 --- /dev/null +++ b/admin/tests/test_caller_settings.py @@ -0,0 +1,162 @@ +"""Atomic combined settings and revision guards with real SQLite connections.""" +import copy +import json +import sqlite3 +import unittest +from contextlib import closing +from pathlib import Path + +from admin.tests import test_api +from broker import operations, policy +from broker.store import Store + + +class CallerSettingsTests(unittest.TestCase): + setUp = test_api.JsonApi.setUp + _restore_env = test_api.JsonApi._restore_env + _auth = test_api.JsonApi._auth + _token = test_api.JsonApi._token + + def seed(self, before=None): + self.headers = self._auth() + with closing(Store(self.db_path)) as store: + caller = store.add_caller("writer-demo") + store.set_policy(caller, before or {"tools": {"echo": {"say": "review"}}}) + self.url = "/api/callers/writer-demo/settings" + state = self.client.get(self.url, headers=self.headers) + self.assertEqual(state.status_code, 200, state.text) + return state.json() + + def edit(self, state): + return {"revision": state["revision"], "enabled": [t for t in state["enabled"] if t in state["catalog"]], + "disabled_functions": {t: [] for t in state["catalog"]}, + "tools": {t: {o["op"]: state["policy"].get("tools", {}).get(t, {}).get(o["op"], "deny") + for o in ops} for t, ops in state["catalog"].items()}} + + def test_one_save_gates_functions_and_retains_disabled_policy(self): + state = self.seed() + draft = self.edit(state) + draft["tools"]["echo"]["say"] = "allow" + draft["disabled_functions"]["echo"] = ["say"] + response = self.client.put(self.url, headers=self.headers, json=draft) + self.assertEqual(response.status_code, 200, response.text) + after = response.json()["policy"] + self.assertEqual(after["tools"]["echo"]["say"], "allow") + self.assertEqual(policy.decide(after, "echo", "say"), "deny") + state = self.client.get(self.url, headers=self.headers).json() + draft = self.edit(state) + response = self.client.put(self.url, headers=self.headers, json=draft) + self.assertEqual(policy.decide(response.json()["policy"], "echo", "say"), "allow") + state = self.client.get(self.url, headers=self.headers).json() + draft = self.edit(state); draft["enabled"] = [] + after = self.client.put(self.url, headers=self.headers, json=draft).json()["policy"] + self.assertEqual(policy.decide(after, "echo", "say"), "deny") + self.assertEqual(after["tools"]["echo"]["say"], "allow") + self.assertEqual(operations.enabled_tools(after), []) + + def test_stale_write_has_no_effect_or_audit(self): + state = self.seed(); draft = self.edit(state) + with closing(Store(self.db_path)) as store: + operations.set_policy(store, "writer-demo", ["echo.say"], [], "other") + count = len(store.recent_audit(500)) + response = self.client.put(self.url, headers=self.headers, json=draft) + self.assertEqual(response.status_code, 409) + with closing(Store(self.db_path)) as store: + self.assertEqual(len(store.recent_audit(500)), count) + self.assertEqual(store.policy_for(store.caller_by_name("writer-demo")["id"])["tools"]["echo"]["say"], "allow") + + def test_preserves_unseen_tools_operations_and_future_metadata(self): + state = self.seed({"enabled": ["echo", "offline"], "tools": {"echo": {"say": "review", "retired": "allow"}, "offline": {"op": "review"}}, + "disabled_functions": {"echo": ["retired"], "offline": ["gone"]}, "future": {"keep": True}}) + draft = self.edit(state); draft["tools"]["echo"]["say"] = "deny" + after = self.client.put(self.url, headers=self.headers, json=draft).json()["policy"] + self.assertEqual(after["enabled"], ["echo", "offline"]) + self.assertEqual(after["tools"]["echo"]["retired"], "allow") + self.assertEqual(after["tools"]["offline"], {"op": "review"}) + self.assertEqual(after["disabled_functions"], {"echo": ["retired"], "offline": ["gone"]}) + self.assertEqual(after["future"], {"keep": True}) + + def test_invalid_payload_never_partially_writes(self): + state = self.seed(); good = self.edit(state) + for field, value in (("enabled", ["unknown"]), ("enabled", "echo"), ("tools", {"echo": {"say": "permit"}}), + ("tools", {}), ("disabled_functions", {"echo": ["gone"]}), ("disabled_functions", [])): + draft = copy.deepcopy(good); draft[field] = value + response = self.client.put(self.url, headers=self.headers, json=draft) + self.assertIn(response.status_code, (400, 409), response.text) + self.assertEqual(self.client.get(self.url, headers=self.headers).json()["revision"], state["revision"]) + + def test_catalog_change_blocks_old_draft(self): + state = self.seed(); draft = self.edit(state) + path = Path(self.tmp, "tools/echo/toolyard.toml") + path.write_text(path.read_text() + '\n[[operations]]\nname="new_op"\nrisk="read"\ndescription="new"\n') + self.assertEqual(self.client.put(self.url, headers=self.headers, json=draft).status_code, 409) + + def test_revoked_caller_cannot_save(self): + state = self.seed(); draft = self.edit(state) + with closing(Store(self.db_path)) as store: store.revoke_caller("writer-demo") + self.assertEqual(self.client.put(self.url, headers=self.headers, json=draft).status_code, 404) + + def test_database_compare_and_swap_closes_the_read_write_race(self): + self.seed() + with closing(Store(self.db_path)) as first, closing(Store(self.db_path)) as second: + caller = first.caller_by_name("writer-demo")["id"] + before = first.policy_for(caller) + changed = {"enabled": [], "tools": before["tools"]} + self.assertTrue(first.compare_and_set_policy(caller, before, changed, "one")) + self.assertFalse(second.compare_and_set_policy(caller, before, {"tools": {}}, "two")) + self.assertEqual(second.policy_for(caller), changed) + + def test_audit_failure_rolls_back_policy_and_invalidation(self): + self.seed() + with closing(Store(self.db_path)) as store: + caller = store.caller_by_name("writer-demo")["id"]; before = store.policy_for(caller) + store._conn.execute("CREATE TRIGGER fail_settings BEFORE INSERT ON audit_events BEGIN SELECT RAISE(ABORT, 'fake full disk'); END") + store._conn.commit() + with self.assertRaises(sqlite3.IntegrityError): + store.compare_and_set_policy(caller, before, {"enabled": [], "tools": {}}, "operator") + self.assertEqual(store.policy_for(caller), before) + + def test_legacy_mutations_keep_function_exclusions(self): + self.seed({"enabled": ["echo"], "tools": {"echo": {"say": "allow"}}, "disabled_functions": {"echo": ["say"]}}) + with closing(Store(self.db_path)) as store: + operations.set_policy(store, "writer-demo", ["echo.say"], [], "operator") + caller = store.caller_by_name("writer-demo")["id"] + self.assertEqual(policy.decide(store.policy_for(caller), "echo", "say"), "deny") + operations.set_enabled_tools(store, "writer-demo", [], "operator") + self.assertEqual(store.policy_for(caller)["enabled"], []) + + def test_web_settings_uses_cookie_csrf_not_bearer_authority(self): + self.seed() + url = "/callers/writer-demo/settings" + self.assertEqual(self.client.get(url, headers=self.headers, follow_redirects=False).status_code, 303) + self.assertEqual(self.client.post(url, headers=self.headers, data={}).status_code, 401) + self.client.post("/login", data={"username": "admin", "password": test_api.PASSWORD}) + state = self.client.get(url, headers={"Accept": "application/json"}).json() + draft = self.edit(state) + self.assertNotIn("csrf", self.client.get(self.url, headers=self.headers).json()) + self.assertEqual(self.client.post(url, data={"settings_json": json.dumps(draft)}).status_code, 403) + response = self.client.post(url, data={"_csrf": state["csrf"], "settings_json": json.dumps(draft)}) + self.assertEqual(response.status_code, 200, response.text) + + def test_30_tools_by_20_functions_commit_together(self): + for index in range(30): + tool = "echo" if index == 0 else f"tool{index:02}" + directory = Path(self.tmp, "tools", tool); directory.mkdir(exist_ok=True) + content = f'id="{tool}"\ntype="api"\n[entrypoint]\ncommand="never-run"\nport={4601 + index}\n' + content += "".join(f'\n[[operations]]\nname="fn{op:02}"\nrisk="read"\ndescription="Fake function"\n' for op in range(20)) + (directory / "toolyard.toml").write_text(content) + state = self.seed(); draft = self.edit(state) + self.assertEqual(len(state["catalog"]), 30) + self.assertEqual(sum(map(len, state["catalog"].values())), 600) + draft["enabled"] = list(state["catalog"]) + for tool in draft["tools"]: + draft["tools"][tool]["fn01"] = "review" + draft["disabled_functions"][tool] = ["fn02"] + result = self.client.put(self.url, headers=self.headers, json=draft) + self.assertEqual(result.status_code, 200, result.text) + after = result.json()["policy"] + for tool in state["catalog"]: + self.assertEqual(policy.decide(after, tool, "fn01"), "review") + self.assertEqual(policy.decide(after, tool, "fn02"), "deny") + with closing(Store(self.db_path)) as store: + self.assertEqual([event["event_type"] for event in store.recent_audit(500)], ["settings_changed"]) diff --git a/admin/tests/webkit_fixtures.py b/admin/tests/webkit_fixtures.py index f1c0e35..3261b09 100644 --- a/admin/tests/webkit_fixtures.py +++ b/admin/tests/webkit_fixtures.py @@ -32,18 +32,18 @@ const rows=document.querySelectorAll('[data-caller-policy]'), row=rows[0]; const doubleClick=el=>el.dispatchEvent(new MouseEvent('dblclick',{bubbles:true})); doubleClick(row.cells[0]);await wait(150); -check(window.fixtureNavigations.join() === 'https://fixture.invalid/callers/writer-demo/policy','row double-click navigates to caller policy'); +check(window.fixtureNavigations.join() === 'https://fixture.invalid/callers/writer-demo/settings','row double-click opens unified caller settings'); window.fixtureNavigations=[]; for(const el of row.querySelectorAll('button,a,input,form'))doubleClick(el); await wait(150);check(window.fixtureNavigations.length===0,'nested row controls never trigger double-click navigation'); row.focus();check(document.activeElement===row && !!row.getAttribute('aria-label'),'caller row focusable and named'); row.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',bubbles:true,cancelable:true}));await wait(150); -check(window.fixtureNavigations.join() === 'https://fixture.invalid/callers/writer-demo/policy','Enter on focused row navigates to caller policy'); +check(window.fixtureNavigations.join() === 'https://fixture.invalid/callers/writer-demo/settings','Enter on focused row opens unified caller settings'); window.fixtureNavigations=[]; row.querySelector('button').dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',bubbles:true})); await wait();check(window.fixtureNavigations.length===0,'nested keyboard action does not navigate'); doubleClick(rows[1].cells[1]);await wait(150); -check(window.fixtureNavigations.join() === 'https://fixture.invalid/callers/revoked-demo/policy','revoked row retains existing policy destination'); +check(window.fixtureNavigations.join() === 'https://fixture.invalid/callers/revoked-demo/settings','revoked row reaches guarded settings destination'); """ CONFIRM = r""" @@ -102,6 +102,7 @@ def instrument(html, checks, returned=None): const results=[];const check=(ok,name)=>results.push({name,ok:!!ok}); const wait=(ms=40)=>new Promise(r=>setTimeout(r,ms)); const checkDialogButtons=dialog=>{ + if(dialog.matches('.caller-settings,.settings-compare'))return; const buttons=Array.from(dialog.querySelectorAll('button')); check(buttons.length===2,'open dialog has two actions'); const bounds=buttons.map(button=>{ @@ -234,7 +235,8 @@ def fixtures(): for width in (1440,1180): result.append(dict(name=f'{name}-{width}',html=instrument(html,checks,returned),width=width,height=900)) result.append(dict(name='shell-startup-620',html=instrument(startup_error('http://127.0.0.1:8780','Admin exited with status 7 before /login responded.'),"check(document.body.textContent.includes('status 7'),'shell observed cause');check(document.querySelector('#retry')!==null,'shell Retry available');let retries=0;window.pywebview={api:{retry:()=>{retries++;}}};document.querySelector('#retry').click();document.querySelector('#retry').click();check(retries===1,'shell Retry button latches synthetic duplicate clicks');"),width=620,height=360)) - return result + from admin.tests.settings_fixtures import settings_fixtures + return result + settings_fixtures(instrument) if __name__ == '__main__': diff --git a/admin/views/dashboard.py b/admin/views/dashboard.py index c122062..ad9375d 100644 --- a/admin/views/dashboard.py +++ b/admin/views/dashboard.py @@ -64,13 +64,12 @@ def token_reveal_banner(lead: str, token: str) -> str: def _callers_section(callers, csrf: str) -> str: rows = "".join( - f"" + f"" f"{esc(c['name'])}" f"{'revoked' if c['revoked_at'] else 'active'}" "" - f"Policy" - f"Tools" + f"Settings" f"
" f"{_csrf_field(csrf)}" diff --git a/admin/views/layout.py b/admin/views/layout.py index 245e818..9255418 100644 --- a/admin/views/layout.py +++ b/admin/views/layout.py @@ -6,6 +6,7 @@ from typing import Any from .assets import _CSS, _JS +from .settings_assets import CSS as SETTINGS_CSS, JS as SETTINGS_JS def esc(value: Any) -> str: @@ -39,7 +40,7 @@ def page(title: str, body: str, *, user: str | None, csrf: str = "", nav: str = "" "" f"{esc(title)} · Toolstack Admin" - f"" + f"" f"
{header}
" f"
{body}
" ) diff --git a/admin/views/settings.py b/admin/views/settings.py new file mode 100644 index 0000000..4c18ab6 --- /dev/null +++ b/admin/views/settings.py @@ -0,0 +1,13 @@ +"""Unified caller settings surface; shared by the browser and desktop shell.""" +import json + +from .layout import esc, page + + +def settings_view(*, user, csrf, caller, state): + if state is None: + return page("Caller settings", f"
Settings unavailable · {esc(caller)}. " + "Your drafts have not been changed. Retry load
", user=user, csrf=csrf) + payload = json.dumps(state).replace("<", "\\u003c") + return page("Caller settings", "

Back to callers

" + f"", user=user, csrf=csrf) diff --git a/admin/views/settings_assets.py b/admin/views/settings_assets.py new file mode 100644 index 0000000..3128965 --- /dev/null +++ b/admin/views/settings_assets.py @@ -0,0 +1,194 @@ +"""Clear desk caller settings. No framework or additional service.""" + +CSS = r""" +dialog.caller-settings{width:900px;max-width:calc(100vw - 24px);max-height:calc(100dvh - 24px);padding:0;overflow:hidden;} +.caller-settings .settings-shell{display:flex;flex-direction:column;max-height:calc(100dvh - 30px);padding:20px;gap:12px;} +.caller-settings h2{margin:0;font-size:19px;overflow-wrap:anywhere;} +.settings-tabs{display:flex;gap:6px;border-bottom:1px solid var(--line);} +.settings-tabs button[aria-selected=true]{border-color:var(--accent);color:var(--accent);background:#e3edfc;} +.settings-filter{display:flex;gap:10px;align-items:center;flex-wrap:wrap;} +.settings-filter input[type=search]{flex:1;min-width:180px;} +.settings-filter label,.function-row label{display:flex;align-items:center;gap:8px;min-height:38px;} +.settings-list{overflow:auto;min-height:130px;max-height:55dvh;border:1px solid var(--line);border-radius:8px;} +.settings-group{border-bottom:1px solid var(--line);padding:10px;} +.settings-group:last-child{border-bottom:0;} +.settings-group-head{display:flex;gap:10px;align-items:center;flex-wrap:wrap;} +.settings-group-head .summary{flex:1;min-width:160px;color:#526173;} +.function-row{display:flex;gap:12px;align-items:center;padding:6px 10px 6px 30px;} +.function-row .function-name{flex:1;min-width:0;overflow-wrap:anywhere;} +.function-row select{width:125px;flex-shrink:0;} +.settings-footer{display:flex;gap:12px;align-items:center;flex-wrap:wrap;} +.settings-footer [role=status]{flex:1;min-width:150px;margin:0;color:#526173;} +.settings-footer .actions{margin-left:auto;gap:20px;} +.settings-stale{padding:10px;background:#fff6df;border-radius:6px;} +.settings-compare{width:780px;max-height:80dvh;overflow:auto;} +.settings-compare td{overflow-wrap:anywhere;} +@media(max-width:550px){.caller-settings .settings-shell{padding:12px;}.function-row{padding-left:8px;flex-wrap:wrap;}.settings-tabs button{padding:6px;}.settings-group-head{gap:6px;}} +""" + +JS = r""" +window.ToolstackSettings=(()=>{ + const clone=x=>JSON.parse(JSON.stringify(x)), same=(a,b)=>JSON.stringify(a)===JSON.stringify(b); + const effect=(s,t,o)=>s.tools[t]?.[o]||'deny'; + const active=(s,t,o)=>s.enabled.includes(t)&&!s.disabled_functions[t]?.includes(o); + function values(s){ + const d={enabled:s.enabled.filter(t=>t in s.catalog).sort(),tools:{},disabled_functions:{}}; + Object.keys(s.catalog).sort().forEach(t=>{ + d.tools[t]={};d.disabled_functions[t]=[]; + s.catalog[t].forEach(o=>{d.tools[t][o.op]=s.policy.tools?.[t]?.[o.op]||'deny'; + if(s.policy.disabled_functions?.[t]?.includes(o.op))d.disabled_functions[t].push(o.op);}); + });return d; + } + function node(tag,text,attrs={}){const n=document.createElement(tag);if(text!==null)n.textContent=text; + Object.entries(attrs).forEach(([k,v])=>n.setAttribute(k,v));return n;} + function button(text,run,attrs={}){const b=node('button',text,{type:'button',...attrs});b.onclick=run;return b;} + function open(initial,onClose=()=>{}){ + if(document.querySelector('.caller-settings'))return; + let state=clone(initial),base=values(state),draft=clone(base),unknown=false,blocked=false,busy=false,writing=false,storageOK=true; + let tab='enabled',query='',enabledOnly=false,reviewOnly=false,expanded=new Set(),scroll={enabled:0,policy:0}; + const key='toolstack.settings.v1:'+JSON.stringify([state.installation,state.operator,state.name,state.caller_id]); + const url='/callers/'+encodeURIComponent(state.name)+'/settings'; + let saved=null;try{saved=JSON.parse(localStorage.getItem(key));}catch(e){storageOK=false;} + if(saved?.state?.name===state.name&&saved?.state?.installation===state.installation){ + draft=saved.draft;base=saved.base;unknown=!!saved.unknown;blocked=!!saved.blocked; + tab=saved.tab||tab;query=saved.query||'';enabledOnly=!!saved.enabledOnly;reviewOnly=!!saved.reviewOnly; + expanded=new Set(saved.expanded||[]);scroll=saved.scroll||scroll; + if(saved.state.revision!==state.revision)blocked=true; + const freshCSRF=state.csrf;state=saved.state;state.csrf=freshCSRF; + } + const dialog=node('dialog',null,{class:'caller-settings','aria-label':'Caller settings for '+state.name}); + const shell=node('div',null,{class:'settings-shell'});dialog.append(shell); + const heading=node('h2','Caller settings · '+state.name);shell.append(heading); + const tabs=node('div',null,{class:'settings-tabs',role:'tablist','aria-label':'Caller settings tabs'});shell.append(tabs); + const filter=node('div',null,{class:'settings-filter'}),search=node('input',null,{type:'search','aria-label':'Search tools and functions',placeholder:'Search tools and functions'}); + search.value=query;filter.append(search); + function filterToggle(title,initial,change){const l=node('label',null),i=node('input',null,{type:'checkbox'});i.checked=initial;i.onchange=()=>change(i.checked);l.append(i,node('span',title));filter.append(l);return i;} + const enabledFilter=filterToggle('Enabled only',enabledOnly,v=>{enabledOnly=v;render();}); + const reviewFilter=filterToggle('Needs review',reviewOnly,v=>{reviewOnly=v;render();});shell.append(filter); + const count=node('div','',{role:'status'});shell.append(count); + const stale=node('div',null,{class:'settings-stale'}),staleText=node('span','Changed elsewhere · Draft retained. '); + const compare=button('Compare latest',()=>compareLatest());stale.append(staleText,compare);shell.append(stale); + const list=node('div',null,{class:'settings-list',role:'tabpanel',id:'settings-panel',tabindex:'0'});shell.append(list); + const scope=node('small','',{class:'muted'});shell.append(scope); + const footer=node('div',null,{class:'settings-footer'}),status=node('p','',{role:'status'}),actions=node('div',null,{class:'actions'}); + const cancel=button('Cancel',()=>leave()),save=button('Save changes',()=>saveDraft(),{class:'primary'}); + actions.append(cancel,save);footer.append(status,actions);shell.append(footer); + const dirty=()=>!same(base,draft); + function persist(){try{if(dirty()||unknown||blocked||writing)localStorage.setItem(key,JSON.stringify({state:{...state,csrf:undefined},base,draft,unknown:unknown||writing,blocked,tab,query,enabledOnly,reviewOnly,expanded:[...expanded],scroll}));else localStorage.removeItem(key);storageOK=true;} + catch(e){storageOK=false;status.textContent='Draft storage unavailable. Keep this window open or save before quitting.';}} + function changed(){draft.enabled.sort();Object.values(draft.disabled_functions).forEach(a=>a.sort());persist();render();} + function setFunction(t,o,v){const wasEnabled=draft.enabled.includes(t); + if(v&&!wasEnabled){draft.enabled.push(t);draft.disabled_functions[t]=state.catalog[t].map(o=>o.op);} + draft.disabled_functions[t]=draft.disabled_functions[t].filter(x=>x!==o); + if(!v)draft.disabled_functions[t].push(o); + } + function setTool(t,v){draft.enabled=draft.enabled.filter(x=>x!==t);if(v){draft.enabled.push(t);draft.disabled_functions[t]=[];}changed();} + function filtered(){const q=query.trim().toLowerCase();return Object.keys(state.catalog).sort().map(t=>[t,state.catalog[t].filter(o=>(!q||(t+' '+o.op+' '+(o.description||'')).toLowerCase().includes(q))&&(!enabledOnly||active(draft,t,o.op))&&(!reviewOnly||effect(draft,t,o.op)==='review'))]).filter(([t,ops])=>ops.length);} + function render(){ + const focus=document.activeElement?.dataset.focus, top=list.scrollTop;tabs.replaceChildren(); + ['enabled','policy'].forEach((id,i)=>{const title=i?'Policy':'Enabled tools'; + const isDirty=i?!same(base.tools,draft.tools):!same([base.enabled,base.disabled_functions],[draft.enabled,draft.disabled_functions]); + const b=button(title+(isDirty?' · Unsaved changes':''),()=>switchTab(id),{role:'tab','aria-selected':String(tab===id),'aria-controls':'settings-panel',id:'settings-tab-'+id,tabindex:tab===id?'0':'-1','data-focus':'tab-'+id}); + b.onkeydown=e=>{if(['ArrowLeft','ArrowRight','Home','End'].includes(e.key)){e.preventDefault();switchTab(e.key==='Home'?'enabled':e.key==='End'?'policy':tab==='enabled'?'policy':'enabled');tabs.querySelector('[aria-selected=true]').focus();}};tabs.append(b);}); + list.setAttribute('aria-labelledby','settings-tab-'+tab);list.replaceChildren(); + const groups=filtered(),total=Object.values(state.catalog).reduce((n,a)=>n+a.length,0),matches=groups.reduce((n,[t,a])=>n+a.length,0); + count.textContent=matches+' of '+total+' functions · '+groups.length+' tools'; + const filtering=!!query.trim()||enabledOnly||reviewOnly; + scope.textContent=filtering?'All / None affect visible filtered matches only.':'All / None affect the whole tool. Enabling never grants Allow.'; + if(!groups.length){list.append(node('p','No matching functions.')); + list.append(button('Clear filters',()=>{query='';search.value='';enabledOnly=false;reviewOnly=false;enabledFilter.checked=false;reviewFilter.checked=false;render();search.focus();}));} + groups.forEach(([t,ops])=>{ + const group=node('div',null,{class:'settings-group'}),head=node('div',null,{class:'settings-group-head'}); + const isOpen=expanded.has(t)||filtering&&!expanded.has('closed:'+t),body=node('div',null,{id:'functions-'+t});body.hidden=!isOpen; + head.append(button((isOpen?'▾ ':'▸ ')+t,()=>{if(isOpen){expanded.delete(t);expanded.add('closed:'+t);}else{expanded.add(t);expanded.delete('closed:'+t);}persist();render();},{'aria-expanded':String(isOpen),'aria-controls':'functions-'+t,'aria-label':t+' functions','data-focus':'group-'+t})); + const allOps=state.catalog[t],n=allOps.filter(o=>active(draft,t,o.op)).length; + head.append(node('span',n+'/'+allOps.length+' enabled'+(filtering?' · '+ops.length+' matches':''),{class:'summary'})); + if(tab==='enabled'){ + const label=node('label',null),toggle=node('input',null,{type:'checkbox','aria-label':'Enable whole tool '+t,'data-focus':'tool-'+t}); + toggle.checked=n===allOps.length;toggle.indeterminate=n>0&&nsetTool(t,toggle.checked);label.append(toggle,node('span','Whole tool'));head.append(label); + [true,false].forEach(v=>head.append(button((v?'All':'None')+(filtering?' matches':''),()=>{ops.forEach(o=>setFunction(t,o.op,v));changed();},{'aria-label':(v?'Enable ':'Disable ')+(filtering?'matched':'all')+' '+t+' functions','data-focus':'bulk-'+t+'-'+v})));} + if(isOpen)ops.forEach(o=>{ + const row=node('div',null,{class:'function-row'}),title=node('div',o.op,{class:'function-name'}); + if(tab==='enabled'){const label=node('label',null,{class:'function-name'}),input=node('input',null,{type:'checkbox','aria-label':'Enable '+t+' / '+o.op,'data-focus':'enable-'+t+'.'+o.op});input.checked=active(draft,t,o.op);input.disabled=busy;input.onchange=()=>{setFunction(t,o.op,input.checked);changed();};label.append(input,title);row.append(label,node('small',effect(draft,t,o.op),{class:'muted'}));} + else {row.append(title);if(!active(draft,t,o.op))row.append(node('small','Disabled',{class:'muted'}));const select=node('select',null,{'aria-label':'Policy for '+t+' / '+o.op,'data-focus':'effect-'+t+'.'+o.op}); + ['deny','review','allow'].forEach(v=>{const option=node('option',v[0].toUpperCase()+v.slice(1),{value:v});select.append(option);});select.value=effect(draft,t,o.op);select.disabled=busy;select.onchange=()=>{draft.tools[t][o.op]=select.value;changed();};row.append(select);} + body.append(row); + });group.append(head,body);list.append(group); + }); + stale.hidden=!(blocked||unknown);staleText.textContent=unknown?'Save outcome unknown · Draft retained. ':'Changed elsewhere · Draft retained. '; + save.disabled=busy||blocked||unknown||!dirty();cancel.disabled=busy;compare.disabled=busy; + search.disabled=busy;enabledFilter.disabled=busy;reviewFilter.disabled=busy; + list.querySelectorAll('button').forEach(b=>b.disabled=busy); + if(storageOK)status.textContent=busy?'Saving both tabs…':dirty()?'Unsaved changes · Both tabs · Draft kept on this device':'No unsaved changes'; + list.scrollTop=top; + if(focus){const control=[...dialog.querySelectorAll('[data-focus]')].find(n=>n.dataset.focus===focus);(control||search).focus();} + persist(); + } + function switchTab(id){scroll[tab]=list.scrollTop;tab=id;render();list.scrollTop=scroll[tab]||0;tabs.querySelector('[aria-selected=true]').focus();persist();} + search.oninput=()=>{query=search.value;render();}; + list.onscroll=()=>{scroll[tab]=list.scrollTop;persist();}; + function close(){persist();dialog.close();dialog.remove();document.removeEventListener('visibilitychange',visible);window.removeEventListener('pagehide',persist);clearInterval(poll);onClose();} + function leave(){if(busy)return;if(!dirty()&&!unknown){close();return;} + const d=node('dialog',null,{'aria-label':'Leave settings for '+state.name+'?'});d.append(node('h2','Leave settings for '+state.name+'?')); + const a=node('div',null,{class:'actions'}),stay=button('Stay',()=>d.close());a.append(stay,button('Keep draft and close',()=>{d.close();close();}),button('Discard draft',()=>{draft=clone(base);unknown=false;blocked=false;localStorage.removeItem(key);d.close();close();}));d.append(a);document.body.append(d);d.onclose=()=>d.remove();d.showModal();stay.focus();} + dialog.addEventListener('cancel',e=>{e.preventDefault();leave();}); + async function request(options={}){const controller=new AbortController(),timer=setTimeout(()=>controller.abort(),15000); + try{const r=await fetch(url,{cache:'no-store',...options,signal:controller.signal,headers:{Accept:'application/json',...options.headers}}); + if(r.url&&new URL(r.url,location.href).pathname==='/login')throw Error('Session expired');return {response:r,data:await r.json()};}finally{clearTimeout(timer);}} + async function observe(){if(busy||!dialog.open)return;const revision=state.revision;try{const {response,data}=await request();if(busy||!dialog.open||state.revision!==revision)return;if(!response.ok||data.name!==state.name||data.installation!==state.installation||data.caller_id!==state.caller_id)throw Error(); + if(data.revision!==state.revision){blocked=true;render();}}catch(e){if(busy||!dialog.open||state.revision!==revision)return;blocked=true;render();staleText.textContent='Latest settings unavailable · Draft retained. ';}} + async function compareLatest(){if(busy)return;busy=true;render();let remote; + try{const {response,data}=await request();if(!response.ok||data.name!==state.name||data.installation!==state.installation||data.caller_id!==state.caller_id)throw Error();remote=data;} + catch(e){busy=false;blocked=true;render();staleText.textContent='Comparison unavailable · Draft retained. Sign in if needed, then retry. ';return;} + busy=false;render(); + const latest=values(remote),d=node('dialog',null,{class:'settings-compare','aria-label':'Compare settings for '+state.name}); + d.append(node('h2','Compare · '+state.name),node('p','Your draft is unchanged. Keep only your explicit edits on the latest settings, or Stay.')); + const table=node('table'),thead=node('thead'),tr=node('tr');['Setting','Your draft','Latest saved'].forEach(s=>tr.append(node('th',s)));thead.append(tr);table.append(thead);const tbody=node('tbody');table.append(tbody);let removed=false,rows=0; + const merged=clone(latest); + function diff(label,a,b){if(a===b)return;rows++;const row=node('tr');[label,a,b].forEach(x=>row.append(node('td',String(x))));tbody.append(row);} + Object.keys(state.catalog).forEach(t=>{ + if(!(t in remote.catalog)){removed=true;diff(t,'Draft retained','Tool no longer available');return;} + diff(t+' / whole tool',draft.enabled.includes(t),latest.enabled.includes(t)); + if(base.enabled.includes(t)!==draft.enabled.includes(t)){merged.enabled=merged.enabled.filter(x=>x!==t);if(draft.enabled.includes(t))merged.enabled.push(t);} + state.catalog[t].forEach(o=>{ + if(!(o.op in latest.tools[t])){removed=true;diff(t+' / '+o.op,'Draft retained','Function no longer available');return;} + diff(t+' / '+o.op,active(draft,t,o.op)+' / '+effect(draft,t,o.op),active(latest,t,o.op)+' / '+effect(latest,t,o.op)); + if(effect(base,t,o.op)!==effect(draft,t,o.op))merged.tools[t][o.op]=effect(draft,t,o.op); + const excluded=s=>s.disabled_functions[t]?.includes(o.op)||false; + if(excluded(base)!==excluded(draft)){merged.disabled_functions[t]=merged.disabled_functions[t].filter(x=>x!==o.op);if(excluded(draft))merged.disabled_functions[t].push(o.op);} + }); + }); + if(!rows)d.append(node('p','Both tabs match the saved settings. No write is needed.'));else d.append(table); + if(removed)d.append(node('p','The function set changed. Your original draft is retained; discard it explicitly and reopen to edit the new catalog.')); + const a=node('div',null,{class:'actions'}),stay=button('Stay',()=>d.close());a.append(stay); + if(!removed)a.append(button('Keep my edits on latest',()=>{state=remote;base=latest;draft=merged;draft.enabled.sort();blocked=false;unknown=false;d.close();changed();},{class:'primary'})); + d.append(a);document.body.append(d);d.onclose=()=>d.remove();d.showModal();stay.focus(); + } + async function saveDraft(){if(busy||blocked||unknown||!dirty())return;busy=true;writing=true;persist();render(); + const body=new URLSearchParams({_csrf:state.csrf,settings_json:JSON.stringify({revision:state.revision,...draft})}); + try{const {response,data}=await request({method:'POST',body}); + if(response.ok&&data.outcome==='saved'&&data.name===state.name&&data.policy&&same(values({...state,policy:data.policy,enabled:data.policy.enabled}),draft)){ + base=clone(draft);unknown=false;blocked=false;busy=false;writing=false;persist();close();return; + } + blocked=true;if(!(response.status>=400&&response.status<500&&data.outcome==='rejected'))unknown=true; + }catch(e){blocked=true;unknown=true;} + busy=false;writing=false;persist();render();compare.focus(); + } + function visible(){if(!document.hidden)observe();} + document.addEventListener('visibilitychange',visible);window.addEventListener('pagehide',persist); + const poll=setInterval(observe,20000); + document.body.append(dialog);render();dialog.showModal();tabs.querySelector('[aria-selected=true]').focus(); + if(saved)observe(); + } + document.addEventListener('DOMContentLoaded',()=>{ + const payload=document.getElementById('caller-settings-state');if(payload)open(JSON.parse(payload.textContent),()=>location.assign('/')); + document.querySelectorAll('a[data-settings-link]').forEach(link=>link.addEventListener('click',async e=>{ + e.preventDefault();if(link.dataset.loading)return;link.dataset.loading='true'; + try{const r=await fetch(link.href,{headers:{Accept:'application/json'},cache:'no-store'}),s=await r.json();if(!r.ok||!s.revision)throw Error();open(s,()=>link.closest('[data-caller-policy]')?.focus());} + catch(e){location.assign(link.href);}finally{delete link.dataset.loading;} + })); + }); + return {open}; +})(); +""" diff --git a/broker/operations.py b/broker/operations.py index bac4816..cc79d6c 100644 --- a/broker/operations.py +++ b/broker/operations.py @@ -37,10 +37,11 @@ def build_policy(allow, review, deny=None) -> dict: def enabled_tools(policy: dict) -> list[str]: - """The tools a caller may manage policy for: the explicit ``enabled`` list (set via the - web "Enabled tools" toggle) plus any tool that already carries granted ops, so a policy - authored by ``brokerctl set-policy`` (which grants ops but never sets ``enabled``) still - surfaces its tools in the editor. Sorted, de-duplicated.""" + """Managed settings have authoritative enablement, even when empty. Legacy + policies also surface tools carrying rules (e.g. a CLI-authored policy). + """ + if "disabled_functions" in policy and "enabled" in policy: + return sorted(set(policy["enabled"])) granted = policy.get("tools", {}) return sorted(set(policy.get("enabled", [])) | set(granted.keys())) @@ -57,6 +58,9 @@ def set_enabled_tools(store: Store, name: str, enabled, operator: str) -> None: policy: dict = {"tools": tools} if enabled: policy["enabled"] = enabled + if "disabled_functions" in store.policy_for(caller["id"]): + policy["enabled"] = enabled + policy["disabled_functions"] = store.policy_for(caller["id"])["disabled_functions"] store.set_policy(caller["id"], policy) record_admin_event(store, operator, "tools_changed", {"name": name, "enabled": enabled}) @@ -134,8 +138,10 @@ def set_policy(store: Store, name: str, allow, review, operator: str, deny=None) policy = build_policy(allow, review, deny) before = store.policy_for(caller["id"]) enabled = before.get("enabled") # preserve tool-enablement - if enabled: + if "enabled" in before: policy["enabled"] = enabled + if "disabled_functions" in before: + policy["disabled_functions"] = before["disabled_functions"] store.set_policy(caller["id"], policy) def grants(value): return {f"{tool}.{op}": effect diff --git a/broker/policy.py b/broker/policy.py index e473deb..73949b6 100644 --- a/broker/policy.py +++ b/broker/policy.py @@ -16,6 +16,10 @@ def decide(policy: dict, tool: str, op: str) -> str: """Return ALLOW, REVIEW, or DENY for a (tool, op) under a caller policy.""" + if "disabled_functions" in policy and "enabled" in policy and tool not in policy["enabled"]: + return DENY + if op in policy.get("disabled_functions", {}).get(tool, []): + return DENY rules = policy.get("tools", {}).get(tool, {}) return _norm(rules.get(op)) diff --git a/broker/store.py b/broker/store.py index 9bcaa64..01d3fc6 100644 --- a/broker/store.py +++ b/broker/store.py @@ -236,6 +236,10 @@ def caller_by_token_hash(self, token_hash: str) -> Caller | None: # --- policy ------------------------------------------------------------- def set_policy(self, caller_id: int, policy: dict) -> None: + self._write_policy(caller_id, policy) + self._conn.commit() + + def _write_policy(self, caller_id: int, policy: dict) -> None: self._conn.execute( """ INSERT INTO caller_policies (caller_id, policy_json, updated_at) @@ -247,7 +251,31 @@ def set_policy(self, caller_id: int, policy: dict) -> None: ) self._conn.execute("UPDATE approvals SET status='cancelled' WHERE status='pending' AND request_id IN (SELECT id FROM requests WHERE caller_id=? AND status='pending_approval')", (caller_id,)) self._conn.execute("UPDATE requests SET status='denied', error='approval_invalidated', arguments_json=NULL, action_json=NULL,updated_at=? WHERE caller_id=? AND status='pending_approval'", (time.time(), caller_id)) - self._conn.commit() + + def compare_and_set_policy(self, caller_id: int, before: dict, after: dict, + operator: str) -> bool: + """Commit both settings tabs, approval invalidation and audit together. + + The write lock closes the read/write race with other admin and CLI connections. + No partial tool-enable / policy write can escape this transaction. + """ + try: + self._conn.execute("BEGIN IMMEDIATE") + caller = self._conn.execute("SELECT name, revoked_at FROM callers WHERE id=?", + (caller_id,)).fetchone() + if caller is None or caller["revoked_at"] is not None or self.policy_for(caller_id) != before: + self._conn.rollback() + return False + self._write_policy(caller_id, after) + self._conn.execute( + "INSERT INTO audit_events (at, component, event_type, outcome, correlation_id, request_id, details_json) " + "VALUES (?, 'admin', 'settings_changed', 'ok', ?, NULL, ?)", + (time.time(), str(uuid.uuid4()), json.dumps({"operator": operator, "name": caller["name"]}))) + self._conn.commit() + return True + except BaseException: + self._conn.rollback() + raise def policy_for(self, caller_id: int) -> dict: row = self._conn.execute( diff --git a/desktop/app.py b/desktop/app.py index 4925dc7..78af083 100644 --- a/desktop/app.py +++ b/desktop/app.py @@ -24,6 +24,7 @@ import time import threading import urllib.request +from pathlib import Path DEFAULT_URL = "http://127.0.0.1:8780" WINDOW_TITLE = "Toolstack" @@ -126,7 +127,13 @@ def main() -> None: js_api=bridge, width=620, height=360) else: webview.create_window(WINDOW_TITLE, stack.url, width=1180, height=820) - webview.start() # blocks until the window is closed + # Private mode clears WebKit's website data at launch, including durable + # caller drafts. Persist this app's origin storage across window closes. + storage = Path(os.environ.get("XDG_STATE_HOME", str(Path.home() / ".local/state"))) / "toolstack/desktop-browser" + storage.mkdir(parents=True, exist_ok=True, mode=0o700) + if storage.is_symlink() or storage.stat().st_uid != os.getuid() or storage.stat().st_mode & 0o077: + raise RuntimeError("desktop browser storage must be an owned private directory") + webview.start(private_mode=False, storage_path=str(storage)) # blocks until closed finally: if bridge is not None: bridge.shutdown() diff --git a/desktop/tests/test_app.py b/desktop/tests/test_app.py index 078453d..f151b84 100644 --- a/desktop/tests/test_app.py +++ b/desktop/tests/test_app.py @@ -159,6 +159,9 @@ def test_double_click_failure_renders_before_main_and_cleans_up(self): self.assertIn("Diagnostics", kwargs["html"]) self.assertIn("Retry", kwargs["html"]) webview.start.assert_called_once() + self.assertFalse(webview.start.call_args.kwargs["private_mode"]) + storage = webview.start.call_args.kwargs["storage_path"] + self.assertEqual(os.stat(storage).st_mode & 0o777, 0o700) stack.stop.assert_called_once() def test_retry_failure_stays_local_then_success_opens_admin(self): diff --git a/docs/caller-settings.md b/docs/caller-settings.md new file mode 100644 index 0000000..5a63abe --- /dev/null +++ b/docs/caller-settings.md @@ -0,0 +1,51 @@ +# Caller settings + +Open a caller's **Settings** action, double-click its row or select it and press Return. +Web and desktop use a modal popup; the Mac app uses a native sheet. Both tabs share one +draft and one **Save changes** button. + +- **Enabled tools:** switch on a whole tool to enable all its functions. Expand its + group to exclude individual functions. Enabling is not an Allow grant. +- **Policy:** choose Deny, Review (ask for human approval) or Allow per function. + Disabled functions retain their policy choices but cannot execute. +- Search tools and functions, or use **Enabled only** and **Needs review**. With any + search/filter active, **All matches / None matches** act only on the visible matches + in that tool. Without filters, **All / None** act on the whole tool. Neither grants Allow. + +Both tabs, filters and edits are kept on this device across app/window closure. Drafts +are scoped to the server installation, operator and caller identity, not just the caller +name. No bearer tokens, passwords, CSRF values or workload secrets are stored in drafts. +Browser private mode or clearing website data removes browser drafts. The desktop shell +uses persistent website storage; the Mac app keeps owner-only files in Application Support. +If storage fails, the editor warns you to keep the window open or save before quitting. + +Cancel/Escape with edits offers Stay (the safe default), Keep draft and close, or explicit +Discard. Closing the app does not apply a draft. Saving commits both tabs and invalidates +pending approvals in the same SQLite transaction. + +## Changed elsewhere and unknown saves + +Settings and catalog revisions are checked on Save and observed while the editor is open. +A stale or unavailable observation pauses Save without replacing your draft. **Compare +latest** shows your draft beside the saved state. Only the explicit **Keep my edits on +latest** action rebases your edits; changes you did not edit are retained from the latest +state. You still choose Save afterward. If the server/caller identity changed or functions +disappeared, the old draft is kept and cannot be silently applied to another target. + +A timeout or missing/mismatched receipt is **Save outcome unknown**, not proof of failure. +No write is replayed. Compare before deciding whether another save is needed. Interrupted +saves stay blocked after relaunch. Discarding a draft does not undo a write already applied. + +## API compatibility + +`GET /api/callers/{name}/settings` returns the catalog, policy, installation/caller identity +and revision. `PUT` takes that revision plus catalog-scoped `enabled`, `disabled_functions` +and `tools` maps and commits both tabs together. A stale revision returns 409 without a write. +Unseen tools, functions and policy metadata are preserved, not replaced by the visible form. +The authenticated HTML endpoint uses the same mutation with cookie/CSRF protection. + +The older policy/tools routes and CLI remain available. Legacy policies keep their previous +semantics until saved as combined settings. Combined settings use explicit enablement and +function exclusions as execution gates; policy-only updates preserve these gates. To grant +a disabled function, enable it as well as setting its policy. Older tools updates still +drop policies for disabled tools; use combined settings to retain choices while disabling. diff --git a/macapp/Sources/ToolstackApp/AppModel.swift b/macapp/Sources/ToolstackApp/AppModel.swift index 76853ef..b9267df 100644 --- a/macapp/Sources/ToolstackApp/AppModel.swift +++ b/macapp/Sources/ToolstackApp/AppModel.swift @@ -75,6 +75,25 @@ final class AppModel: ObservableObject { return result } + func loadCallerSettings(_ caller: String) async throws -> SettingsSnapshot { + let epoch = generation, activeClient = client + let snapshot = try await activeClient.callerSettings(caller) + guard epoch == generation else { throw CancellationError() } + guard snapshot.name == caller, snapshot.callerId > 0 else { throw ApiError.decoding("Settings target differs from caller") } + return snapshot + } + + func writeCallerSettings(_ draft: CallerSettingsDraft) async -> CallerSettingsDraft { + let epoch = generation, activeClient = client + var result = draft + guard result.canSave else { return result } + result.phase = .saving + do { try result.accept(try await activeClient.saveCallerSettings(draft)) } + catch { result.phase = MutationResult(error: error) == .rejected ? .stale : .unknown } + guard epoch == generation else { return draft } + return result + } + /// The admin URL the user signs in against, editable on the login screen, remembered /// across launches. Works for a local admin (the default) or a remote one (a tailnet / /// tunnelled homeserver), as long as it's reachable. diff --git a/macapp/Sources/ToolstackApp/CallerSettingsEditor.swift b/macapp/Sources/ToolstackApp/CallerSettingsEditor.swift new file mode 100644 index 0000000..a7af5d0 --- /dev/null +++ b/macapp/Sources/ToolstackApp/CallerSettingsEditor.swift @@ -0,0 +1,259 @@ +import SwiftUI +import ToolstackKit + +/// The same two-tab editor as the web/desktop surface, using native controls. +struct CallerSettingsEditor: View { + @EnvironmentObject var model: AppModel + @Environment(\.dismiss) private var dismiss + let caller: String + @State private var draft: CallerSettingsDraft? + @State private var comparison: SettingsSnapshot? + @State private var message: String? + @State private var storageError = false + @State private var loading = false + @State private var leaving = false + @State private var epoch: UUID? + @State private var account = "" + @FocusState private var searchFocused: Bool + + init(caller: String, fixture: CallerSettingsDraft? = nil) { + self.caller = caller; _draft = State(initialValue: fixture) + } + + private var store: SettingsDraftStore { + SettingsDraftStore(directory: FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appendingPathComponent("Toolstack/CallerSettings", isDirectory: true)) + } + private var saving: Bool { draft?.phase == .saving } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Caller settings · \(caller)").font(.title2.bold()) + if let message { Text(message).font(.callout).foregroundStyle(.secondary).accessibilityLabel(message) } + if let current = draft { + editor(current) + } else { + if loading { ProgressView("Loading settings…") } + else { Button("Retry load") { Task { await load() } }.buttonStyle(.borderedProminent) } + Spacer() + } + HStack(spacing: 20) { + Text(saving ? "Saving both tabs…" : draft?.dirty == true ? "Unsaved changes · Both tabs · Draft kept on this device" : "No unsaved changes") + .font(.callout).foregroundStyle(.secondary) + Spacer() + Button("Cancel") { leave() }.keyboardShortcut(.cancelAction).disabled(saving) + Button("Save changes") { Task { await save() } } + .buttonStyle(.borderedProminent).keyboardShortcut(.defaultAction) + .disabled(draft?.canSave != true || loading || model.busy || model.effectInFlight || model.uncertainMutation != nil) + } + } + .padding(20).frame(minWidth: 620, idealWidth: 820, maxWidth: 960, minHeight: 480, idealHeight: 620, maxHeight: 780) + .controlSize(.large).tint(.blue) + .background(Color(red: 246.0/255, green: 247.0/255, blue: 249.0/255)) + .environment(\.colorScheme, .light) + .accessibilityLabel("Caller settings for \(caller)") + .interactiveDismissDisabled(draft?.dirty == true || saving || draft?.phase == .unknown) + .task { + if CommandLine.arguments.contains("--snapshot-directory") { return } + await load() + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(20)) + if Task.isCancelled { return } + await observe() + } + } + .onChange(of: model.generation) { _ in dismiss() } + .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in Task { await observe() } } + .confirmationDialog("Leave settings for \(caller)?", isPresented: $leaving) { + Button("Stay", role: .cancel) {} + Button("Keep draft and close") { if persist() { dismiss() } }.disabled(storageError) + Button("Discard draft", role: .destructive) { + guard let draft else { dismiss(); return } + do { try store.discard(account: account, snapshot: draft.snapshot); dismiss() } + catch { message = "Could not discard the stored draft. Keep this window open and retry." } + } + } message: { + Text("Both tabs are kept together. A save with an unknown outcome may already have applied; discarding does not undo it.") + } + .sheet(isPresented: Binding(get: { comparison != nil }, set: { if !$0 { comparison = nil } })) { + if let latest = comparison, let draft { comparisonView(draft, latest) } + } + } + + private func editor(_ current: CallerSettingsDraft) -> some View { + VStack(alignment: .leading, spacing: 12) { + Picker("Settings tab", selection: binding(\.tab)) { + Text("Enabled tools" + (current.values.enabled != current.baseline.enabled || current.values.excluded != current.baseline.excluded ? " · Unsaved changes" : "")).tag("enabled") + Text("Policy" + (current.values.tools != current.baseline.tools ? " · Unsaved changes" : "")).tag("policy") + }.pickerStyle(.segmented) + HStack { + TextField("Search tools and functions", text: binding(\.search)).textFieldStyle(.roundedBorder) + .accessibilityLabel("Search tools and functions").focused($searchFocused) + Toggle("Enabled only", isOn: binding(\.enabledOnly)) + Toggle("Needs review", isOn: binding(\.needsReview)) + } + let tools = current.snapshot.catalog.keys.sorted().filter { !current.matches($0).isEmpty } + Text("\(tools.reduce(0) { $0 + current.matches($1).count }) of \(current.snapshot.catalog.values.reduce(0) { $0 + $1.count }) functions · \(tools.count) tools") + .font(.callout).foregroundStyle(.secondary) + if current.phase == .stale || current.phase == .unknown { + HStack { + Text(current.phase == .unknown ? "Save outcome unknown · Draft retained" : "Changed elsewhere · Draft retained") + Spacer() + Button("Compare latest") { Task { await compare() } }.disabled(loading) + }.padding(10).background(Color.orange.opacity(0.12), in: RoundedRectangle(cornerRadius: 6)) + } + // Keep both scroll views alive so each tab retains its scroll position. + ZStack { + ForEach(["enabled", "policy"], id: \.self) { tab in + ScrollView { + LazyVStack(alignment: .leading, spacing: 0) { + if tools.isEmpty { + Text("No matching functions.").padding() + Button("Clear filters") { edit { $0.search = ""; $0.enabledOnly = false; $0.needsReview = false }; searchFocused = true }.padding() + } + ForEach(tools, id: \.self) { tool in group(current, tool, tab) } + } + }.opacity(current.tab == tab ? 1 : 0) + .allowsHitTesting(current.tab == tab).disabled(current.tab != tab || saving) + .accessibilityHidden(current.tab != tab) + } + }.background(Color.white, in: RoundedRectangle(cornerRadius: 8)) + .overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.gray.opacity(0.25))) + Text(current.filtering ? "All / None affect visible filtered matches only." : "All / None affect the whole tool. Enabling never grants Allow.") + .font(.caption).foregroundStyle(.secondary) + } + } + + private func group(_ current: CallerSettingsDraft, _ tool: String, _ tab: String) -> some View { + let ops = current.matches(tool) + let all = current.snapshot.catalog[tool, default: []] + let count = all.filter { current.values.active(tool, $0.op) }.count + let expanded = current.expanded.contains(tool) || current.filtering && !current.collapsedSearch.contains(tool) + return VStack(alignment: .leading, spacing: 6) { + HStack { + Button { + edit { + if expanded { $0.expanded.remove(tool); $0.collapsedSearch.insert(tool) } + else { $0.expanded.insert(tool); $0.collapsedSearch.remove(tool) } + } + } label: { Label(tool, systemImage: expanded ? "chevron.down" : "chevron.right").fontWeight(.semibold) } + .buttonStyle(.plain).frame(minHeight: 32).accessibilityLabel("\(tool) functions") + .accessibilityValue(expanded ? "Expanded" : "Collapsed") + Text("\(count)/\(all.count) enabled" + (current.filtering ? " · \(ops.count) matches" : "")) + .font(.caption).foregroundStyle(.secondary) + Spacer() + if tab == "enabled" { + Toggle("Whole tool", isOn: Binding(get: { count == all.count }, set: { enabled in edit { $0.setTool(tool, enabled: enabled) } })) + .accessibilityLabel("Enable whole tool \(tool)") + Button(current.filtering ? "All matches" : "All") { edit { $0.bulk(tool, enabled: true) } } + .accessibilityLabel("Enable \(current.filtering ? "matched" : "all") \(tool) functions") + Button(current.filtering ? "None matches" : "None") { edit { $0.bulk(tool, enabled: false) } } + .accessibilityLabel("Disable \(current.filtering ? "matched" : "all") \(tool) functions") + } + } + if expanded { + ForEach(ops) { op in function(current, tool, op, tab) } + } + Divider() + }.padding(10) + } + + private func function(_ current: CallerSettingsDraft, _ tool: String, _ op: OpInfo, _ tab: String) -> some View { + HStack { + if tab == "enabled" { + Toggle(op.op, isOn: Binding(get: { current.values.active(tool, op.op) }, set: { enabled in + edit { $0.setFunction(tool, op.op, enabled: enabled) } + if draft?.matches(tool).contains(where: { $0.op == op.op }) == false { searchFocused = true } + })).accessibilityLabel("Enable \(tool) / \(op.op)") + Spacer() + Text(current.values.effect(tool, op.op).capitalized).font(.caption).foregroundStyle(.secondary) + } else { + Text(op.op) + if !current.values.active(tool, op.op) { Text("Disabled").font(.caption).foregroundStyle(.secondary) } + Spacer() + Picker("Policy for \(tool) / \(op.op)", selection: Binding(get: { current.values.effect(tool, op.op) }, set: { effect in + edit { $0.values.tools[tool]?[op.op] = effect } + if draft?.matches(tool).contains(where: { $0.op == op.op }) == false { searchFocused = true } + })) { + Text("Deny").tag("deny"); Text("Review").tag("review"); Text("Allow").tag("allow") + }.labelsHidden().frame(width: 125) + } + }.padding(.leading, 20).frame(minHeight: 32) + } + + private func comparisonView(_ current: CallerSettingsDraft, _ latest: SettingsSnapshot) -> some View { + VStack(alignment: .leading, spacing: 12) { + Text("Compare · \(caller)").font(.title2.bold()) + Text("Your draft is unchanged. Keep only your explicit edits on the latest settings, or Stay.") + HStack { Text("Setting").frame(maxWidth: .infinity, alignment: .leading); Text("Your draft").frame(width: 180); Text("Latest saved").frame(width: 180) }.font(.headline) + ScrollView { + LazyVStack { + let rows = current.differences(latest) + if rows.isEmpty { Text("Both tabs match the saved settings. No write is needed.") } + ForEach(rows) { row in + HStack { Text(row.id).frame(maxWidth: .infinity, alignment: .leading); Text(row.draft).frame(width: 180); Text(row.latest).frame(width: 180) } + .padding(.vertical, 4) + Divider() + } + } + } + if !current.canRebase(latest) { Text("The function set or server changed. Your draft is retained; discard it explicitly and reopen to edit the latest catalog.") } + HStack(spacing: 20) { + Spacer() + Button("Stay") { comparison = nil }.keyboardShortcut(.cancelAction) + Button("Keep my edits on latest") { edit { _ = $0.keepEdits(on: latest) }; comparison = nil } + .buttonStyle(.borderedProminent).disabled(!current.canRebase(latest)) + } + }.padding(20).frame(width: 760, height: 500) + } + + private func binding(_ key: WritableKeyPath) -> Binding { + Binding(get: { draft![keyPath: key] }, set: { value in edit { $0[keyPath: key] = value } }) + } + private func edit(_ mutation: (inout CallerSettingsDraft) -> Void) { + guard var value = draft, !saving else { return } + mutation(&value); draft = value; _ = persist() + } + @discardableResult private func persist() -> Bool { + guard let draft, epoch == model.generation else { return false } + do { try store.save(draft, account: account); storageError = false; return true } + catch { storageError = true; message = "Draft storage unavailable. Keep this window open or save before quitting."; return false } + } + private func leave() { if draft?.dirty == true || draft?.phase == .unknown { leaving = true } else { dismiss() } } + private func load() async { + epoch = model.generation; account = model.serverURL; loading = true; message = nil + defer { loading = false } + do { + let latest = try await model.loadCallerSettings(caller) + guard epoch == model.generation else { return } + draft = try store.load(account: account, snapshot: latest) ?? CallerSettingsDraft(latest) + if draft?.dirty == true { message = "Resume settings draft · \(caller)" } + } catch { message = "Settings unavailable. Your stored drafts have not been changed. Retry load." } + } + private func observe() async { + guard draft != nil, !saving, !loading, epoch == model.generation else { return } + do { + let latest = try await model.loadCallerSettings(caller) + guard epoch == model.generation, !saving else { return } + draft?.observe(latest); _ = persist() + } catch { guard epoch == model.generation else { return }; if draft?.phase != .unknown { draft?.phase = .stale }; message = "Latest settings unavailable · Draft retained"; _ = persist() } + } + private func compare() async { + guard !saving else { return }; loading = true + defer { loading = false } + do { + let latest = try await model.loadCallerSettings(caller) + guard epoch == model.generation else { return } + comparison = latest + } catch { message = "Comparison unavailable · Draft retained. Sign in if needed, then retry." } + } + private func save() async { + guard var pending = draft, pending.canSave else { return } + draft?.phase = .saving; _ = persist() + pending = await model.writeCallerSettings(pending) + guard epoch == model.generation else { return } + draft = pending + if pending.phase == .saved { if persist() { dismiss() } } + else { _ = persist() } + } +} diff --git a/macapp/Sources/ToolstackApp/NativeSnapshots.swift b/macapp/Sources/ToolstackApp/NativeSnapshots.swift index 5bda510..c2f5ad9 100644 --- a/macapp/Sources/ToolstackApp/NativeSnapshots.swift +++ b/macapp/Sources/ToolstackApp/NativeSnapshots.swift @@ -7,6 +7,10 @@ import ToolstackKit static func run(directory: String) { NSApplication.shared.setActivationPolicy(.prohibited) do { + if CommandLine.arguments.contains("--settings-snapshots") { + try settings(directory: directory) + return + } let model = AppModel() let decoder = JSONDecoder() model.tools = try decoder.decode(ToolsResponse.self, from: Data(#"{"tools":[{"id":"notes-demo","type":"api","running":true,"alive":false,"state":"crashed","health":"stopped","ops":[{"op":"read_note","risk":"read","description":"Read a note"},{"op":"write_note","risk":"write","description":"Write a note"}]}]}"#.utf8)).tools @@ -62,4 +66,49 @@ import ToolstackKit } catch { fputs("Snapshot failure: \(error)\n", stderr); exit(1) } } enum SnapshotError: Error { case render } + + private static func settings(directory: String) throws { + let catalog = Dictionary(uniqueKeysWithValues: (0..<30).map { tool in + (String(format: "tool%02d", tool), (0..<20).map { op in + ["op": String(format: "fn%02d", op), "risk": "read", "description": "Fake function"] + }) + }) + let payload: [String: Any] = ["name": "writer-demo", "caller_id": 1, "operator": "fake-admin", "installation": "fake-installation", "revision": "one", + "enabled": ["tool00"], "policy": ["tools": ["tool00": ["fn01": "review"]]], "catalog": catalog] + let decoder = JSONDecoder(); decoder.keyDecodingStrategy = .convertFromSnakeCase + let snapshot = try decoder.decode(SettingsSnapshot.self, from: JSONSerialization.data(withJSONObject: payload)) + let model = AppModel() + try FileManager.default.createDirectory(atPath: directory, withIntermediateDirectories: true) + var evidence: [[String: Any]] = [] + for state in ["enabled", "policy", "many", "empty", "stale"] { + var draft = CallerSettingsDraft(snapshot) + draft.expanded = ["tool00"] + draft.setFunction("tool00", "fn02", enabled: false) + draft.values.tools["tool00"]?["fn03"] = "review" + if state != "enabled" { draft.tab = "policy" } + if state == "many" { draft.search = "fn01" } + if state == "empty" { draft.search = "no-such-function" } + if state == "stale" { draft.phase = .stale } + for width in [900, 660] { + let host = NSHostingView(rootView: CallerSettingsEditor(caller: "writer-demo", fixture: draft) + .environmentObject(model).environment(\.colorScheme, .light).environment(\.controlActiveState, .active) + .frame(width: CGFloat(width), height: 680).background(Color(nsColor: .windowBackgroundColor))) + host.frame = NSRect(x: 0, y: 0, width: width, height: 680) + let window = NSWindow(contentRect: host.frame, styleMask: [.borderless], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false; window.appearance = NSAppearance(named: .aqua); window.contentView = host + host.layoutSubtreeIfNeeded(); RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.2)); host.layoutSubtreeIfNeeded() + guard !window.isVisible, NSApplication.shared.activationPolicy() == .prohibited, + let bitmap = host.bitmapImageRepForCachingDisplay(in: host.bounds) else { throw SnapshotError.render } + host.cacheDisplay(in: host.bounds, to: bitmap) + guard let png = bitmap.representation(using: .png, properties: [:]) else { throw SnapshotError.render } + try png.write(to: URL(fileURLWithPath: directory).appendingPathComponent("settings-\(state)-\(width).png")) + evidence.append(["state": state, "width": width, "functions": 600, "tools": 30, "window_visible": window.isVisible, + "activation_prohibited": true, "live_keyboard_or_voiceover": false]) + window.close() + } + } + try JSONSerialization.data(withJSONObject: evidence, options: [.prettyPrinted, .sortedKeys]) + .write(to: URL(fileURLWithPath: directory).appendingPathComponent("settings-evidence.json")) + print("Rendered 10 native caller-settings fixtures offscreen; live keyboard and VoiceOver remain separate checks.") + } } diff --git a/macapp/Sources/ToolstackApp/ToolstackApp.swift b/macapp/Sources/ToolstackApp/ToolstackApp.swift index a5add35..de098ce 100644 --- a/macapp/Sources/ToolstackApp/ToolstackApp.swift +++ b/macapp/Sources/ToolstackApp/ToolstackApp.swift @@ -271,8 +271,7 @@ struct CallersPane: View { TableColumn("") { caller in if caller.isActive { Menu { - Button("Enabled tools...") { enablingTools = caller } - Button(model.policyDrafts[caller.name] == nil ? "Edit policy…" : "Resume policy draft · \(caller.name)") { editing = caller } + Button("Caller settings…") { editing = caller } Button("Rotate token") { Task { await model.rotateToken(caller: caller.name) } } Divider() Button("Revoke caller", role: .destructive) { Task { @@ -286,20 +285,20 @@ struct CallersPane: View { // Native table primary actions handle double-click and Return, excluding controls. .contextMenu(forSelectionType: Int.self) { ids in if let caller = Caller.policyTarget(in: model.callers, selection: ids) { - Button(model.policyDrafts[caller.name] == nil ? "Edit policy…" : "Resume policy draft · \(caller.name)") { editing = caller } + Button("Caller settings…") { editing = caller } } } primaryAction: { ids in editing = Caller.policyTarget(in: model.callers, selection: ids) } - .help("Double-click an active caller or press Return to open policy settings") + .help("Double-click an active caller or press Return to open caller settings") } .padding() .navigationTitle("Callers") .sheet(item: $editing) { caller in - PolicyEditor(caller: caller.name).environmentObject(model) + CallerSettingsEditor(caller: caller.name).environmentObject(model) } .sheet(item: $enablingTools) { caller in - EnabledToolsEditor(caller: caller.name).environmentObject(model) + CallerSettingsEditor(caller: caller.name).environmentObject(model) } .confirmationDialog("Revoke caller?", isPresented: revokingBinding, presenting: revoking) { caller in Button("Revoke \(caller.name)", role: .destructive) { diff --git a/macapp/Sources/ToolstackKit/ApiClient.swift b/macapp/Sources/ToolstackKit/ApiClient.swift index 9fec447..749818b 100644 --- a/macapp/Sources/ToolstackKit/ApiClient.swift +++ b/macapp/Sources/ToolstackKit/ApiClient.swift @@ -193,6 +193,17 @@ public actor ApiClient { try await send("GET", "/api/callers/\(caller)/policy") } + public func callerSettings(_ caller: String) async throws -> SettingsSnapshot { + try await send("GET", "/api/callers/\(caller)/settings") + } + + public func saveCallerSettings(_ draft: CallerSettingsDraft) async throws -> SettingsReceipt { + try await send("PUT", "/api/callers/\(draft.snapshot.name)/settings", body: [ + "revision": draft.snapshot.revision, "enabled": draft.values.enabled.sorted(), + "disabled_functions": draft.values.excluded.mapValues { $0.sorted() }, "tools": draft.values.tools + ]) + } + /// Replace a caller's policy. `allow`/`review`/`deny` are `tool.op` specs. /// An op in none of the lists is denied. @discardableResult diff --git a/macapp/Sources/ToolstackKit/CallerSettingsDraft.swift b/macapp/Sources/ToolstackKit/CallerSettingsDraft.swift new file mode 100644 index 0000000..e342e09 --- /dev/null +++ b/macapp/Sources/ToolstackKit/CallerSettingsDraft.swift @@ -0,0 +1,197 @@ +import Foundation +import CryptoKit + +public struct SettingsPolicy: Codable, Equatable, Sendable { + public var tools: [String: [String: String]]? + public var enabled: [String]? + public var disabledFunctions: [String: [String]]? +} + +public struct SettingsSnapshot: Codable, Equatable, Sendable { + public let name: String + public let callerId: Int + public let installation: String + public let revision: String + public let `operator`: String + public var policy: SettingsPolicy + public var enabled: [String] + public let catalog: [String: [OpInfo]] +} + +public struct SettingsReceipt: Decodable, Sendable { + public let outcome: String + public let name: String + public let policy: SettingsPolicy +} + +public struct SettingsValues: Codable, Equatable, Sendable { + public var enabled: Set + public var excluded: [String: Set] + public var tools: [String: [String: String]] + + public init(_ snapshot: SettingsSnapshot) { + enabled = Set(snapshot.enabled).intersection(snapshot.catalog.keys) + excluded = [:]; tools = [:] + for (tool, ops) in snapshot.catalog { + excluded[tool] = Set(snapshot.policy.disabledFunctions?[tool] ?? []).intersection(ops.map(\.op)) + tools[tool] = Dictionary(uniqueKeysWithValues: ops.map { + ($0.op, snapshot.policy.tools?[tool]?[$0.op] ?? "deny") + }) + } + } + public func active(_ tool: String, _ op: String) -> Bool { + enabled.contains(tool) && !(excluded[tool]?.contains(op) ?? false) + } + public func effect(_ tool: String, _ op: String) -> String { tools[tool]?[op] ?? "deny" } +} + +public struct SettingsDifference: Identifiable, Sendable { + public let id: String + public let draft: String + public let latest: String +} + +/// Both tabs and their baseline are one durable draft. Comparing never edits it. +public struct CallerSettingsDraft: Codable, Equatable, Sendable { + public enum Phase: String, Codable, Sendable { case editing, saving, unknown, stale, saved } + public var snapshot: SettingsSnapshot + public var baseline: SettingsValues + public var values: SettingsValues + public var phase: Phase = .editing + public var tab = "enabled" + public var search = "" + public var enabledOnly = false + public var needsReview = false + public var expanded: Set = [] + public var collapsedSearch: Set = [] + public var dirty: Bool { values != baseline } + public var canSave: Bool { phase == .editing && dirty } + public var filtering: Bool { !search.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || enabledOnly || needsReview } + public init(_ snapshot: SettingsSnapshot) { + self.snapshot = snapshot; baseline = SettingsValues(snapshot); values = baseline + } + public func matches(_ tool: String) -> [OpInfo] { + let query = search.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return (snapshot.catalog[tool] ?? []).filter { + (query.isEmpty || "\(tool) \($0.op) \($0.description)".lowercased().contains(query)) && + (!enabledOnly || values.active(tool, $0.op)) && + (!needsReview || values.effect(tool, $0.op) == "review") + } + } + public mutating func setTool(_ tool: String, enabled: Bool) { + if enabled { values.enabled.insert(tool); values.excluded[tool] = [] } + else { values.enabled.remove(tool) } + } + public mutating func setFunction(_ tool: String, _ op: String, enabled: Bool) { + if enabled && !values.enabled.contains(tool) { + values.enabled.insert(tool) + values.excluded[tool] = Set(snapshot.catalog[tool, default: []].map(\.op)) + } + if enabled { values.excluded[tool, default: []].remove(op) } + else { values.excluded[tool, default: []].insert(op) } + } + public mutating func bulk(_ tool: String, enabled: Bool) { + // Capture matches before changing enablement (Enabled only can remove rows). + let visible = matches(tool) + for op in visible { setFunction(tool, op.op, enabled: enabled) } + } + public mutating func observe(_ latest: SettingsSnapshot) { + if phase != .unknown && (latest.revision != snapshot.revision || latest.installation != snapshot.installation) { phase = .stale } + } + public func canRebase(_ latest: SettingsSnapshot) -> Bool { + latest.name == snapshot.name && latest.callerId == snapshot.callerId && latest.installation == snapshot.installation && + snapshot.catalog.allSatisfy { tool, ops in + let current = Set(latest.catalog[tool, default: []].map(\.op)) + return latest.catalog[tool] != nil && Set(ops.map(\.op)).isSubset(of: current) + } + } + public func differences(_ latest: SettingsSnapshot) -> [SettingsDifference] { + let remote = SettingsValues(latest) + var rows: [SettingsDifference] = [] + for tool in Set(snapshot.catalog.keys).union(latest.catalog.keys).sorted() { + let local = values.enabled.contains(tool), saved = remote.enabled.contains(tool) + if local != saved { rows.append(.init(id: "\(tool) / whole tool", draft: String(local), latest: String(saved))) } + for op in Set(snapshot.catalog[tool, default: []].map(\.op)).union(latest.catalog[tool, default: []].map(\.op)).sorted() { + let a = "\(values.active(tool, op) ? "Enabled" : "Disabled") / \(values.effect(tool, op))" + let b = latest.catalog[tool]?.contains(where: { $0.op == op }) == true ? + "\(remote.active(tool, op) ? "Enabled" : "Disabled") / \(remote.effect(tool, op))" : "No longer available" + if a != b { rows.append(.init(id: "\(tool) / \(op)", draft: a, latest: b)) } + } + } + return rows + } + /// Explicitly invoked only after showing side-by-side comparison. Preserve remote + /// unedited fields; do not discard edits for vanished functions or a different server. + @discardableResult public mutating func keepEdits(on latest: SettingsSnapshot) -> Bool { + guard canRebase(latest) else { return false } + var merged = SettingsValues(latest) + for (tool, ops) in snapshot.catalog { + if baseline.enabled.contains(tool) != values.enabled.contains(tool) { + if values.enabled.contains(tool) { merged.enabled.insert(tool) } else { merged.enabled.remove(tool) } + } + for op in ops.map(\.op) { + if baseline.effect(tool, op) != values.effect(tool, op) { merged.tools[tool]?[op] = values.effect(tool, op) } + let a = baseline.excluded[tool]?.contains(op) ?? false, b = values.excluded[tool]?.contains(op) ?? false + if a != b { + if b { merged.excluded[tool, default: []].insert(op) } else { merged.excluded[tool, default: []].remove(op) } + } + } + } + snapshot = latest; baseline = SettingsValues(latest); values = merged; phase = .editing + return true + } + public mutating func accept(_ receipt: SettingsReceipt) throws { + guard receipt.outcome == "saved", receipt.name == snapshot.name else { throw ApiError.decoding("Wrong settings receipt") } + var committed = snapshot; committed.policy = receipt.policy; committed.enabled = receipt.policy.enabled ?? [] + guard SettingsValues(committed) == values else { throw ApiError.decoding("Settings receipt differs from draft") } + baseline = values; phase = .saved + } +} + +/// Device-local non-secret drafts, scoped to server, operator, installation and caller. +/// No tokens or CSRF values are serialized. Saving is owner-only and atomic. +public struct SettingsDraftStore: Sendable { + public let directory: URL + public init(directory: URL) { self.directory = directory } + private func file(account: String, snapshot: SettingsSnapshot) -> URL { + let scope = [account, snapshot.operator, snapshot.installation, snapshot.name, String(snapshot.callerId)] + let data = try! JSONEncoder().encode(scope) + let name = SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + return directory.appendingPathComponent(name + ".json") + } + public func load(account: String, snapshot: SettingsSnapshot) throws -> CallerSettingsDraft? { + let url = file(account: account, snapshot: snapshot) + guard FileManager.default.fileExists(atPath: url.path) else { return nil } + var draft = try JSONDecoder().decode(CallerSettingsDraft.self, from: Data(contentsOf: url)) + guard draft.snapshot.name == snapshot.name, draft.snapshot.callerId == snapshot.callerId, draft.snapshot.installation == snapshot.installation, + draft.snapshot.operator == snapshot.operator else { return nil } + if draft.phase == .saving { draft.phase = .unknown } + draft.observe(snapshot) + return draft + } + public func save(_ draft: CallerSettingsDraft, account: String) throws { + let fm = FileManager.default + try fm.createDirectory(at: directory, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700]) + let permissions = try fm.attributesOfItem(atPath: directory.path) + guard permissions[.type] as? FileAttributeType == .typeDirectory, + (permissions[.posixPermissions] as? NSNumber)?.intValue == 0o700 else { + throw CocoaError(.fileWriteNoPermission) + } + let destination = file(account: account, snapshot: draft.snapshot) + if !draft.dirty && draft.phase != .unknown && draft.phase != .saving && draft.phase != .stale { + if fm.fileExists(atPath: destination.path) { try fm.removeItem(at: destination) }; return + } + let temporary = directory.appendingPathComponent(UUID().uuidString) + guard fm.createFile(atPath: temporary.path, contents: nil, attributes: [.posixPermissions: 0o600]) else { throw CocoaError(.fileWriteUnknown) } + defer { try? fm.removeItem(at: temporary) } + let handle = try FileHandle(forWritingTo: temporary) + do { try handle.write(contentsOf: JSONEncoder().encode(draft)); try handle.synchronize(); try handle.close() } + catch { try? handle.close(); throw error } + if fm.fileExists(atPath: destination.path) { _ = try fm.replaceItemAt(destination, withItemAt: temporary) } + else { try fm.moveItem(at: temporary, to: destination) } + } + public func discard(account: String, snapshot: SettingsSnapshot) throws { + let url = file(account: account, snapshot: snapshot) + if FileManager.default.fileExists(atPath: url.path) { try FileManager.default.removeItem(at: url) } + } +} diff --git a/macapp/Tests/ToolstackKitTests/CallerSettingsTests.swift b/macapp/Tests/ToolstackKitTests/CallerSettingsTests.swift new file mode 100644 index 0000000..3de851c --- /dev/null +++ b/macapp/Tests/ToolstackKitTests/CallerSettingsTests.swift @@ -0,0 +1,117 @@ +import XCTest +@testable import ToolstackKit + +final class CallerSettingsTests: XCTestCase { + func snapshot(revision: String = "one", installation: String = "fake-installation") throws -> SettingsSnapshot { + let catalog = Dictionary(uniqueKeysWithValues: (0..<30).map { tool in + (String(format: "tool%02d", tool), (0..<20).map { op in + ["op": String(format: "fn%02d", op), "risk": "read", "description": "fake function"] + }) + }) + let json: [String: Any] = ["name": "writer-demo", "caller_id": 1, "operator": "admin", "installation": installation, "revision": revision, + "enabled": ["tool00"], "policy": ["tools": ["tool00": ["fn00": "review"]]], "catalog": catalog] + let decoder = JSONDecoder(); decoder.keyDecodingStrategy = .convertFromSnakeCase + return try decoder.decode(SettingsSnapshot.self, from: JSONSerialization.data(withJSONObject: json)) + } + func testWholeToolDefaultsAndFunctionExclusionsRetainPolicy() throws { + var draft = CallerSettingsDraft(try snapshot()) + draft.setTool("tool01", enabled: true) + XCTAssertEqual(draft.matches("tool01").filter { draft.values.active("tool01", $0.op) }.count, 20) + XCTAssertEqual(draft.values.effect("tool01", "fn00"), "deny") + draft.setFunction("tool00", "fn00", enabled: false) + XCTAssertFalse(draft.values.active("tool00", "fn00")) + XCTAssertEqual(draft.values.effect("tool00", "fn00"), "review") + draft.setTool("tool00", enabled: false) + draft.setFunction("tool00", "fn01", enabled: true) + XCTAssertTrue(draft.values.active("tool00", "fn01")) + XCTAssertFalse(draft.values.active("tool00", "fn00")) + } + func test600FunctionsSearchAndFilteredBulkScope() throws { + var draft = CallerSettingsDraft(try snapshot()) + XCTAssertEqual(draft.snapshot.catalog.values.reduce(0) { $0 + $1.count }, 600) + draft.search = "fn01" + XCTAssertEqual(draft.snapshot.catalog.keys.reduce(0) { $0 + draft.matches($1).count }, 30) + draft.bulk("tool01", enabled: true) + XCTAssertTrue(draft.values.active("tool01", "fn01")) + XCTAssertFalse(draft.values.active("tool01", "fn00")) + draft.search = ""; draft.enabledOnly = true + draft.bulk("tool00", enabled: false) + XCTAssertEqual(draft.matches("tool00").count, 0) + draft.enabledOnly = false; draft.needsReview = true + XCTAssertEqual(draft.matches("tool00").map(\.op), ["fn00"]) + } + func testComparingNeverChangesDraftAndExplicitRebasePreservesRemoteEdits() throws { + var draft = CallerSettingsDraft(try snapshot()) + draft.values.tools["tool00"]?["fn00"] = "deny" + draft.setFunction("tool00", "fn02", enabled: false) + let before = draft.values + var remote = try snapshot(revision: "two") + remote.policy.tools?["tool00"]?["fn01"] = "allow" + draft.observe(remote) + XCTAssertEqual(draft.phase, .stale); XCTAssertFalse(draft.canSave) + XCTAssertFalse(draft.differences(remote).isEmpty) + XCTAssertEqual(draft.values, before) + XCTAssertTrue(draft.keepEdits(on: remote)) + XCTAssertEqual(draft.values.effect("tool00", "fn01"), "allow") + XCTAssertEqual(draft.values.effect("tool00", "fn00"), "deny") + XCTAssertFalse(draft.values.active("tool00", "fn02")) + XCTAssertTrue(draft.canSave) + } + func testDifferentInstallationDoesNotRebase() throws { + var draft = CallerSettingsDraft(try snapshot()) + draft.setTool("tool01", enabled: true) + let before = draft + XCTAssertFalse(draft.keepEdits(on: try snapshot(installation: "different"))) + XCTAssertEqual(draft, before) + } + func testDurableDraftIsPrivateScopedAndInterruptedSaveCannotReplay() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let store = SettingsDraftStore(directory: root) + let remote = try snapshot() + var draft = CallerSettingsDraft(remote) + draft.setTool("tool01", enabled: true); draft.values.tools["tool01"]?["fn01"] = "allow" + draft.tab = "policy"; draft.search = "fn01"; draft.phase = .saving + try store.save(draft, account: "http://fake.invalid") + let restored = try XCTUnwrap(store.load(account: "http://fake.invalid", snapshot: remote)) + XCTAssertEqual(restored.values, draft.values); XCTAssertEqual(restored.tab, "policy") + XCTAssertEqual(restored.search, "fn01"); XCTAssertEqual(restored.phase, .unknown); XCTAssertFalse(restored.canSave) + XCTAssertNil(try store.load(account: "http://other.invalid", snapshot: remote)) + let file = try XCTUnwrap(FileManager.default.contentsOfDirectory(at: root, includingPropertiesForKeys: nil).first) + XCTAssertEqual((try FileManager.default.attributesOfItem(atPath: file.path)[.posixPermissions] as? NSNumber)?.intValue, 0o600) + draft.search = "second write"; try store.save(draft, account: "http://fake.invalid") + XCTAssertEqual(try store.load(account: "http://fake.invalid", snapshot: remote)?.search, "second write") + try store.discard(account: "http://fake.invalid", snapshot: remote) + XCTAssertNil(try store.load(account: "http://fake.invalid", snapshot: remote)) + } + + func testRemovedFunctionsRetainTheOriginalDraft() throws { + var draft = CallerSettingsDraft(try snapshot()) + draft.values.tools["tool00"]?["fn01"] = "allow" + var json = try XCTUnwrap(JSONSerialization.jsonObject(with: JSONEncoder().encode(draft.snapshot)) as? [String: Any]) + var catalog = try XCTUnwrap(json["catalog"] as? [String: [[String: String]]]) + catalog["tool00"] = catalog["tool00"]?.filter { $0["op"] != "fn01" } + json["catalog"] = catalog; json["revision"] = "two" + let remote = try JSONDecoder().decode(SettingsSnapshot.self, from: JSONSerialization.data(withJSONObject: json)) + let before = draft + XCTAssertFalse(draft.keepEdits(on: remote)) + XCTAssertEqual(draft, before) + XCTAssertTrue(draft.differences(remote).contains { $0.latest == "No longer available" }) + } + + func testReceiptMustMatchBothTabsAndTarget() throws { + var draft = CallerSettingsDraft(try snapshot()) + draft.values.tools["tool00"]?["fn01"] = "allow"; draft.setTool("tool01", enabled: true) + let decoder = JSONDecoder(); decoder.keyDecodingStrategy = .convertFromSnakeCase + let wrong = try decoder.decode(SettingsReceipt.self, from: Data(#"{"outcome":"saved","name":"writer-demo","policy":{"enabled":["tool00"],"tools":{}}}"#.utf8)) + XCTAssertThrowsError(try draft.accept(wrong)) + XCTAssertTrue(draft.dirty) + let payload: [String: Any] = ["outcome": "saved", "name": "writer-demo", "policy": [ + "enabled": draft.values.enabled.sorted(), "tools": draft.values.tools, + "disabled_functions": draft.values.excluded.mapValues { $0.sorted() } + ]] + let receipt = try decoder.decode(SettingsReceipt.self, from: JSONSerialization.data(withJSONObject: payload)) + try draft.accept(receipt) + XCTAssertEqual(draft.phase, .saved); XCTAssertFalse(draft.dirty); XCTAssertFalse(draft.canSave) + } +} diff --git a/macapp/Tests/ToolstackKitTests/StalePublicationTests.swift b/macapp/Tests/ToolstackKitTests/StalePublicationTests.swift index c471301..25ac54a 100644 --- a/macapp/Tests/ToolstackKitTests/StalePublicationTests.swift +++ b/macapp/Tests/ToolstackKitTests/StalePublicationTests.swift @@ -11,6 +11,24 @@ extension PendingTransport { } final class StalePublicationTests: XCTestCase { + @MainActor func testLateCombinedSettingsCannotCrossAnAccountReset() async throws { + let model = model() + let json = #"{"name":"A","caller_id":1,"operator":"admin","installation":"fake","revision":"one","enabled":["echo"],"policy":{"tools":{"echo":{"say":"review"}}},"catalog":{"echo":[{"op":"say","risk":"read","description":"fake"}]}}"# + let decoder = JSONDecoder(); decoder.keyDecodingStrategy = .convertFromSnakeCase + var draft = CallerSettingsDraft(try decoder.decode(SettingsSnapshot.self, from: Data(json.utf8))) + draft.values.tools["echo"]?["say"] = "allow" + let started = expectation(description: "combined settings pending") + PendingTransport.started = { if PendingTransport.count == 1 { started.fulfill() } } + let pending = draft + let task = Task { await model.writeCallerSettings(pending) } + await fulfillment(of: [started], timeout: 2) + try seedB(model) + PendingTransport.pending?.respond(200, #"{"outcome":"saved","name":"A","policy":{"enabled":["echo"],"tools":{"echo":{"say":"allow"}},"disabled_functions":{"echo":[]}}}"#) + let returned = await task.value + XCTAssertEqual(returned, pending, "A late receipt is not consumable as a saved B draft") + assertB(model) + } + @MainActor private func model() -> AppModel { PendingTransport.count = 0 let configuration = URLSessionConfiguration.ephemeral From 2614889d56c6b169d65affdd308e4042a6db4445 Mon Sep 17 00:00:00 2001 From: batteryshark <1574871+batteryshark@users.noreply.github.com> Date: Sun, 25 Oct 2026 18:16:48 -0400 Subject: [PATCH 2/2] Keep new catalog entries visible in comparisons and preserve filtered focus --- admin/tests/test_caller_settings.py | 19 +++++++++++++++++++ admin/views/settings_assets.py | 7 ++++++- .../ToolstackApp/CallerSettingsEditor.swift | 16 +++++++++------- .../ToolstackKit/CallerSettingsDraft.swift | 3 ++- 4 files changed, 36 insertions(+), 9 deletions(-) diff --git a/admin/tests/test_caller_settings.py b/admin/tests/test_caller_settings.py index 6f1eb58..4461cb6 100644 --- a/admin/tests/test_caller_settings.py +++ b/admin/tests/test_caller_settings.py @@ -2,6 +2,7 @@ import copy import json import sqlite3 +import time import unittest from contextlib import closing from pathlib import Path @@ -110,11 +111,29 @@ def test_audit_failure_rolls_back_policy_and_invalidation(self): self.seed() with closing(Store(self.db_path)) as store: caller = store.caller_by_name("writer-demo")["id"]; before = store.policy_for(caller) + request = store.create_request("fake-approval", caller, "echo", "say", "pending_approval") + store.create_approval(request, "fake-card", time.time() + 300) store._conn.execute("CREATE TRIGGER fail_settings BEFORE INSERT ON audit_events BEGIN SELECT RAISE(ABORT, 'fake full disk'); END") store._conn.commit() with self.assertRaises(sqlite3.IntegrityError): store.compare_and_set_policy(caller, before, {"enabled": [], "tools": {}}, "operator") self.assertEqual(store.policy_for(caller), before) + self.assertEqual(store.request(request)["status"], "pending_approval") + self.assertEqual(store.approval_for_request(request)["status"], "pending") + + def test_combined_save_invalidates_pending_approval_in_the_same_commit(self): + state = self.seed(); draft = self.edit(state) + with closing(Store(self.db_path)) as store: + caller = store.caller_by_name("writer-demo")["id"] + request = store.create_request("fake-approval", caller, "echo", "say", "pending_approval") + store.create_approval(request, "fake-card", time.time() + 300) + draft["tools"]["echo"]["say"] = "allow" + self.assertEqual(self.client.put(self.url, headers=self.headers, json=draft).status_code, 200) + with closing(Store(self.db_path)) as store: + self.assertEqual(store.policy_for(caller)["tools"]["echo"]["say"], "allow") + self.assertEqual(store.request(request)["status"], "denied") + self.assertEqual(store.approval_for_request(request)["status"], "cancelled") + self.assertEqual(len(store.recent_audit(500)), 1) def test_legacy_mutations_keep_function_exclusions(self): self.seed({"enabled": ["echo"], "tools": {"echo": {"say": "allow"}}, "disabled_functions": {"echo": ["say"]}}) diff --git a/admin/views/settings_assets.py b/admin/views/settings_assets.py index 3128965..c49020d 100644 --- a/admin/views/settings_assets.py +++ b/admin/views/settings_assets.py @@ -126,7 +126,7 @@ persist(); } function switchTab(id){scroll[tab]=list.scrollTop;tab=id;render();list.scrollTop=scroll[tab]||0;tabs.querySelector('[aria-selected=true]').focus();persist();} - search.oninput=()=>{query=search.value;render();}; + search.oninput=()=>{query=search.value;expanded=new Set([...expanded].filter(k=>!k.startsWith('closed:')));render();}; list.onscroll=()=>{scroll[tab]=list.scrollTop;persist();}; function close(){persist();dialog.close();dialog.remove();document.removeEventListener('visibilitychange',visible);window.removeEventListener('pagehide',persist);clearInterval(poll);onClose();} function leave(){if(busy)return;if(!dirty()&&!unknown){close();return;} @@ -147,6 +147,10 @@ const table=node('table'),thead=node('thead'),tr=node('tr');['Setting','Your draft','Latest saved'].forEach(s=>tr.append(node('th',s)));thead.append(tr);table.append(thead);const tbody=node('tbody');table.append(tbody);let removed=false,rows=0; const merged=clone(latest); function diff(label,a,b){if(a===b)return;rows++;const row=node('tr');[label,a,b].forEach(x=>row.append(node('td',String(x))));tbody.append(row);} + Object.keys(remote.catalog).filter(t=>!(t in state.catalog)).forEach(t=>{ + diff(t,'Not in draft',latest.enabled.includes(t)); + remote.catalog[t].forEach(o=>diff(t+' / '+o.op,'Not in draft',active(latest,t,o.op)+' / '+effect(latest,t,o.op))); + }); Object.keys(state.catalog).forEach(t=>{ if(!(t in remote.catalog)){removed=true;diff(t,'Draft retained','Tool no longer available');return;} diff(t+' / whole tool',draft.enabled.includes(t),latest.enabled.includes(t)); @@ -158,6 +162,7 @@ const excluded=s=>s.disabled_functions[t]?.includes(o.op)||false; if(excluded(base)!==excluded(draft)){merged.disabled_functions[t]=merged.disabled_functions[t].filter(x=>x!==o.op);if(excluded(draft))merged.disabled_functions[t].push(o.op);} }); + remote.catalog[t].filter(o=>!(o.op in draft.tools[t])).forEach(o=>diff(t+' / '+o.op,'Not in draft',active(latest,t,o.op)+' / '+effect(latest,t,o.op))); }); if(!rows)d.append(node('p','Both tabs match the saved settings. No write is needed.'));else d.append(table); if(removed)d.append(node('p','The function set changed. Your original draft is retained; discard it explicitly and reopen to edit the new catalog.')); diff --git a/macapp/Sources/ToolstackApp/CallerSettingsEditor.swift b/macapp/Sources/ToolstackApp/CallerSettingsEditor.swift index a7af5d0..b765248 100644 --- a/macapp/Sources/ToolstackApp/CallerSettingsEditor.swift +++ b/macapp/Sources/ToolstackApp/CallerSettingsEditor.swift @@ -38,7 +38,7 @@ struct CallerSettingsEditor: View { Spacer() } HStack(spacing: 20) { - Text(saving ? "Saving both tabs…" : draft?.dirty == true ? "Unsaved changes · Both tabs · Draft kept on this device" : "No unsaved changes") + Text(saving ? "Saving both tabs…" : draft?.dirty == true ? "Unsaved changes · Both tabs · \(storageError ? "Not stored" : "Draft kept on this device")" : "No unsaved changes") .font(.callout).foregroundStyle(.secondary) Spacer() Button("Cancel") { leave() }.keyboardShortcut(.cancelAction).disabled(saving) @@ -87,7 +87,7 @@ struct CallerSettingsEditor: View { Text("Policy" + (current.values.tools != current.baseline.tools ? " · Unsaved changes" : "")).tag("policy") }.pickerStyle(.segmented) HStack { - TextField("Search tools and functions", text: binding(\.search)).textFieldStyle(.roundedBorder) + TextField("Search tools and functions", text: Binding(get: { draft?.search ?? "" }, set: { query in edit { $0.search = query; $0.collapsedSearch.removeAll() } })).textFieldStyle(.roundedBorder) .accessibilityLabel("Search tools and functions").focused($searchFocused) Toggle("Enabled only", isOn: binding(\.enabledOnly)) Toggle("Needs review", isOn: binding(\.needsReview)) @@ -136,18 +136,20 @@ struct CallerSettingsEditor: View { if expanded { $0.expanded.remove(tool); $0.collapsedSearch.insert(tool) } else { $0.expanded.insert(tool); $0.collapsedSearch.remove(tool) } } - } label: { Label(tool, systemImage: expanded ? "chevron.down" : "chevron.right").fontWeight(.semibold) } - .buttonStyle(.plain).frame(minHeight: 32).accessibilityLabel("\(tool) functions") + } label: { Label(tool, systemImage: expanded ? "chevron.down" : "chevron.right").fontWeight(.semibold).frame(minHeight: 32).contentShape(Rectangle()) } + .buttonStyle(.plain).accessibilityLabel("\(tool) functions") .accessibilityValue(expanded ? "Expanded" : "Collapsed") Text("\(count)/\(all.count) enabled" + (current.filtering ? " · \(ops.count) matches" : "")) .font(.caption).foregroundStyle(.secondary) Spacer() if tab == "enabled" { - Toggle("Whole tool", isOn: Binding(get: { count == all.count }, set: { enabled in edit { $0.setTool(tool, enabled: enabled) } })) + Toggle("Whole tool", isOn: Binding(get: { count == all.count }, set: { enabled in + edit { $0.setTool(tool, enabled: enabled) }; if draft?.matches(tool).isEmpty == true { searchFocused = true } + })) .accessibilityLabel("Enable whole tool \(tool)") - Button(current.filtering ? "All matches" : "All") { edit { $0.bulk(tool, enabled: true) } } + Button(current.filtering ? "All matches" : "All") { edit { $0.bulk(tool, enabled: true) }; if draft?.matches(tool).isEmpty == true { searchFocused = true } } .accessibilityLabel("Enable \(current.filtering ? "matched" : "all") \(tool) functions") - Button(current.filtering ? "None matches" : "None") { edit { $0.bulk(tool, enabled: false) } } + Button(current.filtering ? "None matches" : "None") { edit { $0.bulk(tool, enabled: false) }; if draft?.matches(tool).isEmpty == true { searchFocused = true } } .accessibilityLabel("Disable \(current.filtering ? "matched" : "all") \(tool) functions") } } diff --git a/macapp/Sources/ToolstackKit/CallerSettingsDraft.swift b/macapp/Sources/ToolstackKit/CallerSettingsDraft.swift index e342e09..59d6ffe 100644 --- a/macapp/Sources/ToolstackKit/CallerSettingsDraft.swift +++ b/macapp/Sources/ToolstackKit/CallerSettingsDraft.swift @@ -112,7 +112,8 @@ public struct CallerSettingsDraft: Codable, Equatable, Sendable { let local = values.enabled.contains(tool), saved = remote.enabled.contains(tool) if local != saved { rows.append(.init(id: "\(tool) / whole tool", draft: String(local), latest: String(saved))) } for op in Set(snapshot.catalog[tool, default: []].map(\.op)).union(latest.catalog[tool, default: []].map(\.op)).sorted() { - let a = "\(values.active(tool, op) ? "Enabled" : "Disabled") / \(values.effect(tool, op))" + let a = snapshot.catalog[tool]?.contains(where: { $0.op == op }) == true ? + "\(values.active(tool, op) ? "Enabled" : "Disabled") / \(values.effect(tool, op))" : "Not in draft" let b = latest.catalog[tool]?.contains(where: { $0.op == op }) == true ? "\(remote.active(tool, op) ? "Enabled" : "Disabled") / \(remote.effect(tool, op))" : "No longer available" if a != b { rows.append(.init(id: "\(tool) / \(op)", draft: a, latest: b)) }