Merge branch 'worktree-agent-af5a53986b9441608' into feature_chore_bug_005

# Conflicts:
#	src-tauri/src/llm/mod.rs
#	src/lib/views/Settings.svelte
#	src/lib/views/SummaryPanel.svelte
This commit is contained in:
iamdoubz
2026-07-07 08:15:04 -05:00
9 changed files with 1207 additions and 72 deletions
+158 -19
View File
@@ -57,6 +57,7 @@ fn default_settings() -> Settings {
low_overhead: false,
default_record: false,
consent_acknowledged: false,
hosted_ai_acknowledged: false,
sync_enabled: false,
retention_max_age_days: None,
retention_max_size_gb: None,
@@ -1972,6 +1973,18 @@ fn llm_provider_from_settings(settings: &Settings) -> Option<Box<dyn crate::llm:
model: settings.llm_model.clone(),
credential_ref: None, // local, unauthenticated custom endpoint (ADR-0007); Phase 10a sets this for hosted gateways
})),
// Hosted (ADR-0011, M3.1/M3.2): the key is never read from settings —
// only `set_llm_provider` writes it, straight to the OS credential
// store via `ANTHROPIC_CREDENTIAL_REF`. Selecting "anthropic" here is
// itself the "configured" signal that puts api.anthropic.com on the
// egress allowlist (see `privacy_self_check_json` below, which reuses
// this same `is_local()` check unconditionally for every provider —
// no anthropic-specific branch needed there).
"anthropic" => Some(Box::new(crate::llm::AnthropicProvider {
model: settings.llm_model.clone(),
credential_ref: crate::llm::ANTHROPIC_CREDENTIAL_REF.to_string(),
endpoint: settings.llm_endpoint.clone(),
})),
_ => None, // "off"
}
}
@@ -1994,34 +2007,87 @@ pub async fn llm_status() -> WaResult<serde_json::Value> {
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SetLlmProviderArgs {
pub provider: String, // "ollama" | "custom" | "off" (Phase 10a adds "anthropic" | "openai")
pub provider: String, // "ollama" | "custom" | "anthropic" | "off" ("openai" not yet wired — M3 scope is Anthropic)
pub endpoint: Option<String>,
pub model: Option<String>,
/// Hosted providers only (Anthropic, M3.2). Consumed here to write the
/// OS credential store and then dropped — never assigned into `Settings`,
/// so it structurally cannot reach settings.json/wa.db (ADR-0011,
/// FR-SEC-1). Optional on every call so re-saving the model/endpoint
/// doesn't force re-entering a key that's already stored.
pub api_key: Option<String>,
}
/// Select/configure the LLM provider (T5.2, FR-LLM-1). Hosted providers
/// (`apiKey`, stored only in the OS credential store — never settings/DB)
/// land in Phase 10a.
#[tauri::command]
pub async fn set_llm_provider(args: SetLlmProviderArgs) -> WaResult<()> {
if !matches!(args.provider.as_str(), "ollama" | "custom" | "off") {
return Err(WaError::new(
"llm",
format!(
"provider '{}' is not available yet — hosted providers land in Phase 10a",
args.provider
),
));
}
let mut settings = load_settings();
settings.llm_provider = args.provider;
if let Some(endpoint) = args.endpoint {
/// Pure settings mutation for `set_llm_provider` — everything except the
/// credential-store write, so it's unit-testable without touching a real
/// keyring and so the "the API key never reaches `Settings`" guarantee is
/// checkable structurally (this function's signature has no key parameter
/// at all; see `set_llm_provider_never_writes_the_api_key_into_settings`).
/// Anthropic's endpoint is fixed, not user-configurable (ADR-0011) — any
/// `endpoint` argument is ignored for that provider so a stale endpoint left
/// over from a previous "custom"/"ollama" selection can't leak through.
/// Symmetrically, switching *away* from Anthropic without supplying a new
/// endpoint resets to Ollama's own local default rather than silently
/// keeping its fixed hosted URL around — otherwise a quick per-use provider
/// switch (SummaryPanel, M3.3) that only sends `provider` could leave
/// `llm_endpoint` pointed at a third party for a provider that has no
/// business talking to it.
fn apply_llm_provider_args(
settings: &mut Settings,
provider: &str,
endpoint: Option<String>,
model: Option<String>,
) {
settings.llm_provider = provider.to_string();
if provider == "anthropic" {
settings.llm_endpoint = "https://api.anthropic.com".to_string();
} else if let Some(endpoint) = endpoint {
settings.llm_endpoint = endpoint;
} else if settings.llm_endpoint == "https://api.anthropic.com" {
settings.llm_endpoint = "http://localhost:11434".to_string();
}
if let Some(model) = args.model {
if let Some(model) = model {
settings.llm_model = model;
}
}
/// Select/configure the LLM provider (T5.2/T10.2, FR-LLM-1, ADR-0011).
/// Hosted providers (`apiKey`) are stored only in the OS credential store —
/// never settings/DB (FR-SEC-1); selecting "anthropic" here is itself what
/// puts `api.anthropic.com` on the settings-derived egress allowlist (see
/// `privacy_self_check_json`), so it must never happen implicitly.
#[tauri::command]
pub async fn set_llm_provider(args: SetLlmProviderArgs) -> WaResult<()> {
if !matches!(
args.provider.as_str(),
"ollama" | "custom" | "anthropic" | "off"
) {
return Err(WaError::new(
"llm",
format!("provider '{}' is not available yet", args.provider),
));
}
if args.provider == "anthropic" {
match args.api_key.as_deref().map(str::trim) {
Some(key) if !key.is_empty() => {
crate::llm::credentials::set(crate::llm::ANTHROPIC_CREDENTIAL_REF, key)
.map_err(|e| WaError::new("llm", e.to_string()))?;
}
// No new key supplied — fine only if one was already stored from
// a previous call (e.g. the user is just changing the model).
_ if crate::llm::credentials::get(crate::llm::ANTHROPIC_CREDENTIAL_REF).is_err() => {
return Err(WaError::new(
"llm",
"an Anthropic API key is required".to_string(),
));
}
_ => {}
}
}
let mut settings = load_settings();
apply_llm_provider_args(&mut settings, &args.provider, args.endpoint, args.model);
save_settings(&settings)
}
@@ -3725,6 +3791,79 @@ mod tests {
);
}
#[test]
fn privacy_self_check_anthropic_host_joins_the_allowlist_only_once_selected() {
// Off by default (ADR-0011) — merely *knowing about* Anthropic (the
// provider code exists) must not put its host on the allowlist.
let result = privacy_self_check_json(&default_settings(), Vec::new());
assert_eq!(result["allowlistedHosts"], serde_json::json!([]));
// Only once the user has actually selected it (mirrors what
// set_llm_provider/apply_llm_provider_args writes) does the same
// generic llm_provider_from_settings + is_local() path used for every
// other provider pick it up — no anthropic-specific allowlist code.
let mut settings = default_settings();
settings.llm_provider = "anthropic".to_string();
settings.llm_endpoint = "https://api.anthropic.com".to_string();
let result = privacy_self_check_json(&settings, Vec::new());
assert_eq!(result["llmIsLocal"], false);
assert_eq!(
result["allowlistedHosts"],
serde_json::json!(["api.anthropic.com"])
);
}
#[test]
fn set_llm_provider_never_writes_the_api_key_into_settings() {
// apply_llm_provider_args has no key parameter at all — this proves
// structurally (not just by inspection) that a supplied apiKey can
// never end up in the Settings struct that gets serialized to
// settings.json (FR-SEC-1, ADR-0011). The real key only ever reaches
// crate::llm::credentials::set, called separately in
// set_llm_provider before this function runs.
let mut settings = default_settings();
apply_llm_provider_args(
&mut settings,
"anthropic",
Some("https://attacker-controlled.example/ignored".to_string()),
Some("claude-3-5-sonnet-latest".to_string()),
);
assert_eq!(settings.llm_provider, "anthropic");
// The fixed hosted endpoint wins over any caller-supplied one.
assert_eq!(settings.llm_endpoint, "https://api.anthropic.com");
assert_eq!(settings.llm_model, "claude-3-5-sonnet-latest");
let json = serde_json::to_string(&settings).expect("settings must serialize");
assert!(!json.to_lowercase().contains("api_key"));
assert!(!json.to_lowercase().contains("apikey"));
assert!(!json.contains("sk-ant-"));
}
#[test]
fn apply_llm_provider_args_keeps_the_existing_endpoint_for_non_anthropic_providers() {
let mut settings = default_settings();
settings.llm_endpoint = "http://192.168.0.42:11434".to_string();
apply_llm_provider_args(&mut settings, "ollama", None, Some("llama3.1".to_string()));
assert_eq!(settings.llm_provider, "ollama");
assert_eq!(settings.llm_endpoint, "http://192.168.0.42:11434");
assert_eq!(settings.llm_model, "llama3.1");
}
#[test]
fn apply_llm_provider_args_resets_endpoint_when_switching_away_from_anthropics_fixed_url() {
// A per-use provider quick-switch (SummaryPanel, M3.3) only sends
// `provider`, relying on whatever endpoint/model were last saved —
// if that was Anthropic's fixed URL, switching to ollama must not
// silently keep pointing at a third party.
let mut settings = default_settings();
settings.llm_provider = "anthropic".to_string();
settings.llm_endpoint = "https://api.anthropic.com".to_string();
apply_llm_provider_args(&mut settings, "ollama", None, None);
assert_eq!(settings.llm_provider, "ollama");
assert_eq!(settings.llm_endpoint, "http://localhost:11434");
}
#[test]
fn privacy_self_check_only_enabled_sync_targets_join_the_allowlist() {
let mut settings = default_settings();