Skip to content

fix(coder-labs/modules/codex): deep-merge config.toml on restart instead of overwriting#896

Open
35C4n0r wants to merge 21 commits into
mainfrom
35C4n0r/idempotent-codex-config
Open

fix(coder-labs/modules/codex): deep-merge config.toml on restart instead of overwriting#896
35C4n0r wants to merge 21 commits into
mainfrom
35C4n0r/idempotent-codex-config

Conversation

@35C4n0r
Copy link
Copy Markdown
Collaborator

@35C4n0r 35C4n0r commented May 20, 2026

Problem

populate_config_toml unconditionally overwrote ~/.codex/config.toml on every workspace start, wiping any user edits (custom MCP servers, auth preferences, project trust entries, hand-tuned settings).

Solution

Replace the destructive overwrite with a deep-merge strategy. All config is built as JSON using jq, merged with jq -s '.[0] * .[1]' (recursive object merge), and converted to TOML via a single dasel -i json -o toml call at the end.

The user's existing on-disk config always takes precedence. Any value a user has set or changed in ~/.codex/config.toml will never be overwritten by the module. New keys from the module are added; existing keys are left untouched. This applies at every nesting level (top-level scalars, nested maps like mcp_servers, projects, model_providers).

Precedence chain (highest to lowest)

  1. Existing on-disk config (~/.codex/config.toml): always wins
  2. User's base_config_toml (if provided): wins over module defaults
  3. Module defaults (MCP servers, AI gateway config): fill in missing keys only

How it works

  1. install_dasel() (new): downloads dasel v3.4.0 for the current OS/arch.
  2. jq availability check (new): errors out with a clear message if jq is not installed, consistent with how other registry modules handle hard dependencies.
  3. build_minimal_default_config() (updated): builds the default config as a JSON object using jq and prints it to stdout. Conditionally includes model_provider, model_reasoning_effort, and projects trust entries.
  4. populate_config_toml() (rewritten): assembles the full config in JSON:
    • Starts from either the user's base_config_toml (converted via dasel -i toml -o json) or the output of build_minimal_default_config.
    • Merges MCP servers and AI gateway config as JSON (module defaults on the left, user config on the right, so user values win).
    • Deep-merges with the existing on-disk config (existing values always win).
    • Writes atomically via a temp file and mv.
    • If the existing config contains invalid TOML, errors out instead of silently proceeding.

Changes

File What changed
scripts/install.sh.tftpl Added install_dasel, jq check; rewrote config building to use jq for JSON construction and merging; atomic writes via temp file; graceful error on corrupted config; fixed all $$/$ tftpl escaping
main.test.ts 9 new integration tests for idempotency and deep-merge; updated existing assertions to handle dasel single-quote TOML output; strengthened value assertions
README.md Version bump to 5.0.1

New tests

Test Scenario
idempotent-defaults-preserve-user-edits User changes auth method + adds custom key; both survive restart
idempotent-mcp-deep-merge User customizes one MCP server; change preserved, other servers kept with original values
idempotent-base-config-preserves-user-edits User changes a base-config value; survives restart
idempotent-stable-after-roundtrip Three runs with no edits; runs 2 and 3 produce byte-identical config
idempotent-mcp-new-servers-added-existing-kept User manually adds MCP server; survives alongside module servers
idempotent-ai-gateway-preserves-user-provider User changes model_provider; survives AI gateway restart
base-config-plus-mcp-combined Base config + MCP servers both present after first run
all-config-sources-combined Base config + MCP + AI gateway all merged correctly
idempotent-all-sources-user-edits-survive All three sources + user edits auth + adds key; everything survives

Generated by Coder Agents on behalf of @35C4n0r

@35C4n0r 35C4n0r closed this May 20, 2026
@35C4n0r 35C4n0r force-pushed the 35C4n0r/idempotent-codex-config branch from 9be9f74 to f980245 Compare May 20, 2026 16:53
@35C4n0r 35C4n0r reopened this May 24, 2026
@35C4n0r 35C4n0r self-assigned this May 24, 2026
@35C4n0r 35C4n0r changed the title fix(coder-labs/modules/codex): make config.toml writes idempotent fix(coder-labs/modules/codex): deep-merge config.toml on restart instead of overwriting May 24, 2026
35C4n0r and others added 3 commits May 24, 2026 18:44
After a dasel roundtrip, TOML values use single quotes instead of
double quotes. Update the codex-with-ai-gateway and
ai-gateway-with-custom-base-config tests to use regex matching that
accepts either quote style.

