Skip to content

fix(config): strip unknown top-level keys from config file on load - #2974

Merged
TimeToBuildBob merged 3 commits into
masterfrom
fix-2969
Jun 22, 2026
Merged

TimeToBuildBob merged 3 commits into
masterfrom
fix-2969

Conversation

@TimeToBuildBob

Copy link
Copy Markdown
Member

Summary

  • load_user_config() now strips unknown top-level keys from disk after warning about them, so the warning fires only once instead of on every invocation
  • Adds _KNOWN_CONFIG_KEYS constant listing the 9 recognised top-level sections
  • Adds _strip_unknown_config_keys() helper that rewrites the file with tomlkit (preserving TOML formatting and comments for known keys)
  • Strips from both config.toml and config.local.toml when the latter exists
  • 4 new regression tests: basic stripping, known-key preservation, local-config stripping, [plugin.*] section survival

Fixes #2969.

Test plan

  • uv run pytest tests/test_config.py — 70 tests pass
  • Manually contaminate ~/.config/gptme/config.toml with an unknown key, run gptme-util tools list — warning fires once, key is gone on next invocation

When load_user_config() encounters foreign keys (from fuzzing artifacts,
server PUT, or external tooling), it now removes them from disk in addition
to warning. This means the warning fires once and then the file is clean,
satisfying the acceptance criteria of #2969.

- Add _KNOWN_CONFIG_KEYS constant (the 9 recognised top-level sections)
- Add _strip_unknown_config_keys() helper that rewrites the file without
  the offending keys, using tomlkit to preserve formatting of everything else
- Update the warning message to say "stripping from <path>" so users know
  the cleanup is automatic
- Apply stripping to both main config.toml and config.local.toml when a
  local override file exists
- Add 4 regression tests covering: basic stripping, known-key preservation,
  local-config stripping, and [plugin.*] section survival

Closes #2969
@greptile-apps

