okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

nekomimi.petconfig: return credentials-merged config directly as a native term11aceb8

main
37.5 KiB914 linesraw
1"""Conditional settings saves, independent writers, and prepared-context recovery."""
2
3from concurrent.futures import ThreadPoolExecutor
4import errno
5import json
6import os
7from pathlib import Path
8import stat
9import sys
10import threading
11import time
12import unittest
13import urllib.error
14import urllib.request
15
16from harness import Albedo, Provider, exclusive, operation_id, text
17
18SERVER = Path(__file__).with_name("fake_mcp_server.py")
19
20
21class SettingsTest(unittest.TestCase):
22    def setUp(self):
23        self.provider = Provider(lambda _request: text("ok"))
24        self.addCleanup(self.provider.close)
25        self.app = Albedo(self.provider).__enter__()
26        self.addCleanup(self.app.__exit__, None, None, None)
27        self.session = self.app.session()
28
29    def read(self, path):
30        with self.app.api(path) as response:
31            return json.load(response)
32
33    def group(self, group):
34        return self.read("/settings?group=" + group)
35
36    def patch(self, group, fields, *, etag=None):
37        route = "/settings?group=" + group
38        if etag is None:
39            with self.app.api(route) as response:
40                json.load(response)
41                etag = response.headers["ETag"]
42        with self.app.api(
43            route, fields, method="PATCH", headers={"If-Match": etag}
44        ) as response:
45            return json.load(response)
46
47    def change(self, fields, session=None):
48        route = f"/sessions/{session or self.session}?view=configuration"
49        with self.app.api(route) as response:
50            json.load(response)
51            etag = response.headers["ETag"]
52        with self.app.api(
53            route, fields, method="PATCH", headers={"If-Match": etag}
54        ) as response:
55            return json.load(response)
56
57    def profile(self, **fields):
58        return {
59            "extension": "openai",
60            "endpoint": self.provider.url,
61            "model": "fixture-model",
62            "protocol": "responses",
63            **fields,
64        }
65
66    def discovery(self, session=None):
67        return self.read(f"/sessions/{session or self.session}/catalog")["discovery"]
68
69    def commands(self):
70        return self.read(f"/sessions/{self.session}/catalog")["loaded"]["commands"]
71
72    def choice(self, name, *, kind="instructions", enabled=False, global_default=False):
73        catalog = self.discovery()
74        row = next(
75            row for row in catalog["candidates"] if row["preference_key"] == name
76        )
77        if global_default:
78            return self.patch(
79                "capabilities",
80                {
81                    "catalog_session_id": self.session,
82                    "catalog_revision": catalog["revision"],
83                    "choices": {row["id"]: enabled},
84                },
85            )
86        return self.change(
87            {
88                "catalog_revision": catalog["revision"],
89                "selection": {kind: {row["id"]: enabled}},
90            }
91        )
92
93    def reload(self, *, session=None):
94        with self.app.api(
95            f"/sessions/{session or self.session}/reload", {"target": "session"}
96        ) as response:
97            return json.load(response)
98
99    def turn(self, prompt="inspect settings", *, session=None):
100        self.app.prompt(session or self.session, prompt).close()
101        self.app.idle(session or self.session)
102        return json.dumps(self.provider.requests[-1]["request"])
103
104    def write_skill(self, name, description):
105        path = self.app.workspace / ".albedo" / "skills" / name / "SKILL.md"
106        path.parent.mkdir(parents=True, exist_ok=True)
107        path.write_text(f"---\nname: {name}\ndescription: {description}\n---\nbody\n")
108        return path
109
110    # exclusive: saves global profiles and credentials
111    @exclusive
112    def test_profiles_preserve_keys_unrelated_fields_and_redact_secrets(self):
113        path = self.app.home / "config.json"
114        config = json.loads(path.read_text())
115        config["unrelated"] = {"kept": True}
116        config["providers"]["settings-one"] = {
117            "extension": "openai",
118            "baseUrl": self.provider.url,
119            "model": "fixture-model",
120            "protocol": "responses",
121            "custom": "keep",
122        }
123        path.write_text(json.dumps(config))
124        self.patch(
125            "providers",
126            {
127                "default_profile": "settings-one",
128                "profiles": {"settings-one": {"api_key": "settings-secret"}},
129            },
130        )
131        self.patch(
132            "providers", {"profiles": {"settings-one": {"model": "replacement"}}}
133        )
134        snapshot = self.read("/settings")
135        self.assertEqual(snapshot["providers"]["default_profile"], "settings-one")
136        self.assertTrue(snapshot["providers"]["profiles"]["settings-one"]["has_key"])
137        self.assertNotIn("settings-secret", json.dumps(snapshot))
138        stored = json.loads(path.read_text())
139        self.assertNotIn("apiKey", stored["providers"]["settings-one"])
140        self.assertEqual(stored["providers"]["settings-one"]["custom"], "keep")
141        self.assertEqual(stored["unrelated"], {"kept": True})
142        self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o600)
143        self.patch(
144            "providers",
145            {"default_profile": self.app.profile, "profiles": {"settings-one": None}},
146        )
147        self.assertNotIn("settings-one", self.group("providers")["profiles"])
148        self.assertNotIn(
149            "settings-one",
150            json.loads((self.app.home / "creds.json").read_text())["providers"],
151        )
152
153    # exclusive: stored-data compatibility remains independent of the HTTP protocol
154    @exclusive
155    def test_legacy_flat_provider_remains_available_after_save(self):
156        path = self.app.home / "config.json"
157        path.write_text(
158            json.dumps(
159                {
160                    "extension": "openai",
161                    "baseUrl": self.provider.url,
162                    "model": "legacy",
163                    "protocol": "responses",
164                    "apiKey": "legacy-secret",
165                    "custom": "keep",
166                }
167            )
168        )
169        self.assertTrue(self.group("providers")["profiles"]["default"]["has_key"])
170        self.patch("providers", {"profiles": {"new-profile": self.profile()}})
171        self.assertIn("default", self.group("providers")["profiles"])
172        self.assertEqual(json.loads(path.read_text())["custom"], "keep")
173        self.assertNotIn("legacy-secret", json.dumps(self.read("/settings")))
174
175    # exclusive: partial profile writes retain omitted credentials
176    @exclusive
177    def test_provider_partial_saves_preserve_omitted_key_and_explicit_null_removes_it(
178        self,
179    ):
180        self.patch(
181            "providers",
182            {"profiles": {"normalization": self.profile(api_key="profile-secret")}},
183        )
184        self.patch("providers", {"profiles": {"normalization": {"model": "changed"}}})
185        profile = self.group("providers")["profiles"]["normalization"]
186        self.assertEqual(profile["model"], "changed")
187        self.assertTrue(profile["has_key"])
188        self.patch("providers", {"profiles": {"normalization": {"api_key": None}}})
189        self.assertFalse(
190            self.group("providers")["profiles"]["normalization"]["has_key"]
191        )
192        self.patch(
193            "providers",
194            {
195                "profiles": {
196                    "subscription": {
197                        "extension": "codex",
198                        "protocol": "responses",
199                        "model": "fixture",
200                    }
201                }
202            },
203        )
204        self.assertIsNone(
205            self.group("providers")["profiles"]["subscription"]["endpoint"]
206        )
207
208    # exclusive: preserves settings files across malformed candidates
209    @exclusive
210    def test_invalid_provider_profiles_leave_settings_and_credentials_untouched(self):
211        paths = [self.app.home / name for name in ("config.json", "creds.json")]
212        before = [path.read_bytes() if path.exists() else None for path in paths]
213        invalid = [
214            [],
215            {},
216            self.profile(model=""),
217            self.profile(model="fixture\x00model"),
218            self.profile(protocol="wrong"),
219            self.profile(extension=""),
220            self.profile(endpoint="file:///tmp/model"),
221            self.profile(endpoint="https://user@example.com"),
222            self.profile(endpoint="https://example.com?bad"),
223            self.profile(endpoint="https://example.com:invalid"),
224            self.profile(api_key="secret\nkey"),
225            self.profile(image_edge=0),
226            self.profile(ignored="unexpected"),
227        ]
228        for profile in invalid:
229            with (
230                self.subTest(profile=profile),
231                self.assertRaises(urllib.error.HTTPError) as caught,
232            ):
233                self.patch("providers", {"profiles": {"invalid-profile": profile}})
234            self.assertEqual(caught.exception.code, 400)
235            self.assertEqual(
236                [path.read_bytes() if path.exists() else None for path in paths], before
237            )
238
239    # exclusive: global group revisions are independent, session visits are idempotent
240    @exclusive
241    def test_concurrent_writers_reject_stale_group_and_preserve_independent_groups(
242        self,
243    ):
244        with self.app.api("/settings?group=ui") as response:
245            json.load(response)
246            etag = response.headers["ETag"]
247        with ThreadPoolExecutor(max_workers=2) as pool:
248            jobs = [
249                pool.submit(self.patch, "ui", fields, etag=etag)
250                for fields in ({"thinking": True}, {"tools": True})
251            ]
252            outcomes = []
253            for job in jobs:
254                try:
255                    outcomes.append(job.result())
256                except urllib.error.HTTPError as error:
257                    outcomes.append(error.code)
258        self.assertEqual(outcomes.count(412), 1)
259        self.patch("ui", {"thinking": True, "tools": True})
260        ids = [self.session, self.app.session()]
261        with ThreadPoolExecutor(max_workers=4) as pool:
262            list(
263                pool.map(
264                    lambda sid: self.change({"preferences": {"pinned": True}}, sid), ids
265                )
266            )
267            visits = [operation_id() for _ in range(12)]
268
269            def visit(identity):
270                with self.app.api(
271                    f"/sessions/{ids[0]}/visits/{identity}", {}, method="PUT"
272                ) as response:
273                    return json.load(response)
274
275            receipts = list(pool.map(visit, visits))
276        # After other visits, a lost acknowledgement returns its original count.
277        first = next(receipt for receipt in receipts if receipt["opens"] == 1)
278        self.assertEqual(visit(first["visit_id"]), first)
279        self.assertEqual(self.read(f"/sessions/{ids[0]}")["preferences"]["opens"], 12)
280        with self.assertRaises(urllib.error.HTTPError) as caught:
281            self.app.api(
282                f"/sessions/{ids[1]}/visits/{first['visit_id']}", {}, method="PUT"
283            )
284        self.assertEqual(caught.exception.code, 409)
285        self.assertEqual(self.read(f"/sessions/{ids[1]}")["preferences"]["opens"], 0)
286        self.assertTrue(
287            all(self.read(f"/sessions/{sid}")["preferences"]["pinned"] for sid in ids)
288        )
289        self.assertTrue(self.group("ui")["thinking"])
290        self.assertTrue(self.group("ui")["tools"])
291
292    def test_session_configuration_is_one_conditional_candidate(self):
293        self.write_skill("compound-config", "COMPOUND_CONFIG_SKILL")
294        self.assertIn("COMPOUND_CONFIG_SKILL", self.turn())
295        discovery = self.discovery()
296        skill = next(
297            row
298            for row in discovery["candidates"]
299            if row["preference_key"] == "compound-config"
300        )
301        route = f"/sessions/{self.session}?view=configuration"
302        with self.app.api(route) as response:
303            before = json.load(response)
304            observed = response.headers["ETag"]
305        model = before["model"] or self.read(f"/sessions/{self.session}")["model"]
306        candidate = {
307            "name": "must not partially save",
308            "preferences": {"pinned": True, "archived": True},
309            "model": model,
310            "effort": "unsupported-effort",
311            "selection": {"skills": {skill["id"]: False}},
312            "catalog_revision": discovery["revision"],
313        }
314        with self.assertRaises(urllib.error.HTTPError) as caught:
315            self.app.api(
316                route, candidate, method="PATCH", headers={"If-Match": observed}
317            )
318        self.assertEqual(caught.exception.code, 400)
319        self.assertEqual(self.read(route), before)
320        self.assertIn(
321            "/compound-config", {row["slash_name"] for row in self.commands()}
322        )
323
324        del candidate["effort"]
325        barrier = threading.Barrier(3)
326
327        def save(name):
328            barrier.wait()
329            try:
330                with self.app.api(
331                    route,
332                    {**candidate, "name": name},
333                    method="PATCH",
334                    headers={"If-Match": observed},
335                ) as response:
336                    return response.status, json.load(response)
337            except urllib.error.HTTPError as error:
338                return error.code, json.load(error)
339
340        with ThreadPoolExecutor(max_workers=2) as pool:
341            futures = [
342                pool.submit(save, name)
343                for name in ("first compound writer", "second compound writer")
344            ]
345            barrier.wait()
346            outcomes = [future.result(timeout=10) for future in futures]
347        self.assertEqual(sorted(status for status, _ in outcomes), [200, 412])
348        winner = next(body for status, body in outcomes if status == 200)
349        saved = self.read(route)
350        self.assertEqual(saved, winner["resource"]["value"])
351        self.assertTrue(saved["preferences"]["pinned"])
352        self.assertTrue(saved["preferences"]["archived"])
353        self.assertEqual(saved["model"], model)
354        # Desired choices do not silently rewrite prepared commands.
355        self.assertIn(
356            "/compound-config", {row["slash_name"] for row in self.commands()}
357        )
358        self.assertEqual(self.reload()["session"]["state"], "applied")
359        self.assertNotIn(
360            "/compound-config", {row["slash_name"] for row in self.commands()}
361        )
362        # Null entry and null map both clear overrides back to inheritance.
363        self.choice("compound-config", kind="skills", enabled=None)
364        self.assertEqual(self.reload()["session"]["state"], "applied")
365        self.assertIn("COMPOUND_CONFIG_SKILL", self.turn())
366        self.choice("compound-config", kind="skills", enabled=False)
367        self.change(
368            {
369                "selection": {"skills": None},
370                "catalog_revision": self.discovery()["revision"],
371            }
372        )
373        self.assertEqual(self.read(route)["selection"]["skills"], {})
374        self.change(
375            {
376                "selection": {"skills": {}},
377                "catalog_revision": self.discovery()["revision"],
378            }
379        )
380        self.assertEqual(self.read(route)["selection"]["skills"], {})
381        self.assertEqual(self.reload()["session"]["state"], "applied")
382        self.assertIn("COMPOUND_CONFIG_SKILL", self.turn())
383
384    # exclusive: restarts and deletes a session with persisted preferences
385    @exclusive
386    def test_preferences_and_visits_survive_restart_and_session_deletion(self):
387        picker = self.app.home / "picker.json"
388        picker.write_text(json.dumps({"unrelated": {"keep": True}}))
389        self.patch("ui", {"thinking": True})
390        self.change({"preferences": {"pinned": True, "archived": True}})
391        for _ in range(3):
392            self.app.api(
393                f"/sessions/{self.session}/visits/{operation_id()}", {}, method="PUT"
394            ).close()
395        self.app.restart()
396        self.assertEqual(json.loads(picker.read_text())["unrelated"], {"keep": True})
397        session = self.read(f"/sessions/{self.session}")
398        self.assertTrue(self.group("ui")["thinking"])
399        self.assertEqual(
400            (
401                session["preferences"]["pinned"],
402                session["preferences"]["archived"],
403                session["preferences"]["opens"],
404            ),
405            (True, True, 3),
406        )
407        resource = session["configuration_resource"]
408        self.app.api(
409            resource["url"], method="DELETE", headers={"If-Match": resource["etag"]}
410        ).close()
411        self.assertNotIn(
412            self.session,
413            [item["id"] for item in self.read("/sessions?archived=true")["items"]],
414        )
415        stored = json.loads(picker.read_text())
416        self.assertNotIn(self.session, json.dumps(stored))
417
418    # exclusive: global choices and explicit session overrides remain separate
419    @exclusive
420    # exclusive: changes daemon-wide extension and capability defaults
421    @exclusive
422    def test_capability_defaults_and_overrides_control_context_and_loaded_commands(
423        self,
424    ):
425        extension_file = self.app.home / "extensions.json"
426        before_bytes = extension_file.read_bytes()
427        with self.app.api("/settings?group=extensions") as response:
428            before_settings = json.load(response)
429            before_etag = response.headers["ETag"]
430        with self.assertRaises(urllib.error.HTTPError) as conflict:
431            self.patch("extensions", {"defaults": {"lcm": True, "rolling": True}})
432        self.assertEqual(conflict.exception.code, 400)
433        self.assertEqual(json.load(conflict.exception)["code"], "selection_conflict")
434        self.assertEqual(extension_file.read_bytes(), before_bytes)
435        with self.app.api("/settings?group=extensions") as response:
436            self.assertEqual(json.load(response), before_settings)
437            self.assertEqual(response.headers["ETag"], before_etag)
438        for selected, displaced in [("lcm", "rolling"), ("rolling", "lcm")]:
439            self.patch("extensions", {"defaults": {selected: True}})
440            session = self.app.session()
441            effective = self.read(f"/sessions/{session}?tail=0")["selection"][
442                "effective"
443            ]["extensions"]
444            self.assertTrue(effective[selected])
445            self.assertFalse(effective[displaced])
446        (self.app.workspace / "AGENTS.md").write_text("CAPABILITY_INSTRUCTION_MARKER")
447        self.write_skill("preference-demo", "CAPABILITY_SKILL_MARKER")
448        self.assertIn("CAPABILITY_INSTRUCTION_MARKER", self.turn())
449        self.assertIn("CAPABILITY_SKILL_MARKER", self.turn())
450        self.choice("project:AGENTS.md", global_default=True)
451        self.choice("preference-demo", kind="skills", global_default=True)
452        disabled = self.app.session()
453        context = self.turn(session=disabled)
454        self.assertNotIn("CAPABILITY_INSTRUCTION_MARKER", context)
455        self.assertNotIn("CAPABILITY_SKILL_MARKER", context)
456        self.choice("project:AGENTS.md", enabled=True)
457        self.choice("preference-demo", kind="skills", enabled=True)
458        self.reload()
459        context = self.turn()
460        self.assertIn("CAPABILITY_INSTRUCTION_MARKER", context)
461        self.assertIn("CAPABILITY_SKILL_MARKER", context)
462        self.assertIn(
463            "/preference-demo", {row["slash_name"] for row in self.commands()}
464        )
465        self.choice("preference-demo", kind="skills", enabled=None)
466        self.reload()
467        self.assertNotIn(
468            "/preference-demo", {row["slash_name"] for row in self.commands()}
469        )
470        self.assertNotIn("CAPABILITY_SKILL_MARKER", self.turn(session=disabled))
471
472    # exclusive: changes daemon-wide extension defaults
473    @exclusive
474    def test_saves_report_composition_changes_and_reload_lists_are_asked_for(self):
475        # More loaded sessions than one observation window, so the list
476        # gathers several windows.
477        sessions = [self.session] + [self.app.session() for _ in range(3)]
478        for session in sessions:
479            self.turn(session=session)
480
481        def pending():
482            return {
483                row["id"]
484                for row in self.read("/sessions?scope=all&needs_reload=true")["items"]
485            }
486
487        # A group that never feeds composition reports no change.
488        ui = self.patch("ui", {"thinking": False})
489        self.assertFalse(ui["application"]["composition_changed"])
490        self.assertNotIn("needs_reload_count", ui["application"])
491        self.assertEqual(pending(), set())
492
493        # A default every session inherits changes the desired composition,
494        # and the sessions behind it are listed when asked for.
495        extensions = self.patch("extensions", {"defaults": {"view": True}})
496        self.assertTrue(extensions["application"]["composition_changed"])
497        self.assertEqual(pending(), set(sessions))
498
499        # A reload brings one session up to date; the rest stay listed.
500        self.reload(session=sessions[0])
501        self.assertEqual(pending(), set(sessions[1:]))
502
503    # exclusive: malformed preferences cannot be hidden by a session override
504    @exclusive
505    def test_failed_reload_keeps_loaded_commands_and_does_not_overwrite_malformed_preferences(
506        self,
507    ):
508        skill = self.write_skill("validated-choice", "PREPARED_CHOICE")
509        self.choice("validated-choice", kind="skills", enabled=True)
510        self.reload()
511        prepared = self.commands()
512        path = self.app.home / "capabilities.json"
513        preferences = json.loads(path.read_text()) if path.exists() else {}
514        preferences["global"] = {"skills": {"validated-choice": "invalid"}}
515        path.write_text(json.dumps(preferences))
516        malformed = path.read_bytes()
517        skill.write_text(
518            skill.read_text().replace("PREPARED_CHOICE", "REPAIRED_CHOICE")
519        )
520        outcome = self.reload()
521        self.assertEqual(outcome["session"]["state"], "failed")
522        self.assertEqual(path.read_bytes(), malformed)
523        self.assertEqual(self.commands(), prepared)
524        preferences["global"]["skills"]["validated-choice"] = False
525        path.write_text(json.dumps(preferences))
526        self.assertEqual(self.reload()["session"]["state"], "applied")
527        self.assertEqual(
528            next(
529                row["description"]
530                for row in self.commands()
531                if row["slash_name"] == "/validated-choice"
532            ),
533            "REPAIRED_CHOICE",
534        )
535
536    # exclusive: disabled invalid instructions must not enter model context
537    @exclusive
538    def test_disabled_instruction_is_not_read_until_enabled(self):
539        (self.app.workspace / "AGENTS.md").write_bytes(b"\xff")
540        (self.app.workspace / "CLAUDE.md").write_text("READABLE_INSTRUCTION")
541        self.choice("project:AGENTS.md", global_default=True)
542        before = self.group("capabilities")
543        invalid = next(
544            row
545            for row in self.discovery()["candidates"]
546            if row["preference_key"] == "project:AGENTS.md"
547        )
548        self.assertFalse(invalid["valid"])
549        self.assertTrue(invalid["diagnostic"])
550        self.assertFalse(invalid["eligible"])
551        self.assertIn("READABLE_INSTRUCTION", self.turn())
552        with self.assertRaises(urllib.error.HTTPError):
553            self.choice("project:AGENTS.md", enabled=True)
554        self.assertEqual(self.group("capabilities"), before)
555
556    # exclusive: checks the persisted capability document byte boundary
557    @exclusive
558    def test_capability_size_limit_accepts_boundary_and_rejects_growth(self):
559        (self.app.workspace / "AGENTS.md").write_text("bounded preference")
560        self.write_skill("new-choice", "size boundary")
561        path = self.app.home / "capabilities.json"
562        limit = 1048576
563        for size in (limit - 1, limit):
564            document = {
565                "padding": "",
566                "global": {"instructions": {"project:AGENTS.md": False}},
567            }
568            overhead = len(json.dumps(document, separators=(",", ":")).encode())
569            document["padding"] = "a" * (size - overhead)
570            content = json.dumps(document, separators=(",", ":")).encode()
571            self.assertEqual(len(content), size)
572            path.write_bytes(content)
573            self.group("capabilities")
574            try:
575                self.choice("project:AGENTS.md", global_default=True)
576            except urllib.error.HTTPError as error:
577                error.add_note(
578                    f"capability boundary submitted {size} bytes; persisted {path.stat().st_size} bytes"
579                )
580                raise
581            self.assertEqual(path.stat().st_size, size)
582            with self.assertRaises(urllib.error.HTTPError):
583                self.choice("new-choice", kind="skills", global_default=True)
584            self.assertEqual(json.loads(path.read_bytes()), document)
585        content += b" "
586        path.write_bytes(content)
587        with self.assertRaises(urllib.error.HTTPError):
588            self.group("capabilities")
589        catalog = self.read(f"/sessions/{self.session}/catalog")
590        self.assertIsNone(catalog["discovery"])
591        self.assertIsNotNone(catalog["discovery_failure"])
592        self.assertEqual(path.read_bytes(), content)
593
594    # exclusive: validates global MCP candidates before saving secrets
595    @exclusive
596    def test_mcp_failed_candidate_keeps_configuration_credentials_and_loaded_tools(
597        self,
598    ):
599        self.change({"selection": {"extensions": {"mcp": True}}})
600        server = {
601            "enabled": True,
602            "transport": "stdio",
603            "command": sys.executable,
604            "arguments": [str(SERVER)],
605            "secrets": {"environment": {"FAKE_SECRET": "settings-mcp-secret"}},
606        }
607        self.patch("mcp", {"definitions": {"settings-mcp": server}})
608        self.reload()
609        before = self.group("mcp")
610        self.assertNotIn("settings-mcp-secret", json.dumps(before))
611        self.assertEqual(
612            before["definitions"]["settings-mcp"]["secret_presence"]["environment"],
613            ["FAKE_SECRET"],
614        )
615        creds = (self.app.home / "creds.json").read_bytes()
616        with self.assertRaises(urllib.error.HTTPError):
617            self.patch(
618                "mcp",
619                {
620                    "definitions": {
621                        "settings-mcp": {
622                            "command": "/missing/mcp",
623                            "secrets": {
624                                "environment": {"FAKE_SECRET": "changed-secret"}
625                            },
626                        }
627                    }
628                },
629            )
630        self.assertEqual(self.group("mcp"), before)
631        self.assertEqual((self.app.home / "creds.json").read_bytes(), creds)
632        self.turn("still usable")
633        tools = self.provider.requests[-1]["request"]["tools"]
634        self.assertTrue(
635            any(
636                tool.get("name", tool.get("function", {}).get("name", "")).startswith(
637                    "mcp_settings_mcp_"
638                )
639                for tool in tools
640            )
641        )
642        self.patch("mcp", {"definitions": {"settings-mcp": None}})
643        self.assertNotIn("settings-mcp", self.group("mcp")["definitions"])
644        self.assertNotIn(
645            "settings-mcp",
646            json.loads((self.app.home / "creds.json").read_text())["mcp"],
647        )
648
649    # exclusive: malformed persistent documents must never be overwritten
650    @exclusive
651    def test_malformed_documents_reject_saves_without_overwriting(self):
652        cases = [
653            ("picker.json", "ui", {"tools": True}, {"tools": "yes"}),
654            (
655                "config.json",
656                "providers",
657                {"profiles": {"sample": self.profile()}},
658                {"providers": []},
659            ),
660            (
661                "extensions.json",
662                "mcp",
663                {
664                    "definitions": {
665                        "sample": {
666                            "enabled": False,
667                            "transport": "http",
668                            "url": "http://localhost/mcp",
669                        }
670                    }
671                },
672                {"mcp": {"servers": []}},
673            ),
674        ]
675        for name, group, fields, malformed in cases:
676            path = self.app.home / name
677            prior = path.read_bytes() if path.exists() else None
678            try:
679                for content in (b'{"broken":', json.dumps(malformed).encode()):
680                    path.write_bytes(content)
681                    with self.assertRaises(urllib.error.HTTPError):
682                        self.patch(group, fields)
683                    self.assertEqual(path.read_bytes(), content)
684            finally:
685                if prior is None:
686                    path.unlink(missing_ok=True)
687                else:
688                    path.write_bytes(prior)
689        for group, fields in (
690            ("ui", {"tools": "yes"}),
691            ("providers", {"profiles": {"sample": self.profile(protocol="wrong")}}),
692            (
693                "mcp",
694                {
695                    "definitions": {
696                        "sample": {
697                            "enabled": False,
698                            "transport": "http",
699                            "url": "file:///tmp/server",
700                        }
701                    }
702                },
703            ),
704        ):
705            with self.assertRaises(urllib.error.HTTPError):
706                self.patch(group, fields)
707
708    # exclusive: persistence must fail atomically when the directory cannot be written
709    @exclusive
710    def test_failed_persistence_keeps_old_settings_and_credentials(self):
711        paths = [self.app.home / name for name in ("config.json", "creds.json")]
712        before = [path.read_bytes() if path.exists() else None for path in paths]
713        mode = stat.S_IMODE(self.app.home.stat().st_mode)
714        try:
715            self.app.home.chmod(0o500)
716            with self.assertRaises(urllib.error.HTTPError):
717                self.patch(
718                    "providers",
719                    {"profiles": {"failing": self.profile(api_key="must-not-save")}},
720                )
721        finally:
722            self.app.home.chmod(mode)
723        self.assertEqual(
724            [path.read_bytes() if path.exists() else None for path in paths], before
725        )
726
727    # exclusive: MCP validation and another settings save must not deadlock
728    @exclusive
729    def test_slow_mcp_validation_and_independent_ui_save_complete_when_overlapping(
730        self,
731    ):
732        marker = self.app.workspace / "mcp-started"
733        gate = self.app.workspace / "mcp-release"
734        os.mkfifo(gate)
735        script = (
736            "import pathlib; "
737            + f"pathlib.Path({str(marker)!r}).touch(); "
738            + f"open({str(gate)!r}, 'rb').read(1) if pathlib.Path({str(gate)!r}).exists() else None; "
739            + f"exec(pathlib.Path({str(SERVER)!r}).read_text())"
740        )
741        candidate = {
742            "definitions": {
743                "slow-start": {
744                    "enabled": True,
745                    "transport": "stdio",
746                    "command": sys.executable,
747                    "arguments": ["-c", script],
748                    "startup_timeout_ms": 30000,
749                }
750            }
751        }
752        with ThreadPoolExecutor(max_workers=2) as pool:
753            mcp = pool.submit(self.patch, "mcp", candidate)
754            deadline = time.monotonic() + 10
755            while not marker.exists() and time.monotonic() < deadline:
756                time.sleep(0.01)
757            self.assertTrue(marker.exists(), "MCP validation did not start")
758            try:
759                original = pool.submit(self.group, "mcp").result(timeout=10)
760                pool.submit(self.group, "ui").result(timeout=10)
761                pool.submit(self.patch, "ui", {"tools": True}).result(timeout=10)
762                newer = {
763                    "definitions": {
764                        "newer": {
765                            "enabled": False,
766                            "transport": "stdio",
767                            "command": sys.executable,
768                        }
769                    }
770                }
771                pool.submit(self.patch, "mcp", newer).result(timeout=10)
772                paths = [self.app.home / name for name in ("config.json", "creds.json")]
773                committed = [
774                    path.read_bytes() if path.exists() else None for path in paths
775                ]
776            finally:
777                deadline = time.monotonic() + 10
778                while True:
779                    try:
780                        descriptor = os.open(gate, os.O_WRONLY | os.O_NONBLOCK)
781                        break
782                    except OSError as error:
783                        if error.errno != errno.ENXIO or time.monotonic() >= deadline:
784                            raise
785                        time.sleep(0.01)
786                try:
787                    os.write(descriptor, b"x")
788                finally:
789                    os.close(descriptor)
790                    gate.unlink()
791            with self.assertRaises(urllib.error.HTTPError) as conflict:
792                mcp.result(timeout=40)
793            self.assertEqual(conflict.exception.code, 412)
794            self.assertEqual(
795                [path.read_bytes() if path.exists() else None for path in paths],
796                committed,
797            )
798        current = self.group("mcp")
799        self.assertNotIn("slow-start", current["definitions"])
800        self.assertIn("newer", current["definitions"])
801        self.assertNotEqual(current, original)
802        self.assertTrue(self.group("ui")["tools"])
803        self.patch("mcp", candidate)
804        self.assertIn("slow-start", self.group("mcp")["definitions"])
805
806    def test_settings_routes_reject_missing_authentication_and_browser_origins(self):
807        for method, path in (
808            ("GET", "/settings"),
809            ("PATCH", "/settings?group=providers"),
810            ("PATCH", "/settings?group=ui"),
811            ("PATCH", f"/sessions/{self.session}?view=configuration"),
812            ("PUT", f"/sessions/{self.session}/visits/{operation_id()}"),
813        ):
814            for headers in (
815                {},
816                {"Origin": "http://localhost"},
817                {
818                    "Authorization": "Bearer " + self.app.connection["token"],
819                    "Origin": "http://localhost",
820                },
821            ):
822                request = urllib.request.Request(
823                    self.app.base + path, method=method, headers=headers
824                )
825                with self.assertRaises(urllib.error.HTTPError) as caught:
826                    urllib.request.urlopen(request, timeout=10)
827                self.assertEqual(
828                    caught.exception.code, 403 if "Origin" in headers else 401
829                )
830                if "Origin" not in headers:
831                    self.assertEqual(
832                        caught.exception.headers["WWW-Authenticate"], "Bearer"
833                    )
834
835    # exclusive: corrupt vs missing config.json errors
836    @exclusive
837    def test_corrupt_and_missing_config_file_errors(self):
838        config_path = self.app.home / "config.json"
839        prior = config_path.read_bytes()
840        route = f"/sessions/{operation_id()}"
841        body = {
842            "kind": "new",
843            "workspace": str(self.app.workspace),
844            "provider_profile": self.app.profile,
845        }
846        try:
847            # 1. Non-JSON corrupt content
848            config_path.write_bytes(b"not valid json")
849            with self.assertRaises(urllib.error.HTTPError) as caught:
850                self.app.api(route, body, method="PUT", headers={"If-None-Match": "*"})
851            self.assertEqual(caught.exception.code, 409)
852            self.assertIn(
853                "provider configuration is invalid; run /login",
854                caught.exception.read().decode(),
855            )
856
857            # 2. Valid JSON but not a JSON object
858            config_path.write_bytes(b"[1, 2]")
859            with self.assertRaises(urllib.error.HTTPError) as caught:
860                self.app.api(
861                    f"/sessions/{operation_id()}",
862                    body,
863                    method="PUT",
864                    headers={"If-None-Match": "*"},
865                )
866            self.assertEqual(caught.exception.code, 409)
867            self.assertIn(
868                "provider configuration is invalid; run /login",
869                caught.exception.read().decode(),
870            )
871
872            # 3. Missing config.json
873            config_path.unlink()
874            with self.assertRaises(urllib.error.HTTPError) as caught:
875                self.app.api(
876                    f"/sessions/{operation_id()}",
877                    body,
878                    method="PUT",
879                    headers={"If-None-Match": "*"},
880                )
881            self.assertEqual(caught.exception.code, 409)
882            self.assertIn(
883                "provider is not configured; run /login",
884                caught.exception.read().decode(),
885            )
886        finally:
887            config_path.write_bytes(prior)
888
889    def test_busy_session_rejects_settings_before_persistence(self):
890        entered, release = threading.Event(), threading.Event()
891
892        def script(_request):
893            entered.set()
894            release.wait(10)
895            return text("finished")
896
897        self.provider.script = script
898        self.addCleanup(release.set)
899        self.app.prompt(self.session, "wait").close()
900        self.assertTrue(entered.wait(10))
901        route = f"/sessions/{self.session}?view=configuration"
902        before = self.read(route)
903        try:
904            with self.assertRaises(urllib.error.HTTPError) as caught:
905                self.change({"selection": {"extensions": {"mcp": True}}})
906            self.assertEqual(caught.exception.code, 409)
907            self.assertEqual(self.read(route), before)
908        finally:
909            release.set()
910            self.app.idle(self.session)
911
912
913if __name__ == "__main__":
914    unittest.main()