Repository navigation
feat(spec): update to toml spec v1.1 - #456
Merged
Merged
Conversation
Signed-off-by: Frost Ming <me@frostming.com>
Signed-off-by: Frost Ming <me@frostming.com>
blitzy Bot
pushed a commit
to blitzy-research/tomlkit
that referenced
this pull request
Jul 16, 2026
…A Issue #1) and document ConversionError.key_path (QA Issue #2) Issue #1 (MAJOR): The four tomlkit.convert functions annotate params/returns with the bare name TOMLDocument, which was registered in two py-domain locations (the 'Public API' automodule:: tomlkit and the dedicated 'TOML Document' autoclass), causing 8 'more than one target found for cross-reference TOMLDocument' warnings that fail the fail_on_warning docs build (AAP §0.6.3). Exclude TOMLDocument from the automodule:: tomlkit directive so it is documented once, canonically, in its dedicated section; the bare annotation now resolves unambiguously. Convert warnings 8 -> 0. Issue #2 (INFO): Add an :ivar key_path: field to the ConversionError docstring so the key_path attribute contract (AAP R5) is documented in the class prose, not only via the constructor signature. Out-of-scope (AAP §0.6.2, documented not fixed): api.py register_encoder RST warnings; CHANGELOG python-poetry#456 parser bullet paren. Verified: RTD Sphinx 7.4.7 build -> 0 tomlkit.convert warnings; ConversionError renders key_path; full test suite 964 passed (no regressions).
Merged
16 of 21 tasks
JerrettDavis
added a commit
to headroomlabs-ai/headroom
that referenced
this pull request
Oct 29, 2026
## Description
Codex accepts an inline table split across lines, with a trailing comma,
in `~/.codex/config.toml`:
```toml
env = {
DINGTALK_Client_ID = "**********",
}
```
That is TOML 1.1 syntax. Headroom reads the Codex config with the
standard library `tomllib`, which only implements TOML 1.0 before Python
3.15, so it rejects the file with `Invalid initial character for a key
part`. The result is what the issue shows: `headroom wrap codex` and
`headroom mcp install` refuse to register MCP servers ("is not valid
TOML ... refusing to overwrite") and session routing fails with "could
not read Codex config for session routing", even though Codex itself is
happy with the file.
`tomlkit` is already a runtime dependency (used for Codex config
recovery) and parses this syntax from 0.15.0. 0.13.3 and 0.14.0 both
fail on it (`Empty key at line 1 col 7`).
The fix adds one helper, `loads_codex_config()`, that parses with
`tomllib` first and only on `TOMLDecodeError` retries with `tomlkit`. If
`tomlkit` rejects the text too, it re-raises the original
`tomllib.TOMLDecodeError`, so every caller keeps its existing `except
tomllib.TOMLDecodeError` handling and its error messages unchanged. All
places that parse the user's Codex config now go through it.
The follow-up commit (`dc193870`) fixes a problem the first commit
exposed: once a multi-line inline table parses, a `profiles = {` table
can carry per-profile `model_provider` / `openai_base_url` keys on their
own indented lines, and the line-based "root key" edits in init, the
provider-scope install and persistent wrap mistook those nested lines
for root assignments. Init and install deleted the profile's provider,
and wrap rewrote it to `headroom` and then skipped adding a real root
`model_provider`. Those edits now only touch lines that are actual root
assignments.
Fixes #4378
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)
## Changes Made
- `headroom/providers/codex/config_toml.py` (new):
`loads_codex_config(content)`, `tomllib` first, `tomlkit` fallback,
original `tomllib` error if both fail.
- Routed the Codex config parse sites through it (15 call sites, 16
parse calls):
- `headroom/cli/wrap.py`: session routing read, the user-content check
and its fallback in `_inject_codex_provider_config`, and the two "never
write a malformed candidate" checks.
- `headroom/cli/init.py`: provider snapshot check, the candidate check
in `_ensure_codex_provider`, and `_codex_features`.
- `headroom/providers/codex/install.py`: `_codex_credential_store`,
`codex_auth_helper_is_referenced`, and the merged-config check before
writing.
- `headroom/providers/codex/project_context.py`: the `sqlite_home`
lookup.
- `headroom/mcp_registry/codex.py`: `_load_toml`, `_unmergeable_reason`,
and both sides of `_only_server_changed`. This registrar class is shared
with Grok, so a Grok `config.toml` with the same syntax is accepted too.
- Left on `tomllib` on purpose: `_table_path` (parses a single `[table]`
header line) and the auth snippet check in `build_codex_auth_config`
(validates text Headroom generates itself).
- `pyproject.toml` / `uv.lock`: raise the `tomlkit` minimum from
`>=0.13.0` to `>=0.15.0`, the first release that parses multi-line
inline tables. The lock already resolved 0.15.0, so `uv lock` only
changed the specifier line.
- Write paths: the user's text is never re-serialized by this change.
The registrar and the wrap/init injectors still append or splice marker
blocks as text, and the existing `tomlkit` round-trip in wrap keeps the
multi-line table byte for byte (checked below).
- Tests:
- `tests/test_provider_codex_config_toml.py`: the helper on the exact
shape from the issue (multi-line, trailing comma, whitespace-only line,
indented `}`), and that invalid TOML still raises
`tomllib.TOMLDecodeError`.
-
`tests/test_mcp_registry/test_codex_registrar.py::test_register_accepts_multiline_inline_table`:
registering into that config succeeds and the user's text is kept as a
byte-for-byte prefix.
-
`tests/test_cli/test_wrap_codex.py::test_codex_session_launch_settings_accept_multiline_inline_table`:
session routing reads that config and leaves the file untouched.
- Follow-up commit `dc193870` (multi-line inline profile overrides):
- `headroom/providers/codex/config_toml.py`:
`root_assignment_lines(content)` returns the indexes of lines that start
a key/value pair at the document root. It stops at the first table
header and skips lines inside a multi-line inline table, array or
string, with a small scanner that tracks quotes, comments and bracket
depth.
- `headroom/cli/init.py` (`_ensure_codex_provider`) and
`headroom/providers/codex/install.py`
(`_strip_root_provider_assignments`) only drop `model_provider` /
`openai_base_url` lines that are root assignments.
- `headroom/cli/wrap.py`: `_redirect_existing_top_level_keys` and
`_has_redirectable_top_level_key` only look at root assignments. Before,
they matched the key on any line, so a `[profiles.work]` section
override was also rewritten to `headroom` and counted as a root key;
that now stays the user's, matching how init, install and
`_strip_codex_headroom_blocks` already treat `[profiles.*]`.
- Tests:
`test_root_assignment_lines_skip_keys_nested_in_multiline_inline_table`,
`test_ensure_codex_provider_preserves_multiline_inline_profile_overrides`
(init),
`test_provider_scope_preserves_multiline_inline_profile_overrides`
(install) and `test_inject_preserves_multiline_inline_profile_overrides`
(wrap).
## Testing
- [x] Unit tests pass (`pytest`) - Codex, MCP registry, init and Grok
suites: 774 passed, 2 failed (the 2 failures are in
`tests/test_codex_responses_passthrough_bytes.py` and fail the same way
on `main` without this change)
- [x] Linting passes (`ruff check .`) - `ruff check` and `ruff format
--check` on the 9 changed Python files
- [x] Type checking passes (`mypy headroom`) - `mypy` on the 6 changed
source files, no issues
- [x] New tests added for new functionality
- [x] Manual testing performed
### Test Output
New end-to-end tests on `main` (fix stashed):
```text
$ pytest tests/test_mcp_registry/test_codex_registrar.py::test_register_accepts_multiline_inline_table tests/test_cli/test_wrap_codex.py::test_codex_session_launch_settings_accept_multiline_inline_table -q
E AssertionError: .../.codex/config.toml is not valid TOML (Invalid initial character for a key part (at line 3, column 8)); refusing to overwrite. Fix or remove the file, then re-run.
E click.exceptions.ClickException: could not read Codex config for session routing: Invalid initial character for a key part (at line 5, column 8)
============================== 2 failed in 0.38s ===============================
```
(`tests/test_provider_codex_config_toml.py` cannot import on `main`
because the helper does not exist yet.)
With the fix:
```text
$ pytest tests/test_provider_codex_config_toml.py tests/test_mcp_registry/test_codex_registrar.py::test_register_accepts_multiline_inline_table tests/test_cli/test_wrap_codex.py::test_codex_session_launch_settings_accept_multiline_inline_table -q
============================== 4 passed in 0.56s ===============================
$ pytest tests/test_provider_codex_config_toml.py tests/test_mcp_registry tests/test_cli/test_wrap_codex.py tests/test_cli/test_init_cli.py tests/test_install/test_codex_install.py tests/test_provider_codex*.py tests/test_provider_grok*.py tests/test_codex*.py -q
FAILED tests/test_codex_responses_passthrough_bytes.py::test_unmutated_zstd_post_forwards_decoded_bytes_and_strips_content_encoding - concurrent.futures._base.CancelledError
FAILED tests/test_codex_responses_passthrough_bytes.py::test_unmutated_plain_post_passes_original_bytes_through - concurrent.futures._base.CancelledError
================== 2 failed, 774 passed, 8 warnings in 50.43s ==================
$ pytest tests/test_codex_responses_passthrough_bytes.py -q # on main, without this change
========================= 2 failed, 1 warning in 4.09s =========================
$ mypy headroom/cli/init.py headroom/cli/wrap.py headroom/mcp_registry/codex.py headroom/providers/codex/install.py headroom/providers/codex/project_context.py headroom/providers/codex/config_toml.py
Success: no issues found in 6 source files
```
Follow-up commit, the three new write-path tests on the first commit
(`dfe93720`, source changes reverted):
```text
$ pytest tests/test_cli/test_init_cli.py tests/test_install/test_codex_install.py tests/test_cli/test_wrap_codex.py -k multiline_inline_profile -q
FAILED tests/test_cli/test_init_cli.py::test_ensure_codex_provider_preserves_multiline_inline_profile_overrides - AssertionError: assert {} == {'model_provi...y.example/v1'}
FAILED tests/test_install/test_codex_install.py::test_provider_scope_preserves_multiline_inline_profile_overrides - AssertionError: assert {} == {'model_provider': 'company'}
FAILED tests/test_cli/test_wrap_codex.py::TestSubscriptionRouting::test_inject_preserves_multiline_inline_profile_overrides - KeyError: 'model_provider'
====================== 3 failed, 220 deselected in 0.56s =======================
```
With `dc193870`:
```text
$ pytest tests/test_provider_codex_config_toml.py tests/test_cli/test_init_cli.py tests/test_install/test_codex_install.py tests/test_cli/test_wrap_codex.py -k "multiline_inline_profile or root_assignment" -q
======================= 4 passed, 222 deselected in 0.27s =======================
$ pytest tests/test_provider_codex_config_toml.py tests/test_cli/test_init_cli.py tests/test_install/ -q
================== 346 passed, 13 skipped, 1 warning in 3.94s ==================
$ pytest tests/test_cli/test_wrap_codex.py tests/test_cli/test_port_alignment.py -q
============================= 167 passed in 6.01s ==============================
$ pytest tests/test_mcp_registry/ tests/test_provider_codex_install.py tests/test_cli/test_install_cli.py tests/test_cli/test_recover_codex.py -q
============================= 364 passed in 4.71s ==============================
$ pytest tests/test_cli/test_wrap_persistent.py tests/test_codex_project_context.py tests/test_cli/test_wrap_stale_marker.py tests/test_cli/test_wrap_dead_marker_selfheal.py tests/test_provider_codex_runtime.py tests/test_install_tool_env_reconcile.py -q
======================== 4 failed, 261 passed in 9.01s =========================
```
The 4 failures are in `tests/test_cli/test_wrap_persistent.py`
(`AttributeError: '_Manifest' object has no attribute 'runtime_kind'`)
and fail the same way with this PR's changes stashed. `ruff check`,
`ruff format --check` (8 files) and `mypy` on the 4 changed source files
are clean.
tomlkit versions on the issue's snippet:
```text
0.13.3 FAIL Empty key at line 1 col 7
0.14.0 FAIL Empty key at line 1 col 7
0.15.0 {'env': {'A': 'x'}}
```
## Real Behavior Proof
- Environment: macOS 26.2 (Apple Silicon), Python 3.14.7, repo venv with
the `mcp` package installed, a throwaway `HOME`/`CODEX_HOME` whose
`config.toml` holds the issue's config:
```toml
[mcp_servers.dingtalk]
command = "npx"
env = {
DINGTALK_Client_ID = "**********",
}
```
- Exact command / steps: `HOME=<tmp>/home CODEX_HOME=<tmp>/home/.codex
python -m headroom.cli mcp install --agent codex`, then compare the
start of the resulting file with the original bytes and count
`[mcp_servers.headroom]` tables. Also called
`wrap._inject_codex_provider_config(8787)` and
`init._ensure_codex_provider(path, 8787)` on the same config to check
the write paths.
- Observed result: before (`main`):
```text
codex: install failed (failed): <tmp>/home/.codex/config.toml is not
valid TOML (Invalid initial character for a key part (at line 3, column
8)); refusing to overwrite. Fix or remove the file, then re-run.
```
After:
```text
codex: registered (restart the agent if it was already running)
```
The user's original bytes are an exact prefix of the new file and one
`[mcp_servers.headroom]` table was appended. The wrap injector printed
`Codex config: injected Headroom provider (WS + HTTP)`, and in both the
wrap and init outputs the user's multi-line table is still present
verbatim, the file parses, `env` is `{'DINGTALK_Client_ID':
'**********'}` and `model_provider` is `headroom`.
- Follow-up commit: with a throwaway `HOME`/`CODEX_HOME` holding
```toml
model_provider = "openai"
profiles = {
work = {
model_provider = "company",
},
}
```
I ran `init._ensure_codex_provider`, `install.apply_provider_scope`
(provider scope) and `wrap._inject_codex_provider_config` (same config
without the root key) and parsed the result:
```text
== before (dfe9372)
init: root = headroom work = {}
install: root = headroom work = {}
wrap: root = None work = {'model_provider': 'headroom'}
== after (dc19387)
init: root = headroom work = {'model_provider': 'company'}
install: root = headroom work = {'model_provider': 'company'}
wrap: root = headroom work = {'model_provider': 'company'}
```
- Not tested: a full `headroom wrap codex` run with a live proxy and the
real Codex CLI, and Python 3.10 (where `tomli` is the first parser; the
`tomlkit` fallback is the same there).
## Runtime Rollout Safety
- Rollout-managed feature(s): none
- Minimum rollout channel: n/a
- Stable/default behavior changed: only for Codex configs `tomllib`
rejects but `tomlkit` 0.15 accepts (TOML 1.1 multi-line inline tables).
Valid TOML 1.0 still goes through `tomllib` exactly as before, and text
both parsers reject fails with the same error as before.
- Kill switch / disable path: none needed; the fallback only runs after
`tomllib` has already failed.
- Unsafe override required: no
- Qualification impact: none
- Rollback path: revert the two commits
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
## Checklist
- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] I did **not** edit `CHANGELOG.md`, it is generated by
release-please from my Conventional Commit PR title (a CI guard enforces
this)
## Screenshots (if applicable)
N/A
## Additional Notes
- Documentation: no user-facing docs describe which TOML syntax the
Codex config may use, so nothing to update.
- Python 3.15's `tomllib` implements TOML 1.1 and parses this config
directly (as noted in the issue thread), so on 3.15 the fallback simply
never runs.
- `tomlkit` is a bit more permissive than `tomllib` only where TOML 1.1
is; it still rejects duplicate keys and malformed tables, so the
existing "refuse to overwrite an unparseable file" guards still trigger
for genuinely broken configs (covered by the existing registrar tests).
- Dependency review for the `tomlkit` minimum bump (`>=0.13.0,<1.0` to
`>=0.15.0,<1.0`):
- Why this package: it is already a direct runtime dependency (Codex
config recovery and the memory MCP block edit in `wrap.py`), so no new
package enters the tree. Writing a TOML 1.1 parser here is not worth it,
and `tomllib` only gains TOML 1.1 in Python 3.15.
- Who maintains it: the Poetry project (`python-poetry/tomlkit`, MIT),
with Frost Ming and Sébastien Eustace as the main maintainers. It is
actively released: 0.13.3 (2025-06), 0.14.0 (2026-01), 0.15.0 (2026-05),
0.15.1 (2026-07), 0.15.2 (2026-10). No GitHub security advisories are
published for the repo.
- Install surface: unchanged. Pure Python, a single `py3-none-any` wheel
(about 41 KB for 0.15.0), no runtime dependencies, no native code, no
install-time or runtime network access. `uv.lock` already resolved
0.15.0 before this PR, so the locked version and hashes do not change;
only the specifier line moves.
- Why this version: required new functionality. 0.15.0 is the first
release with TOML 1.1 support ("feat(spec): update to toml spec v1.1",
python-poetry/tomlkit#456), which is what parses multi-line inline
tables with trailing commas. 0.13.3 and 0.14.0 both reject the issue's
snippet (`Empty key at line 1 col 7`, output above). 0.15.0 also
includes an upstream parser hang fix (python-poetry/tomlkit#470).
---------
Co-authored-by: Abelo9996 <Abelo9996@users.noreply.github.com>
Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Signed-off-by: Frost Ming me@frostming.com