Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
91 changes: 90 additions & 1 deletion src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -597,7 +597,7 @@ impl EmbeddingsConfig {
key: "EMBEDDING_ENABLED".to_string(),
message: format!("must be 'true' or 'false': {e}"),
})?
.unwrap_or_else(|| settings.embeddings.enabled || openai_api_key.is_some());
.unwrap_or(settings.embeddings.enabled);

Ok(Self {
enabled,
Expand Down Expand Up @@ -1463,3 +1463,92 @@ where
.transpose()
.map(|opt| opt.unwrap_or(default))
}

#[cfg(test)]
mod tests {
use super::*;
use crate::settings::Settings;

/// RAII guard that sets/clears an env var for the duration of a test.
struct EnvGuard {
key: &'static str,
original: Option<String>,
}

impl EnvGuard {
fn set(key: &'static str, val: &str) -> Self {
let original = std::env::var(key).ok();
unsafe {
std::env::set_var(key, val);
}
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.

high

The use of unsafe std::env::set_var must be documented with a SAFETY comment explaining why the usage is safe in this context, as per the general rules. Additionally, be aware that modifying environment variables via set_var is not thread-safe and can cause data races or panics if tests are run in parallel (which is the default for cargo test).

            // SAFETY: This is used in a test context to simulate environment variables.
            // Note: Tests using this guard should ideally be run sequentially to avoid races.
            unsafe {
                std::env::set_var(key, val);
            }
References
  1. The use of unsafe std::env::set_var is permissible during application startup or within a single-threaded context like an interactive setup wizard, provided this safety invariant is documented in a SAFETY comment.

Self { key, original }
}

fn clear(key: &'static str) -> Self {
let original = std::env::var(key).ok();
unsafe {
std::env::remove_var(key);
}
Self { key, original }
}
}

impl Drop for EnvGuard {
fn drop(&mut self) {
unsafe {
if let Some(ref val) = self.original {
std::env::set_var(self.key, val);
} else {
std::env::remove_var(self.key);
}
}
}
}

#[test]
fn embeddings_resolve_respects_disabled_setting() {
// Clear env vars so resolve() falls through to settings
let _g1 = EnvGuard::clear("EMBEDDING_ENABLED");
let _g2 = EnvGuard::clear("EMBEDDING_PROVIDER");
let _g3 = EnvGuard::clear("EMBEDDING_MODEL");
let _g4 = EnvGuard::set("OPENAI_API_KEY", "sk-test-key-present");

let settings = Settings {
embeddings: crate::settings::EmbeddingsSettings {
enabled: false,
..Default::default()
},
..Default::default()
};

let result = EmbeddingsConfig::resolve(&settings).unwrap();
// Must respect the user's explicit enabled=false even though OPENAI_API_KEY is set
assert!(
!result.enabled,
"embeddings should be disabled when settings.enabled=false, regardless of OPENAI_API_KEY"
);
}

#[test]
fn embeddings_resolve_respects_enabled_setting() {
let _g1 = EnvGuard::clear("EMBEDDING_ENABLED");
let _g2 = EnvGuard::clear("EMBEDDING_PROVIDER");
let _g3 = EnvGuard::clear("EMBEDDING_MODEL");
let _g4 = EnvGuard::clear("OPENAI_API_KEY");

let settings = Settings {
embeddings: crate::settings::EmbeddingsSettings {
enabled: true,
..Default::default()
},
..Default::default()
};

let result = EmbeddingsConfig::resolve(&settings).unwrap();
assert!(
result.enabled,
"embeddings should be enabled when settings.enabled=true"
);
}
Comment on lines +1846 to +1943
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

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

These tests manipulate the same environment variables (EMBEDDING_ENABLED, EMBEDDING_PROVIDER, EMBEDDING_MODEL, OPENAI_API_KEY) and could potentially interfere with each other if run in parallel. While the EnvGuard RAII pattern properly restores variables, there's a race condition window.

Consider marking these tests with #[serial] from the serial_test crate, or use distinct environment variable names for each test (e.g., by using a test-specific prefix). However, since the test suite is reported as passing reliably, this may not be a practical issue yet.

Copilot uses AI. Check for mistakes.

}
6 changes: 3 additions & 3 deletions src/llm/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -432,9 +432,9 @@ impl SessionManager {
.get_setting(&user_id, "nearai.session_token")
.await
.map_err(|e| LlmError::SessionRenewalFailed {
provider: "nearai".to_string(),
reason: format!("DB query failed: {}", e),
})? {
provider: "nearai".to_string(),
reason: format!("DB query failed: {}", e),
})? {
Comment on lines +435 to +437
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.

medium

The indentation for the fields within the LlmError::SessionRenewalFailed struct initialization appears to have regressed and is no longer aligned with standard Rust formatting.

Suggested change
provider: "nearai".to_string(),
reason: format!("DB query failed: {}", e),
})? {
provider: "nearai".to_string(),
reason: format!("DB query failed: {}", e),
})? {

value
} else {
tracing::warn!(
Expand Down
16 changes: 8 additions & 8 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -308,14 +308,6 @@ async fn main() -> anyhow::Result<()> {
};
let session = create_session_manager(session_config).await;

// Session-based auth is only needed for NEAR AI backend without an API key.
// ChatCompletions mode with an API key skips session auth entirely.
if config.llm.backend == ironclaw::config::LlmBackend::NearAi
&& config.llm.nearai.api_key.is_none()
{
session.ensure_authenticated().await?;
}

// Initialize tracing
let env_filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("ironclaw=info,tower_http=warn"));
Expand Down Expand Up @@ -516,6 +508,14 @@ async fn main() -> anyhow::Result<()> {
}
}

