Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/agent/agent_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,9 @@ pub struct AgentDeps {
pub sandbox_readiness: crate::agent::routine_engine::SandboxReadiness,
/// Software builder for self-repair tool rebuilding.
pub builder: Option<Arc<dyn crate::tools::SoftwareBuilder>>,
/// Resolved LLM backend identifier (e.g., "nearai", "openai", "groq").
/// Used by `/model` persistence to determine which env var to update.
pub llm_backend: String,
}

/// The main agent that coordinates all components.
Expand Down
52 changes: 49 additions & 3 deletions src/agent/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -841,12 +841,50 @@ impl Agent {
.await
{
tracing::warn!("Failed to persist model to DB: {}", e);
} else {
tracing::debug!("Persisted selected_model to DB: {}", model);
}
} else {
tracing::warn!("No database store available — model choice will not persist to DB");
}

// 2. Update TOML config file if it exists (sync I/O in spawn_blocking).
// 2. Update .env and TOML config file (sync I/O in spawn_blocking).
let model_owned = model.to_string();
let backend = self.deps.llm_backend.clone();
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()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

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.

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.

// (env var > TOML > DB > default). If the .env file has e.g.
// NEARAI_MODEL=old-model, it shadows everything else. We must
// update this var or the /model change is invisible on restart.
let registry = crate::llm::ProviderRegistry::load();
let model_env = registry.model_env_var(&backend);
Comment on lines +861 to +862

Copilot AI Mar 23, 2026

Copy link

Choose a reason for hiding this comment

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

Backend detection for choosing which *_MODEL env var to update ignores the DB layer. LlmConfig::resolve() selects backend as env LLM_BACKEND > settings.llm_backend (DB/TOML) > default, but here you only consult LLM_BACKEND and TOML. If the backend is coming from DB (e.g., llm_backend=openai) and .env contains OPENAI_MODEL=..., this code will default to nearai and skip updating the real shadowing env var, so /model still won’t persist across restart. Consider deriving the backend from the same resolved settings chain (e.g., read llm_backend from the store when available / pass the active backend into the closure), or detect which model env var is present in .env and update that one.

Copilot uses AI. Check for mistakes.

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 d824875. Backend is now read from AgentDeps.llm_backend (resolved at startup from the full config chain: env > DB > TOML > default), eliminating the disk re-read and correctly handling DB-only backends.

let env_var_prefix = format!("{}=", model_env);

// Only update the .env file if the var is actually set there
// (avoid injecting new vars the user never configured).
let env_path = crate::bootstrap::ironclaw_env_path();
let env_has_var = std::fs::read_to_string(&env_path)
.ok()
.is_some_and(|content| {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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))

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 d824875. Now uses format\!("{}=", model_env) prefix and skips #-commented lines, matching the same logic as upsert_bootstrap_var_to.

content.lines().any(|line| {
let trimmed = line.trim_start();
!trimmed.starts_with('#') && trimmed.starts_with(&env_var_prefix)
})
});
if env_has_var {
if let Err(e) = crate::bootstrap::upsert_bootstrap_var(model_env, &model_owned) {
tracing::warn!("Failed to update {} in .env: {}", model_env, e);
} else {
tracing::debug!("Updated {} in .env to {}", model_env, model_owned);
}
}

// 2b. Update (or create) the TOML config file.
//
// The TOML overlay has higher priority than DB settings on
// startup, so it MUST stay in sync with the DB.
let toml_path = crate::settings::Settings::default_toml_path();
match crate::settings::Settings::load_toml(&toml_path) {
Ok(Some(mut settings)) => {
Expand All @@ -856,7 +894,15 @@ impl Agent {
}
}
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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

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.

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.

let settings = crate::settings::Settings {
selected_model: Some(model_owned),
..Default::default()
};
if let Err(e) = settings.save_toml(&toml_path) {
tracing::warn!("Failed to create config.toml for model persistence: {}", e);
}
}
Err(e) => {
tracing::warn!("Failed to load config.toml for model persistence: {}", e);
Expand All @@ -865,7 +911,7 @@ impl Agent {
})
.await
{
tracing::warn!("Model TOML persistence task failed: {}", e);
tracing::warn!("Model persistence task failed: {}", e);
}
}
}
3 changes: 3 additions & 0 deletions src/agent/dispatcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1225,6 +1225,7 @@ mod tests {
document_extraction: None,
sandbox_readiness: crate::agent::routine_engine::SandboxReadiness::DisabledByConfig,
builder: None,
llm_backend: "nearai".to_string(),
};

