Fix structured settings environment projection

Canonicalize structured model settings before publishing the process environment so UI-shaped JSON objects remain readable by runtime consumers. Add seam and endpoint regression coverage for disk and environment projections.

Co-authored-by: Ouroboros <311266734+ouroboros-agent@users.noreply.github.com>
This commit is contained in:
Ouroboros 2026-09-11 16:10:59 +03:00
parent 526b0563d7
commit f9dd323737
3 changed files with 60 additions and 1 deletions

View file

@ -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)

View file

@ -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,
):

View file

@ -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