// Session-based auth is only needed for NEAR AI backend without an API key.
// Runs after DB config reload so the user's actual LLM backend choice is used.
if config.llm.backend == ironclaw::config::LlmBackend::NearAi
&& config.llm.nearai.api_key.is_none()
{
session.ensure_authenticated().await?;
}

// Initialize LLM provider (clone session so we can reuse it for embeddings)
let llm = create_llm_provider(&config.llm, session.clone())?;
tracing::info!("LLM provider initialized: {}", llm.model_name());
Expand Down
31 changes: 31 additions & 0 deletions src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -904,4 +904,35 @@ mod tests {
Some("http://my-vllm:8000/v1".to_string())
);
}

#[test]
fn test_openai_compatible_db_map_round_trip() {
let settings = Settings {
llm_backend: Some("openai_compatible".to_string()),
openai_compatible_base_url: Some("http://my-vllm:8000/v1".to_string()),
embeddings: EmbeddingsSettings {
enabled: false,
..Default::default()
},
..Default::default()
};

let map = settings.to_db_map();
let restored = Settings::from_db_map(&map);

assert_eq!(
restored.llm_backend,
Some("openai_compatible".to_string()),
"llm_backend must survive DB round-trip"
);
assert_eq!(
restored.openai_compatible_base_url,
Some("http://my-vllm:8000/v1".to_string()),
"openai_compatible_base_url must survive DB round-trip"
);
assert!(
!restored.embeddings.enabled,
"embeddings.enabled=false must survive DB round-trip"
);
}
}
17 changes: 15 additions & 2 deletions src/setup/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -299,16 +299,26 @@ Contains only the settings needed BEFORE database connection. Written by
```env
DATABASE_BACKEND="libsql"
LIBSQL_PATH="/Users/name/.ironclaw/ironclaw.db"
LLM_BACKEND="openai_compatible"
LLM_BASE_URL="http://my-vllm:8000/v1"
```

Or for PostgreSQL:
Or for PostgreSQL + NEAR AI:
```env
DATABASE_BACKEND="postgres"
DATABASE_URL="postgres://user:pass@localhost/ironclaw"
LLM_BACKEND="nearai"
```

Or for Ollama:
```env
LLM_BACKEND="ollama"
OLLAMA_BASE_URL="http://localhost:11434"
```

**Why separate?** Chicken-and-egg: you need `DATABASE_BACKEND` to know
which database to connect to, so it can't be stored in the database.
which database to connect to, and `LLM_BACKEND` to know whether to
attempt NEAR AI session auth -- neither can be stored in the database.

**Layer 2: Database settings table** (everything else)

Expand Down Expand Up @@ -339,6 +349,9 @@ Final step of the wizard:
- DATABASE_URL (if postgres)
- LIBSQL_PATH (if libsql)
- LIBSQL_URL (if turso sync)
- LLM_BACKEND (always, when set)
- LLM_BASE_URL (if openai_compatible)
- OLLAMA_BASE_URL (if ollama)
4. Print configuration summary
```

Expand Down
12 changes: 12 additions & 0 deletions src/setup/wizard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1527,6 +1527,18 @@ impl SetupWizard {
env_vars.push(("LIBSQL_URL", url.clone()));
}

// LLM bootstrap vars: same chicken-and-egg problem as DATABASE_BACKEND.
// Config::from_env() needs the backend before the DB is connected.
if let Some(ref backend) = self.settings.llm_backend {
env_vars.push(("LLM_BACKEND", backend.clone()));
}
if let Some(ref url) = self.settings.openai_compatible_base_url {
env_vars.push(("LLM_BASE_URL", url.clone()));
}
if let Some(ref url) = self.settings.ollama_base_url {
env_vars.push(("OLLAMA_BASE_URL", url.clone()));
}
Comment on lines +1538 to +1543
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.

security-medium medium

The openai_compatible_base_url and ollama_base_url values are obtained from user input and subsequently passed to save_bootstrap_env, which writes them to a .env file. The save_bootstrap_env function (in src/bootstrap.rs) wraps values in double quotes but does not escape double quotes within the values themselves. This allows an attacker to inject additional environment variables into the .env file by providing a malicious string (e.g., http://localhost"\nINJECTED_VAR="value). These injected variables will be loaded by the application on subsequent starts, potentially allowing for persistent configuration manipulation, such as disabling security features or redirecting network requests.


if !env_vars.is_empty() {
let pairs: Vec<(&str, &str)> =
env_vars.iter().map(|(k, v)| (*k, v.as_str())).collect();
Expand Down