greptile-apps Bot commented Jun 22, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a repeated-warning annoyance (#2969) by writing the auto-detected unknown top-level config keys back to disk on the first load, so subsequent invocations produce no warning. It also addresses two follow-up issues from prior review rounds: the write is now wrapped in try/except OSError, and the warning message names both files when config.local.toml exists.

  • _strip_unknown_config_keys reads the config file with tomlkit (preserving formatting), deletes identified unknown keys, and writes back; an OSError (read-only mount, permission denied) degrades to a logger.warning rather than crashing load_user_config.
  • Unknown keys are derived from the merged (main + local) in-memory dict after all pop() calls and are applied independently to each file on disk, with the if key in doc guard preventing no-op writes.
  • Four regression tests cover key stripping, known-key preservation, local-config stripping, and [plugin.*] survival.

Confidence Score: 5/5

Safe to merge — the stripping logic is correct for both single-file and merged-config scenarios, and the OSError guard makes write failures survivable.

The detection and stripping logic correctly operates on individually-parsed files using the unknown-key set derived from the merged in-memory dict, with if key in doc preventing spurious deletions. The OSError guard addresses the previously reported crash path. The one remaining edge case — tomlkit.dump raising a non-OSError exception after file truncation — is extremely unlikely given that the document was just successfully loaded by tomlkit. Four regression tests validate the key behaviours.

No files require special attention; both changed files are straightforward and well-covered by tests.

Important Files Changed

Filename Overview
gptme/config/user.py Adds _strip_unknown_config_keys helper and wires it into load_user_config; OSError is caught, warning message names both paths when local config exists — logic is correct, one minor truncation-before-write edge case noted.
tests/test_config.py Four new regression tests covering stripping, known-key preservation, local-config stripping, and plugin-section survival — all well-structured with meaningful assertions.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[load_user_config called] --> B[_load_config_doc: read config.toml]
    B --> C{config.local.toml exists?}
    C -- yes --> D[Load & merge config.local.toml]
    C -- no --> E[Use main config only]
    D --> F[pop all known keys from merged dict]
    E --> F
    F --> G{Any keys remain?}
    G -- no --> H[Build & return UserConfig]
    G -- yes --> I[Log warning with strip_targets]
    I --> J[_strip_unknown_config_keys: config.toml]
    J --> K{OSError?}
    K -- yes --> L[Log warning, skip write]
    K -- no --> M[Write cleaned file]
    M --> N{has_local?}
    L --> N
    N -- yes --> O[_strip_unknown_config_keys: config.local.toml]
    O --> H
    N -- no --> H
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A[load_user_config called] --> B[_load_config_doc: read config.toml]
    B --> C{config.local.toml exists?}
    C -- yes --> D[Load & merge config.local.toml]
    C -- no --> E[Use main config only]
    D --> F[pop all known keys from merged dict]
    E --> F
    F --> G{Any keys remain?}
    G -- no --> H[Build & return UserConfig]
    G -- yes --> I[Log warning with strip_targets]
    I --> J[_strip_unknown_config_keys: config.toml]
    J --> K{OSError?}
    K -- yes --> L[Log warning, skip write]
    K -- no --> M[Write cleaned file]
    M --> N{has_local?}
    L --> N
    N -- yes --> O[_strip_unknown_config_keys: config.local.toml]
    O --> H
    N -- no --> H
Loading

Reviews (3): Last reviewed commit: "fix(config): catch OSError when strippin..." | Re-trigger Greptile

Comment thread gptme/config/user.py Outdated
Comment on lines +57 to +70
# Top-level keys recognised by load_user_config(). Anything else is foreign.
_KNOWN_CONFIG_KEYS: frozenset[str] = frozenset(
{
"prompt",
"user",
"env",
"mcp",
"providers",
"models",
"lessons",
"plugins",
"plugin",
}
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 _KNOWN_CONFIG_KEYS is never referenced anywhere in the code — it is dead code. The unknown-key detection works through the pop() pattern in load_user_config, and _strip_unknown_config_keys receives the already-computed set of unknowns from the caller. Because the constant is not used to drive any logic, it creates a maintenance trap: if a future contributor adds a new section via config.pop(…) but forgets to update this set (or vice versa), nothing breaks at runtime but the constant silently misrepresents the truth. Either wire the constant into the detection logic or remove it.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 26c6706: removed the dead _KNOWN_CONFIG_KEYS constant entirely. The detection logic via config.pop() is the single source of truth — no separate constant to drift.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 26c6706 — _KNOWN_CONFIG_KEYS was removed.

Comment thread gptme/config/user.py
Comment on lines +308 to +314
logger.warning(
f"Unknown keys in config: {sorted(unknown)} — stripping from"
f" {path_with_tilde(config_file)}"
)
_strip_unknown_config_keys(str(config_file), unknown)
if has_local:
_strip_unknown_config_keys(str(local_path), unknown)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 The warning message names only the main config file, but the code also strips from config.local.toml when it exists. A user whose unknown key lives exclusively in config.local.toml will see "stripping from config.toml" in the log and look in the wrong place. The message should mention both paths when the local file is also being modified.

Suggested change
logger.warning(
f"Unknown keys in config: {sorted(unknown)} — stripping from"
f" {path_with_tilde(config_file)}"
)
_strip_unknown_config_keys(str(config_file), unknown)
if has_local:
_strip_unknown_config_keys(str(local_path), unknown)
strip_targets = str(path_with_tilde(config_file))
if has_local:
strip_targets += f" and {path_with_tilde(local_path)}"
logger.warning(
f"Unknown keys in config: {sorted(unknown)} — stripping from"
f" {strip_targets}"
)
_strip_unknown_config_keys(str(config_file), unknown)
if has_local:
_strip_unknown_config_keys(str(local_path), unknown)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 26c6706: warning now builds strip_targets mentioning both paths when has_local is true, matching the suggestion.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 26c6706 — warning now includes both paths when config.local.toml is also modified.

@codecov

codecov Bot commented Jun 22, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 86.95652% with 3 lines in your changes missing coverage. Please review.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
gptme/config/user.py 86.95% 3 Missing ⚠️

📢 Thoughts on this report? Let us know!

@TimeToBuildBob

Copy link
Copy Markdown
Member Author

@greptileai review

Comment thread gptme/config/user.py Outdated
Comment on lines +71 to +73
if changed:
with open(path, "w") as f:
tomlkit.dump(doc, f)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Write failure turns a warning into a crash

_strip_unknown_config_keys has no exception handling around the file write. If config.toml is read-only (e.g., bind-mounted read-only in a container, owned by root, or placed in a read-only filesystem), open(path, "w") will raise PermissionError and propagate all the way out of load_user_config. Before this PR, encountering unknown keys was survivable — only a warning was logged. After this PR, the same situation causes a hard crash. Wrapping the write in a try/except OSError and logging a warning instead would preserve the original resilience.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in f9227a5 — wrapped the write in try/except OSError so a read-only config file degrades to a warning instead of crashing load_user_config.

If config.toml is read-only (e.g. bind-mounted in a container), the
open() call would raise PermissionError and crash load_user_config.
Before this PR, unknown keys only logged a warning — wrap the write in
try/except OSError to preserve that resilience.
@TimeToBuildBob

Copy link
Copy Markdown
Member Author

@greptileai review

@TimeToBuildBob
TimeToBuildBob merged commit 0da7af2 into master Jun 22, 2026
14 checks passed
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.

User config.toml accumulates foreign keys (warnings on every invocation)

1 participant