diff --git a/hermes_cli/codex_runtime_plugin_migration.py b/hermes_cli/codex_runtime_plugin_migration.py index dd7faa09794..49b4905d5b2 100644 --- a/hermes_cli/codex_runtime_plugin_migration.py +++ b/hermes_cli/codex_runtime_plugin_migration.py @@ -304,6 +304,37 @@ def render_codex_toml_section( return "\n".join(out) + "\n" +def _insert_managed_block_at_top_level(user_text: str, managed_block: str) -> str: + """Insert Hermes' managed Codex TOML block while keeping root keys root-scoped. + + TOML has no syntax to return to the document root after a table header. + Therefore appending a root key like `default_permissions = ...` after a + user table such as `[features]` actually creates `features.default_permissions`, + which Codex rejects. Insert the managed block before the first table header + so its root keys remain top-level, while preserving user content verbatim. + """ + if not user_text.strip(): + return managed_block + + lines = user_text.splitlines(keepends=True) + first_table_idx: Optional[int] = None + for idx, line in enumerate(lines): + stripped = line.lstrip() + if stripped.startswith("["): + first_table_idx = idx + break + + if first_table_idx is None: + prefix = user_text.rstrip("\n") + return f"{prefix}\n\n{managed_block}" if prefix else managed_block + + prefix = "".join(lines[:first_table_idx]).rstrip("\n") + suffix = "".join(lines[first_table_idx:]).lstrip("\n") + if prefix: + return f"{prefix}\n\n{managed_block}\n{suffix}" + return f"{managed_block}\n{suffix}" + + def _strip_existing_managed_block(toml_text: str) -> str: """Remove any prior managed section so re-runs idempotently replace it. @@ -571,14 +602,7 @@ def migrate( report.errors.append(f"could not read {target}: {exc}") return report without_managed = _strip_existing_managed_block(existing) - # Ensure exactly one blank line between user content and managed block - if without_managed and not without_managed.endswith("\n"): - without_managed += "\n" - new_text = ( - without_managed.rstrip("\n") + "\n\n" + managed_block - if without_managed.strip() - else managed_block - ) + new_text = _insert_managed_block_at_top_level(without_managed, managed_block) else: new_text = managed_block diff --git a/tests/hermes_cli/test_codex_runtime_plugin_migration.py b/tests/hermes_cli/test_codex_runtime_plugin_migration.py index b2e27f8c97b..c283a668681 100644 --- a/tests/hermes_cli/test_codex_runtime_plugin_migration.py +++ b/tests/hermes_cli/test_codex_runtime_plugin_migration.py @@ -567,10 +567,31 @@ class TestMigrate: assert "[model]" in new_text assert 'profile = "default"' in new_text assert "[providers.openai]" in new_text - # And new MCP block appended + # And new MCP block inserted without breaking user tables assert "[mcp_servers.a]" in new_text assert MIGRATION_MARKER in new_text + def test_managed_root_keys_stay_top_level_when_config_ends_in_table(self, tmp_path): + """TOML has no explicit 'leave current table' syntax. If Hermes appends + root keys like default_permissions after a user table such as [features], + Codex parses them as features.default_permissions and rejects the config. + The managed block must therefore be inserted before the first table.""" + import tomllib + + target = tmp_path / "config.toml" + target.write_text( + 'model = "gpt-5.5"\n' + "\n" + "[features]\n" + "terminal_resize_reflow = true\n" + ) + migrate({}, codex_home=tmp_path, discover_plugins=False, expose_hermes_tools=False) + new_text = target.read_text() + parsed = tomllib.loads(new_text) + assert parsed["default_permissions"] == ":workspace" + assert "default_permissions" not in parsed["features"] + assert new_text.index(MIGRATION_MARKER) < new_text.index("[features]") + def test_preserves_user_mcp_server_outside_managed_block(self, tmp_path): """Quirk #6: when a user adds their own MCP server entry directly to ~/.codex/config.toml outside Hermes' managed block, re-running