Agent::new(
Expand Down Expand Up @@ -2092,6 +2093,7 @@ mod tests {
document_extraction: None,
sandbox_readiness: crate::agent::routine_engine::SandboxReadiness::DisabledByConfig,
builder: None,
llm_backend: "nearai".to_string(),
};

Agent::new(
Expand Down Expand Up @@ -2212,6 +2214,7 @@ mod tests {
document_extraction: None,
sandbox_readiness: crate::agent::routine_engine::SandboxReadiness::DisabledByConfig,
builder: None,
llm_backend: "nearai".to_string(),
};

Agent::new(
Expand Down
1 change: 1 addition & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -862,6 +862,7 @@ async fn async_main() -> anyhow::Result<()> {
ironclaw::agent::routine_engine::SandboxReadiness::DockerUnavailable
},
builder: components.builder,
llm_backend: config.llm.backend.clone(),
};

let channels_for_warnings = Arc::clone(&channels);
Expand Down
108 changes: 108 additions & 0 deletions src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1297,6 +1297,92 @@ mod tests {
assert_eq!(loaded.heartbeat.interval_secs, 900);
}

/// Regression: /model writes a single key ("selected_model") to the DB via
/// set_setting(). On restart, get_all_settings() returns ALL keys including
/// wizard-written defaults. The single-key update must survive the full
/// from_db_map() round trip.
#[test]
fn db_single_key_model_update_survives_roundtrip() {
// Step 1: Wizard writes full settings to DB (including selected_model
// from initial setup).
let wizard_settings = Settings {
llm_backend: Some("nearai".to_string()),
selected_model: Some("old-wizard-model".to_string()),
..Default::default()
};
let mut db: std::collections::HashMap<String, serde_json::Value> =
wizard_settings.to_db_map();

// Step 2: User runs /model new-model — persist_selected_model writes
// a single key, overwriting the wizard value.
db.insert(
"selected_model".to_string(),
serde_json::Value::String("new-model".to_string()),
);

// Step 3: On restart, from_db_map() rebuilds Settings from the full
// DB map.
let restored = Settings::from_db_map(&db);
assert_eq!(
restored.selected_model,
Some("new-model".to_string()),
"/model change must survive DB round trip"
);
}

/// Regression: TOML overlay must not clobber a DB-persisted selected_model
/// when the TOML file matches the DB. This is the normal case after /model
/// successfully writes to both DB and TOML.
#[test]
fn toml_overlay_preserves_matching_model() {
// DB settings with new model from /model command.
let mut db_settings = Settings {
llm_backend: Some("nearai".to_string()),
selected_model: Some("new-model".to_string()),
..Default::default()
};

// TOML also updated by /model command to the same value.
let toml_settings = Settings {
selected_model: Some("new-model".to_string()),
..Default::default()
};

db_settings.merge_from(&toml_settings);
assert_eq!(
db_settings.selected_model,
Some("new-model".to_string()),
"TOML overlay must not clobber matching model"
);
}

/// Regression: when /model updates DB but TOML write fails, a stale TOML
/// file would overwrite the DB value. This test documents the priority:
/// TOML > DB (by design). persist_selected_model MUST update the TOML.
#[test]
fn stale_toml_overwrites_db_model() {
// DB has the new model from /model.
let mut db_settings = Settings {
selected_model: Some("new-model".to_string()),
..Default::default()
};

// TOML still has the old model (write failed or was not attempted).
let stale_toml = Settings {
selected_model: Some("old-model".to_string()),
..Default::default()
};

db_settings.merge_from(&stale_toml);
// This documents the current priority: TOML wins over DB.
// The fix in persist_selected_model ensures TOML is always updated.
assert_eq!(
db_settings.selected_model,
Some("old-model".to_string()),
"TOML overlay has higher priority than DB (by design)"
);
}

/// Regression test: /model command must persist selected_model to TOML config.
/// Prior to the fix, `set_model()` only changed the in-memory provider and the
/// choice was lost on restart.
Expand All @@ -1322,6 +1408,28 @@ mod tests {
assert_eq!(reloaded.selected_model, Some("new-model".to_string()));
}

/// Regression: /model must create config.toml when it doesn't exist, so the
/// model survives restarts. Previously the Ok(None) case was a no-op.
#[test]
fn toml_created_when_missing_for_model_persist() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");

// No config.toml yet (fresh install, no wizard).
assert!(Settings::load_toml(&path).unwrap().is_none());

// Simulate what persist_selected_model now does for the Ok(None) case.
let settings = Settings {
selected_model: Some("new-model".to_string()),
..Default::default()
};
settings.save_toml(&path).unwrap();

// Verify the model survived.
let loaded = Settings::load_toml(&path).unwrap().unwrap();
assert_eq!(loaded.selected_model, Some("new-model".to_string()));
}

#[test]
fn toml_missing_file_returns_none() {
let result = Settings::load_toml(std::path::Path::new("/tmp/nonexistent_config.toml"));
Expand Down
1 change: 1 addition & 0 deletions src/testing/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -563,6 +563,7 @@ impl TestHarnessBuilder {
document_extraction: None,
sandbox_readiness: crate::agent::routine_engine::SandboxReadiness::DisabledByConfig,
builder: None,
llm_backend: "nearai".to_string(),
};

TestHarness {
Expand Down
1 change: 1 addition & 0 deletions tests/e2e_telegram_message_routing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,7 @@ mod tests {
document_extraction: None,
sandbox_readiness: ironclaw::agent::SandboxReadiness::DisabledByConfig,
builder: None,
llm_backend: "nearai".to_string(),
};

let gateway = Arc::new(TestChannel::new());
Expand Down
1 change: 1 addition & 0 deletions tests/support/gateway_workflow_harness.rs
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,7 @@ impl GatewayWorkflowHarness {
document_extraction: None,
sandbox_readiness: ironclaw::agent::SandboxReadiness::DisabledByConfig,
builder: None,
llm_backend: "nearai".to_string(),
},
channels,
None,
Expand Down
1 change: 1 addition & 0 deletions tests/support/test_rig.rs
Original file line number Diff line number Diff line change
Expand Up @@ -768,6 +768,7 @@ impl TestRigBuilder {
document_extraction: None,
sandbox_readiness: ironclaw::agent::SandboxReadiness::Available, // tests don't use real Docker
builder: None,
llm_backend: "nearai".to_string(),
};

// 7. Create TestChannel and ChannelManager.
Expand Down
Loading