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