Also fix idempotent-run-twice-no-change to read the config file
directly from the container instead of piping TOML strings through
shell echo (which breaks on single quotes).
The idempotent-run-twice-no-change test was calling dasel in a
separate execContainer shell where the PATH export from the install
script is not available. Instead, compare the raw config output
after runs 2 and 3 (both post-roundtrip, so serialization is
stable and byte-comparison is valid).
@35C4n0r

This comment has been minimized.

@35C4n0r

This comment has been minimized.

35C4n0r added 2 commits May 25, 2026 02:22
…le dasel conversion

Replace TOML string concatenation with jq-native JSON building:

- Extract write_minimal_default_config() back as its own function,
  now returning JSON on stdout via jq.
- populate_config_toml() assembles all config sources as JSON,
  deep-merges with jq, and does a single dasel JSON-to-TOML
  conversion at the end.
- Remove merge_toml_config() and all TOML string building.
- Update test assertions to accept either quote style since all
  output now goes through dasel.
@35C4n0r 35C4n0r marked this pull request as ready for review May 25, 2026 02:37
@35C4n0r

This comment has been minimized.

1 similar comment
@matifali
Copy link
Copy Markdown
Member

/coder-agents-review

Script fixes:
- Rename write_minimal_default_config to build_minimal_default_config
  (no longer writes to disk, emits JSON to stdout).
- Guard corrupted existing config: if dasel cannot parse the
  existing TOML, error out and exit instead of silently proceeding.
- Atomic config write: write to a temp file and mv, preventing
  data loss if the process is interrupted mid-write.
- Add jq availability check before populate_config_toml, consistent
  with how other registry modules handle hard dependencies.
- Normalize blank lines between function definitions.

Test fixes:
- idempotent-mcp-deep-merge: use sed address range to only replace
  the github server command, assert filesystem command is still npx.
- workdir-trusted-project: tighten regex to require bracket syntax
  instead of matching any line containing the path.
- Rename idempotent-run-twice-no-change to
  idempotent-stable-after-roundtrip (test runs 3 times, not 2).
- Remove unnecessary regex escaping of forward slashes.
- Strengthen combination test assertions to check values, not just
  key presence.
if [ -n "$${ARG_BASE_CONFIG_TOML}" ]; then
printf "Using provided base configuration\n"
echo "$${ARG_BASE_CONFIG_TOML}" > "$${config_path}"
new_json=$(echo "$${ARG_BASE_CONFIG_TOML}" | dasel -i toml -o json)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why are we mixing json and toml?

Copy link
Copy Markdown
Collaborator Author

@35C4n0r 35C4n0r May 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dasel does not support deep merge of toml, so I came up with the idea that all the internal processing will be done in json (jq ftw), the final output will be written in toml. dasel only does 2 things 1. convert any toml to json for internal manipulation, 2. write the final json output as toml.

else
printf "AI Gateway provider already defined in config, skipping append\n"
printf "Adding AI Gateway configuration\n"
local aibridge_json
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we do not use aibridge anymore. rename to

Suggested change
local aibridge_json
local ai_gateway_json

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@matifali, is it okay if we release it in a new patch ? The readme already seems to be using ai_gateway for user facing variables, I'll do internal refactor in a new PR.

Copy link
Copy Markdown
Member

@matifali matifali left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why are we doing toml to json to toml? a few real costs with this:

  • comments in ~/.codex/config.toml get stripped on every restart (dasel can't preserve them).
  • two new binary deps (dasel + jq), and the dasel download isn't checksum-verified.

two cleaner options:

marker block: module owns a fenced region, user owns everything outside.

# >>> coder-managed: codex module >>>
preferred_auth_method = "apikey"
[mcp_servers.github]
command = "npx"
# <<< coder-managed: codex module <<<

sed strips and re-emits the block on each run. no deps, comments preserved, byte-stable. overrides go through terraform variables, which matches the rest of the registry.

yq (mikefarah) native toml: if we keep merging, yq -p toml -o toml eval-all '. as $i ireduce ({}; . *+ $i)' a.toml b.toml is the same thing in one binary instead of two. still loses comments but a strict win over dasel + jq.

prefer marker block. happy to be wrong if there's a reason.

@35C4n0r
Copy link
Copy Markdown
Collaborator Author

35C4n0r commented May 25, 2026

yq dosen't seem to work.

@35C4n0r
Copy link
Copy Markdown
Collaborator Author

35C4n0r commented May 25, 2026

as for the block marker, everything written by the module will go inside the block marker (this includes the mcps provided by the user and the base_toml or user_provided_toml).
user/codex will append outside of it.
also to be noted that if the user/codex modifies the section in the block marker, it will be overwritten on the next restart.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants