fix(agent): persist /model selection to .env, TOML, and DB - #1581
Conversation
The /model command only wrote selected_model to the DB and config.toml, but env vars from ~/.ironclaw/.env (e.g. NEARAI_MODEL) have the highest priority in LlmConfig::resolve_model(). The .env value was never updated, so it always shadowed the new model on restart. Now persist_selected_model updates all three persistence layers: 1. The backend-specific model env var in ~/.ironclaw/.env (only if the var already exists, to avoid injecting new vars) 2. The config.toml file (created if absent, since TOML > DB priority) 3. The DB settings table (for completeness) Also adds diagnostic logging when the DB store is unavailable. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
3b3442c to
0c4d497
Compare
| let env_path = crate::bootstrap::ironclaw_env_path(); | ||
| let env_has_var = std::fs::read_to_string(&env_path) | ||
| .ok() | ||
| .is_some_and(|content| { |
There was a problem hiding this comment.
High Severity — .env line detection uses starts_with without = delimiter
The check for whether the env var exists in .env uses:
line.trim_start().starts_with(model_env)Where model_env is e.g. "NEARAI_MODEL". This matches any line that starts with that prefix, including unrelated vars like NEARAI_MODEL_VERSION=foo.
Meanwhile, upsert_bootstrap_var correctly uses format!("{}=", key) as its prefix match. So the detection and update use different matching strategies — the detection could false-positive, causing the code to update the wrong var or trigger an update when it shouldn't.
Fix:
let prefix = format!("{model_env}=");
content.lines().any(|line| line.trim_start().starts_with(&prefix))There was a problem hiding this comment.
Fixed in d824875. Now uses format\!("{}=", model_env) prefix and skips #-commented lines, matching the same logic as upsert_bootstrap_var_to.
| if let Err(e) = tokio::task::spawn_blocking(move || { | ||
| // 2a. Update the backend-specific model env var in ~/.ironclaw/.env. | ||
| // | ||
| // Env vars have the HIGHEST priority in LlmConfig::resolve_model() |
There was a problem hiding this comment.
Medium Severity — concurrent /model commands could corrupt .env or TOML
The .env read-then-conditionally-write and the TOML load-then-write are not atomic. If two /model commands execute concurrently (e.g., from different channels — Telegram + web gateway), they could interleave reads/writes and corrupt either file.
Low likelihood for a single-user assistant, but worth noting since the agent supports multi-channel access. Consider flock-style advisory locking, or at minimum a comment documenting this as a known limitation.
There was a problem hiding this comment.
Acknowledged. This is a single-user assistant and concurrent /model commands are extremely unlikely. The TOML write is a full-file replace (atomic on most filesystems), and upsert_bootstrap_var does a read-modify-write that is also a single fs::write. Adding flock would add complexity disproportionate to the risk here.
| // update this var or the /model change is invisible on restart. | ||
| let registry = crate::llm::ProviderRegistry::load(); | ||
| let backend = std::env::var("LLM_BACKEND") | ||
| .ok() |
There was a problem hiding this comment.
Medium Severity — backend re-resolved from disk instead of using in-memory value
The closure re-resolves the LLM backend by reading LLM_BACKEND env var then re-loading config.toml from the default path. But the agent already has the resolved backend in memory (in its Config). This creates two issues:
- Redundant I/O —
ProviderRegistry::load()re-readsproviders.jsonfrom disk, andSettings::load_toml()re-reads the TOML. - Custom config path — if the user specified a non-default config path, this reads the wrong file (or no file), potentially resolving a different backend than the one actually in use.
Suggestion: Capture the already-resolved backend and model_env var before entering spawn_blocking, then move the strings into the closure:
let registry = crate::llm::ProviderRegistry::load();
let backend = self.config().llm.backend.clone(); // or however the agent exposes it
let model_env = registry.model_env_var(&backend).to_string();
let model_owned = model.to_string();
tokio::task::spawn_blocking(move || {
// use model_env directly, no re-resolution needed
...
})There was a problem hiding this comment.
Fixed in d824875. Backend is now read from AgentDeps.llm_backend — the already-resolved value from startup (env > DB > TOML > default). This eliminates the redundant ProviderRegistry::load() and Settings::load_toml() calls, and correctly handles custom --config paths and DB-only backends.
| Ok(None) => { | ||
| // No config file on disk; nothing to update. | ||
| // No config file yet — create one so the model choice | ||
| // survives restarts even when the DB is unavailable. |
There was a problem hiding this comment.
Medium Severity — newly created config.toml from Default::default() may leak non-default values
When no config.toml exists, this creates one with Settings { selected_model: Some(...), ..Default::default() }. On next startup, merge_from overlays the TOML onto DB settings.
merge_from skips fields that match Default::default(), so this is probably safe. But if any `Settings` field has a non-trivial default (e.g., `gateway_enabled` defaults to `true` via `default_true`), the newly created TOML could unintentionally override DB settings for those fields.
The existing test `toml_created_when_missing_for_model_persist` only verifies `selected_model` round-trips — it doesn't verify that other fields in the created TOML don't interfere with `merge_from`.
Suggestion: Add a test that creates TOML via this code path, then `merge_from`s it onto a Settings with different values, and asserts the non-model fields are unchanged.
There was a problem hiding this comment.
This is safe by construction. merge_non_default compares each field against Default::default() and only copies values that differ. The created TOML has Settings { selected_model: Some("..."), ..Default::default() }, so every field except selected_model matches the default and is skipped during merge. Fields with non-trivial defaults (e.g. default_true()) are equal to Default::default() by definition, so they are also skipped.
Overall ReviewGood fix for a real and subtle persistence bug — the root cause analysis ( One real bug: The Three design considerations:
Tests are good — the regression tests clearly document the priority model (env > TOML > DB) and verify the round-trip behavior. Missing: a test for the |
zmanian
left a comment
There was a problem hiding this comment.
Code Review — persist /model selection to .env, TOML, and DB
+164 / -3 across 2 files. Fixes the root cause where .env model var shadowed DB/TOML on restart. Good test coverage of the priority chain.
Issues
1. .env line matching is prefix-only (high — already flagged)
line.trim_start().starts_with(model_env) (commands.rs:881) matches prefixed vars — e.g., NEARAI_MODEL_FALLBACK would match when looking for NEARAI_MODEL. Also matches commented-out lines like # NEARAI_MODEL=....
Fix: check for starts_with(&format!("{model_env}=")) or starts_with(&format!("{model_env} =")) and skip lines starting with #.
2. Backend resolved from disk, not in-memory config (medium — already flagged)
The spawn_blocking closure re-resolves the LLM backend by reading LLM_BACKEND env var then re-parsing config.toml (commands.rs:862-870). The Agent already has the resolved backend in its config. Pass it into the closure instead of re-deriving it — avoids the double TOML parse and potential divergence.
3. Default::default() in new TOML may include non-default values (medium)
When no config.toml exists (commands.rs:901-908), the code creates one with Settings { selected_model: Some(model), ..Default::default() }. If Default::default() for Settings ever includes non-empty values for other fields, they'll be persisted to disk as if the user configured them. Consider using a minimal TOML write that only contains selected_model, or use save_toml with a settings struct that only serializes non-None fields.
4. No concurrency protection (low-medium)
Two concurrent /model commands could interleave reads and writes to .env and config.toml. Unlikely in practice (CLI single-user) but upsert_bootstrap_var should ideally use atomic write (write to temp + rename). Documenting this as a known limitation is also fine.
5. Double TOML load in the closure (low)
Settings::load_toml is called twice in the same spawn_blocking closure — once to infer llm_backend (line 866) and once to update selected_model (line 897). Load once, use both.
What's good
- Root cause analysis is correct:
.env> TOML > DB priority chain meant only updating DB was useless - Conservative
.envupdate: only writes if the var already exists (line 882), avoiding injecting new vars - Tests cover the priority chain:
stale_toml_overwrites_db_modeldocuments the TOML > DB priority and proves why the fix is necessary - TOML creation on missing file: solves the fresh-install case where no wizard ran
- Diagnostic logging: warns when DB store is unavailable
Verdict
Approve with required changes. Fix the .env prefix matching (item 1) before merge — it's a real bug that could corrupt unrelated env vars. Items 2 and 5 (double TOML load) are easy wins.
Review feedback: - Use resolved llm_backend from AgentDeps instead of re-reading from disk/env (fixes DB-only backend detection, eliminates redundant I/O) - Match .env var with exact "KEY=" prefix and skip commented lines (prevents false matches on NEARAI_MODEL_VERSION etc.) - TOML is now loaded once (no double-read for backend + model update) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
All three required changes from @zmanian's review are addressed in d824875: Item 1 ( Item 2 (backend from in-memory config): Added Item 5 (double TOML load): Eliminated. Backend comes from Item 3 ( Item 4 (concurrency): Acknowledged as known limitation. Single-user assistant makes this extremely unlikely. |
- Add server-side validation of custom provider ID format (lowercase alphanumeric + hyphens, 1-64 chars) to match frontend regex - Tighten is_nearai_private_endpoint to exact-match private.near.ai or *.private.near.ai, rejecting lookalikes like private-evil.near.ai - Fix misleading priority doc comments in config/mod.rs and settings.rs to reflect the split model: LLM uses DB > env, others use env > DB - Clean up #1581 artifacts: remove TOML file creation from persist_selected_model (DB is sufficient), update stale priority comments in commands.rs, fix contradictory test assertions - Add 18 new tests for provider ID validation, adapter validation, and nearai private endpoint matching Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(agent): persist /model selection to .env, TOML, and DB The /model command only wrote selected_model to the DB and config.toml, but env vars from ~/.ironclaw/.env (e.g. NEARAI_MODEL) have the highest priority in LlmConfig::resolve_model(). The .env value was never updated, so it always shadowed the new model on restart. Now persist_selected_model updates all three persistence layers: 1. The backend-specific model env var in ~/.ironclaw/.env (only if the var already exists, to avoid injecting new vars) 2. The config.toml file (created if absent, since TOML > DB priority) 3. The DB settings table (for completeness) Also adds diagnostic logging when the DB store is unavailable. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(agent): address PR review — backend from deps, exact .env match Review feedback: - Use resolved llm_backend from AgentDeps instead of re-reading from disk/env (fixes DB-only backend detection, eliminates redundant I/O) - Match .env var with exact "KEY=" prefix and skip commented lines (prevents false matches on NEARAI_MODEL_VERSION etc.) - TOML is now loaded once (no double-read for backend + model update) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: support custom LLM provider configuration via web UI Users can now define custom LLM providers through the web UI and have them take effect without modifying environment variables or config files. - Add `CustomLlmProviderSettings` struct and `llm_custom_providers` field to `Settings` so custom provider definitions are persisted and loaded from the DB settings table - Add `LlmConfig::resolve_custom_provider()` to build a `RegistryProviderConfig` from user-defined provider data (base_url, adapter, model, api_key) - Flip resolution priority to `db > env > default` so active provider set through the UI takes precedence over deployment env vars - Warn when a custom provider is missing base_url or model - Add startup info logs for backend source and provider creation - Add regression tests for custom provider resolution and DB priority * feat: add test connection for custom LLM providers - Add POST /api/llm/test_connection endpoint that validates connectivity and auth for OpenAI-compatible, Anthropic, and Ollama adapters (10s timeout, per-adapter request logic) - Add "Test" button next to Save/Cancel in the add-provider form; result shown inline with green/red styling - Hide delete button for the active provider instead of showing an error toast - Sort the active provider to the top of the provider list - Clear selected_model when switching providers to avoid model-not-supported errors on the new provider - Add i18n keys for test/testing states (en + zh-CN) * feat: add built-in provider API key and model configuration - Add Configure button on built-in provider cards (openai, anthropic, gemini, ollama, etc.) to set API key and default model via web UI - Store overrides as `llm_builtin_overrides` setting (per-provider key/model map) using the existing generic settings k/v API - Add LlmBuiltinOverride struct in settings.rs; resolve in resolve_registry_provider() with priority: env var > selected_model > llm_builtin_overrides[id] > default - Restore provider's configured model to selected_model on provider switch, so /model command always takes precedence at runtime - Fix fetch-models button in built-in configure mode: use hardcoded base_url from BUILTIN_PROVIDERS instead of the hidden form field - Add edit support for custom providers with pre-filled dialog - Show current model on active and configured provider cards - Convert add/edit provider form to a modal dialog - Sync selected_model when editing or deleting an active custom provider * feat: move Config tab into Settings as Providers subtab * feat(web): merge Providers into Inference tab with UX improvements * chore: resolve conflicts * fix(llm): address security and correctness issues in custom LLM provider * fix(llm): address security and correctness issues in custom LLM provider * feat(web): fall back to env vars for LLM provider config in UI * fix(llm): enforce db > env > default config priority for provider setting * fix: address review feedback on provider config priority * feat: extract BUILTIN_PROVIDERS into providers.js * fix(security): store LLM API keys in encrypted secrets store instead of plaintext * fix(security): harden LLM API key handling across settings and LLM endpoints * fix: test_connection sends actual chat completion * refactor(web): derive LLM Provider display from active Model Provider * fix(settings): language switch not working for llm provider * feat(web): add restart notice to LLM Provider settings * fix: review fixes for custom LLM provider PR - Add server-side validation of custom provider ID format (lowercase alphanumeric + hyphens, 1-64 chars) to match frontend regex - Tighten is_nearai_private_endpoint to exact-match private.near.ai or *.private.near.ai, rejecting lookalikes like private-evil.near.ai - Fix misleading priority doc comments in config/mod.rs and settings.rs to reflect the split model: LLM uses DB > env, others use env > DB - Clean up #1581 artifacts: remove TOML file creation from persist_selected_model (DB is sufficient), update stale priority comments in commands.rs, fix contradictory test assertions - Add 18 new tests for provider ID validation, adapter validation, and nearai private endpoint matching Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address PR review comments for custom LLM provider - Move LLM handlers (test_connection, list_models, env_defaults) from server.rs to handlers/llm.rs for consistency with other handler modules - Merge validate_custom_providers into single pass (ID + adapter check) - Allow underscores in custom provider IDs to match builtin naming - Add missing i18n key config.fetchingModels (en + zh-CN) - Fix optional_env().ok().flatten() error swallowing in config/llm.rs; propagate ConfigError with ? instead of silently discarding - Narrow settings.rs module docs to scope DB>env precedence to LLM - Add unit tests for hydrate_llm_keys_from_secrets Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: replace static providers.js with API endpoint from registry - Delete providers.js; serve provider list from /api/llm/providers endpoint that reads from the embedded ProviderRegistry (providers.json) - Centralize secret naming (builtin_secret_name, custom_secret_name) into settings.rs; replace 8 duplicated format! calls across 4 files - Extract JS API_KEY_UNCHANGED constant; replace 6 magic string literals - Replace hard-coded API key placeholder strings with i18n keys (config.apiKeyConfigured, config.apiKeyFromEnv, config.apiKeyEnter) - Simplify apiFetchVoid to delegate to apiFetch - Remove unnecessary Vec clones in guard_active_provider_not_removed Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Robert Yan <46699230+think-in-universe@users.noreply.github.com> Co-authored-by: Illia Polosukhin <ilblackdragon@gmail.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(agent): persist /model selection to .env, TOML, and DB The /model command only wrote selected_model to the DB and config.toml, but env vars from ~/.ironclaw/.env (e.g. NEARAI_MODEL) have the highest priority in LlmConfig::resolve_model(). The .env value was never updated, so it always shadowed the new model on restart. Now persist_selected_model updates all three persistence layers: 1. The backend-specific model env var in ~/.ironclaw/.env (only if the var already exists, to avoid injecting new vars) 2. The config.toml file (created if absent, since TOML > DB priority) 3. The DB settings table (for completeness) Also adds diagnostic logging when the DB store is unavailable. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(agent): address PR review — backend from deps, exact .env match Review feedback: - Use resolved llm_backend from AgentDeps instead of re-reading from disk/env (fixes DB-only backend detection, eliminates redundant I/O) - Match .env var with exact "KEY=" prefix and skip commented lines (prevents false matches on NEARAI_MODEL_VERSION etc.) - TOML is now loaded once (no double-read for backend + model update) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: support custom LLM provider configuration via web UI Users can now define custom LLM providers through the web UI and have them take effect without modifying environment variables or config files. - Add `CustomLlmProviderSettings` struct and `llm_custom_providers` field to `Settings` so custom provider definitions are persisted and loaded from the DB settings table - Add `LlmConfig::resolve_custom_provider()` to build a `RegistryProviderConfig` from user-defined provider data (base_url, adapter, model, api_key) - Flip resolution priority to `db > env > default` so active provider set through the UI takes precedence over deployment env vars - Warn when a custom provider is missing base_url or model - Add startup info logs for backend source and provider creation - Add regression tests for custom provider resolution and DB priority * feat: add test connection for custom LLM providers - Add POST /api/llm/test_connection endpoint that validates connectivity and auth for OpenAI-compatible, Anthropic, and Ollama adapters (10s timeout, per-adapter request logic) - Add "Test" button next to Save/Cancel in the add-provider form; result shown inline with green/red styling - Hide delete button for the active provider instead of showing an error toast - Sort the active provider to the top of the provider list - Clear selected_model when switching providers to avoid model-not-supported errors on the new provider - Add i18n keys for test/testing states (en + zh-CN) * feat: add built-in provider API key and model configuration - Add Configure button on built-in provider cards (openai, anthropic, gemini, ollama, etc.) to set API key and default model via web UI - Store overrides as `llm_builtin_overrides` setting (per-provider key/model map) using the existing generic settings k/v API - Add LlmBuiltinOverride struct in settings.rs; resolve in resolve_registry_provider() with priority: env var > selected_model > llm_builtin_overrides[id] > default - Restore provider's configured model to selected_model on provider switch, so /model command always takes precedence at runtime - Fix fetch-models button in built-in configure mode: use hardcoded base_url from BUILTIN_PROVIDERS instead of the hidden form field - Add edit support for custom providers with pre-filled dialog - Show current model on active and configured provider cards - Convert add/edit provider form to a modal dialog - Sync selected_model when editing or deleting an active custom provider * feat: move Config tab into Settings as Providers subtab * feat(web): merge Providers into Inference tab with UX improvements * chore: resolve conflicts * fix(llm): address security and correctness issues in custom LLM provider * fix(llm): address security and correctness issues in custom LLM provider * feat(web): fall back to env vars for LLM provider config in UI * fix(llm): enforce db > env > default config priority for provider setting * fix: address review feedback on provider config priority * feat: extract BUILTIN_PROVIDERS into providers.js * fix(security): store LLM API keys in encrypted secrets store instead of plaintext * fix(security): harden LLM API key handling across settings and LLM endpoints * fix: test_connection sends actual chat completion * refactor(web): derive LLM Provider display from active Model Provider * fix(settings): language switch not working for llm provider * feat(web): add restart notice to LLM Provider settings * fix: review fixes for custom LLM provider PR - Add server-side validation of custom provider ID format (lowercase alphanumeric + hyphens, 1-64 chars) to match frontend regex - Tighten is_nearai_private_endpoint to exact-match private.near.ai or *.private.near.ai, rejecting lookalikes like private-evil.near.ai - Fix misleading priority doc comments in config/mod.rs and settings.rs to reflect the split model: LLM uses DB > env, others use env > DB - Clean up nearai#1581 artifacts: remove TOML file creation from persist_selected_model (DB is sufficient), update stale priority comments in commands.rs, fix contradictory test assertions - Add 18 new tests for provider ID validation, adapter validation, and nearai private endpoint matching Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address PR review comments for custom LLM provider - Move LLM handlers (test_connection, list_models, env_defaults) from server.rs to handlers/llm.rs for consistency with other handler modules - Merge validate_custom_providers into single pass (ID + adapter check) - Allow underscores in custom provider IDs to match builtin naming - Add missing i18n key config.fetchingModels (en + zh-CN) - Fix optional_env().ok().flatten() error swallowing in config/llm.rs; propagate ConfigError with ? instead of silently discarding - Narrow settings.rs module docs to scope DB>env precedence to LLM - Add unit tests for hydrate_llm_keys_from_secrets Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: replace static providers.js with API endpoint from registry - Delete providers.js; serve provider list from /api/llm/providers endpoint that reads from the embedded ProviderRegistry (providers.json) - Centralize secret naming (builtin_secret_name, custom_secret_name) into settings.rs; replace 8 duplicated format! calls across 4 files - Extract JS API_KEY_UNCHANGED constant; replace 6 magic string literals - Replace hard-coded API key placeholder strings with i18n keys (config.apiKeyConfigured, config.apiKeyFromEnv, config.apiKeyEnter) - Simplify apiFetchVoid to delegate to apiFetch - Remove unnecessary Vec clones in guard_active_provider_not_removed Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Robert Yan <46699230+think-in-universe@users.noreply.github.com> Co-authored-by: Illia Polosukhin <ilblackdragon@gmail.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Summary
/modelwroteselected_modelto the DB andconfig.toml, but the~/.ironclaw/.envfile (e.g.NEARAI_MODEL=old-model) has the highest priority inLlmConfig::resolve_model()and was never updated — so it always shadowed the new model on restart.persist_selected_modelnow updates all three persistence layers:.env(backend-specific model var),config.toml(created if absent), and DB settings.Test plan
db_single_key_model_update_survives_roundtrip— verifies/model's single-key DB write survivesfrom_db_map()roundtriptoml_overlay_preserves_matching_model— TOML overlay doesn't clobber when DB and TOML matchstale_toml_overwrites_db_model— documents TOML > DB priority (proves why TOML must be kept in sync)toml_created_when_missing_for_model_persist— verifies config.toml creation when absent/model <name>, quit, restart, verify model persists🤖 Generated with Claude Code