Files
optimclaw/tests/config_round_trip.rs
T
6232609080 feat(llm): add GitHub Copilot as LLM provider (#1512)
* Add github copilot as LLM provider.

* Fix Copilot in Openclaw

* security: harden Copilot OAuth token handling

C1: Use secrecy::SecretString for oauth_token and cached session token
    in CopilotTokenManager/CachedCopilotToken. Expose only at HTTP
    header injection point via .expose_secret().

C2: Document risks of hardcoded VS Code OAuth client ID and editor
    identity headers (ToS, rotation, staleness). Remove the unreliable
    paste-token setup path (setup_github_copilot_manual_token).

C3: Fix TOCTOU race in get_token() — re-check token validity after
    acquiring write lock so concurrent callers don't all perform
    redundant token exchanges.

I1: Remove dead empty else {} block in get_token().

I2: Map 401 responses to LlmError::AuthFailed instead of RequestFailed
    so retry/circuit-breaker logic handles auth failures correctly.

I3: Replace prepare_github_copilot_setup() with call to existing
    set_llm_backend_preserving_model() helper to avoid logic drift.

I4: Add unit tests for CopilotTokenManager (caching, invalidation,
    expiry/buffer behavior), poll response parsing (all OAuth device
    flow states), and DeviceCodeResponse/CopilotTokenResponse deserialization.

Co-authored-by: Copilot <[email protected]>

* fix: address review feedback and code improvements (takeover #1202)

- Fix ContentPart::Text being silently dropped in convert_messages
- Replace custom truncate_for_error with crate::util::floor_char_boundary
- Fix CLAUDE.md: accurately describe dedicated provider (not "OpenAI-compatible path")
- Fix "Github" -> "GitHub" capitalization in READMEs
- Add manual token paste option to setup wizard (not just device login)
- Fix missing extension_manager field in EngineContext (merge fixup)
- cargo fmt applied

Co-Authored-By: fallenwood <[email protected]>
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: address PR review feedback for GitHub Copilot provider

- Plumb request_timeout_secs into GithubCopilotProvider (was hardcoded 120s)
- Forward stop_sequences to Copilot API via OpenAI `stop` field
- Skip empty text part in multimodal message conversion
- Improve paste-token wizard hint with specific file path guidance

Co-Authored-By: fallenwood <[email protected]>
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: 401 retry, retryable token exchange errors, shared retry-after parsing

- Retry once inline on 401 after token invalidation (was returning
  AuthFailed immediately, guaranteeing user-visible failure)
- Map token exchange failures to RequestFailed (retryable) instead of
  AuthFailed (non-retryable by RetryProvider)
- Use shared crate::llm::retry::parse_retry_after for HTTP-date support
  and safe 60s default
- Improve paste-token wizard hint: mention `gh auth token` as primary source

Co-Authored-By: fallenwood <[email protected]>
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: 401 retry error mapping, retry status logging, token whitespace safety

- Map 401 retry get_token() failure to RequestFailed (retryable),
  consistent with initial token acquisition path
- Log retry response status before returning AuthFailed
- Trim oauth_token in exchange_copilot_token to prevent header panics
  from whitespace in env vars

Co-Authored-By: fallenwood <[email protected]>
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

---------

Co-authored-by: Fallenwood <[email protected]>
Co-authored-by: Copilot <[email protected]>
Co-authored-by: fallenwood <[email protected]>
Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-21 00:02:00 -07:00

305 lines
9.4 KiB
Rust

//! Config round-trip tests (QA Plan item 1.2).
//!
//! Tests the full config lifecycle: write via bootstrap helpers, read back via
//! dotenvy, and assert values match. Each test uses a tempdir for isolation.
//!
//! These tests call the real `save_bootstrap_env_to` and `upsert_bootstrap_var_to`
//! functions from `ironclaw::bootstrap`, ensuring test coverage of the actual
//! escaping/formatting logic rather than a reimplementation.
use std::collections::HashMap;
use tempfile::tempdir;
use ironclaw::bootstrap::{save_bootstrap_env_to, upsert_bootstrap_var_to};
/// Fake OpenAI API key for test use only. Mirrors the internal
/// `TEST_OPENAI_API_KEY_LONG` constant from the main crate, which is not
/// directly available to integration tests due to `#[cfg(test)]`.
const TEST_OPENAI_API_KEY_LONG: &str = "sk-test-key-1234567890";
/// Parse a .env file into a HashMap using dotenvy.
fn read_env_map(path: &std::path::Path) -> HashMap<String, String> {
dotenvy::from_path_iter(path)
.expect("dotenvy should parse the .env file")
.filter_map(|r| r.ok())
.collect()
}
// ── Test 1: LLM_BACKEND round-trips ────────────────────────────────────────
#[test]
fn bootstrap_env_round_trips_llm_backend() {
let dir = tempdir().unwrap();
let env_path = dir.path().join(".env");
// Write: same vars the wizard writes when user picks an LLM backend
save_bootstrap_env_to(
&env_path,
&[
("DATABASE_BACKEND", "libsql"),
("LLM_BACKEND", "openai"),
("ONBOARD_COMPLETED", "true"),
],
)
.unwrap();
// Read back
let map = read_env_map(&env_path);
assert_eq!(
map.get("LLM_BACKEND").map(String::as_str),
Some("openai"),
"LLM_BACKEND must survive .env round-trip"
);
// All other backends the wizard supports
for backend in &[
"nearai",
"anthropic",
"github_copilot",
"ollama",
"openai_compatible",
"tinfoil",
] {
save_bootstrap_env_to(&env_path, &[("LLM_BACKEND", backend)]).unwrap();
let map = read_env_map(&env_path);
assert_eq!(
map.get("LLM_BACKEND").map(String::as_str),
Some(*backend),
"LLM_BACKEND={backend} must survive round-trip"
);
}
}
// ── Test 2: EMBEDDING_ENABLED=false survives even with OPENAI_API_KEY ──────
#[test]
fn bootstrap_env_round_trips_embedding_disabled() {
let dir = tempdir().unwrap();
let env_path = dir.path().join(".env");
save_bootstrap_env_to(
&env_path,
&[
("DATABASE_BACKEND", "libsql"),
("EMBEDDING_ENABLED", "false"),
("OPENAI_API_KEY", TEST_OPENAI_API_KEY_LONG),
("ONBOARD_COMPLETED", "true"),
],
)
.unwrap();
let map = read_env_map(&env_path);
assert_eq!(
map.get("EMBEDDING_ENABLED").map(String::as_str),
Some("false"),
"EMBEDDING_ENABLED=false must not be lost when OPENAI_API_KEY is also present"
);
assert_eq!(
map.get("OPENAI_API_KEY").map(String::as_str),
Some(TEST_OPENAI_API_KEY_LONG),
"OPENAI_API_KEY must be preserved alongside EMBEDDING_ENABLED"
);
}
// ── Test 3: ONBOARD_COMPLETED round-trips and check_onboard_needed logic ───
#[test]
fn bootstrap_env_round_trips_onboard_completed() {
let dir = tempdir().unwrap();
let env_path = dir.path().join(".env");
save_bootstrap_env_to(
&env_path,
&[
("DATABASE_BACKEND", "libsql"),
("ONBOARD_COMPLETED", "true"),
],
)
.unwrap();
let map = read_env_map(&env_path);
assert_eq!(
map.get("ONBOARD_COMPLETED").map(String::as_str),
Some("true"),
"ONBOARD_COMPLETED=true must survive .env round-trip"
);
let onboard_val = map.get("ONBOARD_COMPLETED").unwrap();
let onboard_completed = onboard_val == "true";
assert!(
onboard_completed,
"Parsed ONBOARD_COMPLETED must satisfy check_onboard_needed() logic (== \"true\")"
);
// Also verify that without ONBOARD_COMPLETED, the flag is absent
save_bootstrap_env_to(&env_path, &[("DATABASE_BACKEND", "libsql")]).unwrap();
let map2 = read_env_map(&env_path);
assert!(
!map2.contains_key("ONBOARD_COMPLETED"),
"ONBOARD_COMPLETED must be absent when not written"
);
}
// ── Test 4: Session token key name round-trips ─────────────────────────────
#[test]
fn bootstrap_env_round_trips_session_token_key() {
let dir = tempdir().unwrap();
let env_path = dir.path().join(".env");
let token = "sess_abc123def456ghi789jkl012mno345pqr678stu901vwx234";
save_bootstrap_env_to(
&env_path,
&[
("DATABASE_BACKEND", "libsql"),
("NEARAI_API_KEY", token),
("ONBOARD_COMPLETED", "true"),
],
)
.unwrap();
let map = read_env_map(&env_path);
assert_eq!(
map.get("NEARAI_API_KEY").map(String::as_str),
Some(token),
"NEARAI_API_KEY (session token) must survive .env round-trip"
);
let session_token = "sess_hosting_provider_injected_token_value";
save_bootstrap_env_to(
&env_path,
&[
("NEARAI_SESSION_TOKEN", session_token),
("ONBOARD_COMPLETED", "true"),
],
)
.unwrap();
let map2 = read_env_map(&env_path);
assert_eq!(
map2.get("NEARAI_SESSION_TOKEN").map(String::as_str),
Some(session_token),
"NEARAI_SESSION_TOKEN must survive .env round-trip"
);
}
// ── Test 5: Multiple keys are preserved on re-read ─────────────────────────
#[test]
fn bootstrap_env_preserves_existing_values() {
let dir = tempdir().unwrap();
let env_path = dir.path().join(".env");
let initial_vars: &[(&str, &str)] = &[
("DATABASE_BACKEND", "postgres"),
(
"DATABASE_URL",
"postgres://user:pass@localhost:5432/ironclaw",
),
("LLM_BACKEND", "nearai"),
("NEARAI_API_KEY", "key_abc123"),
("EMBEDDING_ENABLED", "true"),
("ONBOARD_COMPLETED", "true"),
];
save_bootstrap_env_to(&env_path, initial_vars).unwrap();
let map = read_env_map(&env_path);
assert_eq!(
map.len(),
initial_vars.len(),
"all vars must survive round-trip"
);
for (key, value) in initial_vars {
assert_eq!(
map.get(*key).map(String::as_str),
Some(*value),
"{key} must be preserved"
);
}
// Now upsert a new key and verify nothing is lost
upsert_bootstrap_var_to(&env_path, "LLM_MODEL", "gpt-4o").unwrap();
let map2 = read_env_map(&env_path);
for (key, value) in initial_vars {
assert_eq!(
map2.get(*key).map(String::as_str),
Some(*value),
"{key} must be preserved after upsert"
);
}
assert_eq!(
map2.get("LLM_MODEL").map(String::as_str),
Some("gpt-4o"),
"upserted LLM_MODEL must be present"
);
// Upsert an existing key and verify the value is updated, others preserved
upsert_bootstrap_var_to(&env_path, "LLM_BACKEND", "anthropic").unwrap();
let map3 = read_env_map(&env_path);
assert_eq!(
map3.get("LLM_BACKEND").map(String::as_str),
Some("anthropic"),
"LLM_BACKEND must be updated after upsert"
);
assert_eq!(
map3.get("DATABASE_URL").map(String::as_str),
Some("postgres://user:pass@localhost:5432/ironclaw"),
"DATABASE_URL must be preserved after upsert of different key"
);
assert_eq!(
map3.get("LLM_MODEL").map(String::as_str),
Some("gpt-4o"),
"previously upserted LLM_MODEL must be preserved"
);
}
// ── Test 6: Special characters in values ───────────────────────────────────
#[test]
fn bootstrap_env_handles_special_characters() {
let dir = tempdir().unwrap();
let env_path = dir.path().join(".env");
let test_cases: &[(&str, &str)] = &[
// Spaces in values
("AGENT_NAME", "my ironclaw agent"),
// Equals signs in values (e.g., base64 tokens)
("API_TOKEN", "dGVzdA=="),
// Hash characters (common in URL-encoded passwords, treated as comments without quoting)
("DATABASE_URL", "postgres://user:p%23assword@host:5432/db"),
// Single quotes inside double-quoted values
("GREETING", "it's a test"),
// Double quotes (must be escaped)
("QUOTED_VAL", r#"say "hello" world"#),
// Backslashes (must be escaped)
("WIN_PATH", r"C:\Users\ironclaw\data"),
// Mixed special characters
("COMPLEX", r#"key=val with "quotes" & back\slash #hash"#),
// Empty-ish but non-empty value (single space)
("SPACER", " "),
];
save_bootstrap_env_to(&env_path, test_cases).unwrap();
let map = read_env_map(&env_path);
for (key, expected) in test_cases {
let actual = map.get(*key);
assert!(actual.is_some(), "{key} must be present in parsed .env");
assert_eq!(
actual.unwrap(),
expected,
"{key}: value with special characters must round-trip exactly"
);
}
}