diff --git a/ouroboros/config.py b/ouroboros/config.py index 658230fec..c2cb0d219 100644 --- a/ouroboros/config.py +++ b/ouroboros/config.py @@ -952,6 +952,21 @@ def get_mcp_tool_timeout_sec() -> int: return parsed if parsed > 0 else int(SETTINGS_DEFAULTS["MCP_TOOL_TIMEOUT_SEC"]) +def _serialize_env_setting(key: str, value: object) -> str: + """Project the incoming settings shape without leaking Python repr syntax. + + Settings writers accept structured values from the UI before the persistence + prologue canonicalizes them for disk. The process projection must carry the + same JSON-shaped value, otherwise ``str(dict)`` produces single-quoted text + that strict runtime readers cannot parse. + """ + if key in (MODEL_ACCOUNTS_KEY, MODEL_CONTEXT_WINDOWS_KEY): + return normalize_model_role_options(key, value)[1] + if isinstance(value, (dict, list)): + return json.dumps(value, ensure_ascii=False, separators=(",", ":")) + return str(value) + + def apply_settings_to_env(settings: dict, *, environ=None) -> None: """Push settings into environment variables for supervisor modules.""" with _settings_integrity.SETTINGS_ENV_LOCK: @@ -974,7 +989,7 @@ def apply_settings_to_env(settings: dict, *, environ=None) -> None: if val is None or val == "": environ.pop(k, None) else: - environ[k] = str(val) + environ[k] = _serialize_env_setting(k, val) # Reviewer-model floors moved into the structured-slot projection (6.1): from ouroboros.reviewer_slot_config import project_reviewer_slots_into_env project_reviewer_slots_into_env(environ=environ) diff --git a/tests/test_owner_settings_write_seam.py b/tests/test_owner_settings_write_seam.py index 5907f72b9..363ef0311 100644 --- a/tests/test_owner_settings_write_seam.py +++ b/tests/test_owner_settings_write_seam.py @@ -348,6 +348,28 @@ def test_generic_settings_save_validates_and_canonicalizes_available_subagents( assert '"name"' not in canonical +def test_generic_settings_save_projects_model_role_objects_as_json( + monkeypatch, isolated_settings, +): + from ouroboros import config as cfg + from ouroboros.gateway import settings as settings_mod + + app = _settings_app(monkeypatch, isolated_settings) + monkeypatch.setattr(settings_mod, "_apply_settings_to_env", cfg.apply_settings_to_env) + response = TestClient(app).post("/api/settings", json={ + "OUROBOROS_MODEL_ACCOUNTS": {"main": "", "fallback": ["work", ""]}, + "OUROBOROS_MODEL_CONTEXT_WINDOWS": {"main": 131072, "fallback": [0, 65536]}, + }) + + assert response.status_code == 200, response.text + stored = json.loads(isolated_settings.read_text(encoding="utf-8")) + assert json.loads(stored["OUROBOROS_MODEL_ACCOUNTS"]) == { + "fallback": ["work", ""], "main": "", + } + assert json.loads(os.environ["OUROBOROS_MODEL_ACCOUNTS"])["main"] == "" + assert json.loads(os.environ["OUROBOROS_MODEL_CONTEXT_WINDOWS"])["main"] == 131072 + + def test_generic_settings_save_rejects_malformed_available_subagents_without_write( monkeypatch, isolated_settings, ): diff --git a/tests/test_settings_env_on_disk.py b/tests/test_settings_env_on_disk.py index e328e4714..fe7b0bf0e 100644 --- a/tests/test_settings_env_on_disk.py +++ b/tests/test_settings_env_on_disk.py @@ -150,6 +150,28 @@ def test_a_disk_authored_key_is_not_projected_back_out_of_a_silent_file( assert os.environ["OUROBOROS_CONTEXT_MODE"] == "max" +def test_structured_model_settings_keep_json_shape_in_environment(isolated_settings): + """UI-shaped model role objects stay parseable after process projection.""" + from ouroboros import config as cfg + + projected = {} + cfg.apply_settings_to_env({ + "OUROBOROS_MODEL_ACCOUNTS": {"main": "", "fallback": ["work", ""]}, + "OUROBOROS_MODEL_CONTEXT_WINDOWS": {"main": 131072, "fallback": [0, 65536]}, + }, environ=projected) + + assert json.loads(projected["OUROBOROS_MODEL_ACCOUNTS"]) == { + "fallback": ["work", ""], "main": "", + } + assert json.loads(projected["OUROBOROS_MODEL_CONTEXT_WINDOWS"]) == { + "fallback": [0, 65536], "main": 131072, + } + parsed, _ = cfg.normalize_model_role_options( + cfg.MODEL_ACCOUNTS_KEY, projected["OUROBOROS_MODEL_ACCOUNTS"]) + assert parsed["fallback"] == ["work", ""] + assert "'main'" not in projected["OUROBOROS_MODEL_ACCOUNTS"] + + def test_install_time_facts_are_disk_only_in_both_directions(isolated_settings, monkeypatch): """`ENDPOINT_AUTHORED_SETTINGS` is stricter than the ratchets: those project once the file carries them, these never leave disk at all. An environment