Files
optimclaw/src/llm/session.rs
T
a53b2c10b5 fix: Fix wasm tool schemas and runtime (#42)
* feat: Move debug log truncation from agent loop to REPL channel

Full tool output now flows through StatusUpdate so the web gateway
gets untruncated content. The REPL channel truncates at display time
(200 chars for tool results, thinking, and status messages).

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: Flatten WASM tool schemas and fix host HTTP runtime contention

LLMs can't reliably follow oneOf + const discriminator patterns in JSON
Schema, causing tools like Google Calendar to receive malformed params
(e.g., {"operation":"list_events","data":{"calendarId":"primary"}} instead
of {"action":"list_events","calendar_id":"primary"}). Replace all 9 WASM
tool schemas with flat action enum + top-level properties. The serde
#[serde(tag = "action")] deserialization works identically.

Also fixes WASM host HTTP requests (channels and tools) stalling during
startup by replacing Handle::current().block_on() with a dedicated
single-threaded runtime per request, avoiding I/O driver contention.

Reduces verbose LLM debug logging (full request/response payloads) and
changes tower_http default from debug to warn.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* feat: Built-in OAuth credentials and combined Google scopes

Add infrastructure for shipping default OAuth credentials with the binary,
similar to how gcloud/rclone bake in their client_id. Credentials are set
at compile time via IRONCLAW_GOOGLE_CLIENT_ID / IRONCLAW_GOOGLE_CLIENT_SECRET
env vars, or can be hardcoded in src/cli/oauth_defaults.rs.

The fallback chain is: capabilities file > runtime env var > built-in defaults.

Also, when authing any Google tool, scopes from ALL installed Google tools
are now combined into a single OAuth request (they all share the same
google_oauth_token secret). One login covers Gmail, Calendar, Drive, etc.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* feat: Ship default Google OAuth credentials for zero-config auth

Google Desktop App credentials are not secret (per Google's own docs).
Hardcode them so `ironclaw tool auth <google-tool>` works out of the box
without requiring users to register their own OAuth app.

Credentials can still be overridden at compile time
(IRONCLAW_GOOGLE_CLIENT_ID) or runtime (GOOGLE_OAUTH_CLIENT_ID).

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: Consistent OAuth callback port and polished landing page

- Use fixed port 9876 instead of scanning 9876-9886 (one redirect URI
  to register in provider OAuth apps, deterministic behavior)
- Replace broken unicode checkmark with SVG icons (charset was missing,
  rendered as mojibake)
- Dark themed landing page with proper card layout for both success
  and error states
- Add charset=utf-8 to Content-Type headers

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* refactor: Unify OAuth callback server across all auth flows

All three OAuth flows (WASM tool auth, MCP server auth, NEAR AI login)
now share the same code from cli::oauth_defaults:

- Fixed port 9876 (one redirect URI to register per provider)
- Shared landing page HTML (dark card with SVG icons, proper charset)
- Parameterized wait_for_callback(listener, path, param, display_name)

Removes ~120 lines of duplicated callback/HTML code.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* Support for oauth token refresh

* refactor: Replace bootstrap.json with ~/.ironclaw/.env for DATABASE_URL

Kill the 4-field BootstrapConfig JSON file. Only DATABASE_URL actually
needs disk persistence (chicken-and-egg before DB connect). The other
three fields are now derived: pool_size defaults to 10 via env var,
secrets master key is auto-detected (env then keychain probe), and
onboard_completed is inferred from DATABASE_URL presence.

The new format is a standard .env file loaded via dotenvy early in
main, so DATABASE_URL is available as a regular env var everywhere.

Handles three upgrade paths:
- Clean start: wizard writes .env, reload after wizard completes
- Returning user: .env loaded at startup, business as usual
- Legacy upgrade: bootstrap.json auto-migrated to .env on first run

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: Address PR review findings

- Fix UTF-8 panic in truncate_for_preview (byte-slice on char boundary)
- Cap WASM guest timeout_ms at 5 minutes to prevent resource exhaustion
- Fix localhost detection in requires_auth() to avoid substring matches
  (e.g. "notlocalhost.com" no longer matches)
- Fix query param injection to insert before URL fragment
- Fix extract_host_from_url for IPv6 bracket notation
- Remove misleading schema defaults: Slack limit, Slides insertion_index,
  Docs index (per-action defaults documented in descriptions instead)

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* style: Fix cargo fmt formatting

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: IPv6 loopback support for OAuth listener and localhost detection

- bind_callback_listener: try [::1] first, fall back to 127.0.0.1,
  so OAuth redirects work on systems where localhost resolves to ::1
- is_localhost_url: replace manual string parsing with url::Url for
  correct handling of IPv6 brackets, ports, userinfo, etc.
- Add url crate as direct dependency (already a transitive dep)

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: Address PR review feedback on runtime reuse, onboard check, and OAuth binding

- Remove session file check from check_onboard_needed(); DATABASE_URL is sufficient
- Detect AddrInUse on IPv6 bind and fail immediately instead of falling through to IPv4
- Reuse dedicated tokio runtime across HTTP calls in both tool and channel WASM wrappers

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: HTML-escape provider name in OAuth landing page, simplify Slack limit description

- Add html_escape() to prevent XSS in landing_html() where provider_name
  was interpolated directly into HTML (defense-in-depth, source is trusted
  but escaping costs nothing)
- Remove per-action default numbers from Slack limit field description to
  avoid confusing LLMs with conflicting defaults

Addresses review feedback from zmanian on PR #42.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: Save all bootstrap fields from wizard, fix config module comment

- Wizard now saves secrets_master_key_source and database_pool_size to
  bootstrap.json (was only saving database_url and onboard_completed,
  which broke secrets after fresh onboard since SecretsConfig::resolve
  reads key source from bootstrap)
- Update config.rs module doc to reflect bootstrap.json priority chain
  instead of the removed ~/.ironclaw/.env approach

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* refactor: Replace BootstrapConfig with .env-based bootstrap

DATABASE_URL is the only setting that needs disk persistence before
the database is available. Instead of a custom bootstrap.json with 4
fields, use a standard ~/.ironclaw/.env file loaded via dotenvy.

- Remove BootstrapConfig struct entirely
- Restore ironclaw_env_path(), load_ironclaw_env(), save_database_url()
- SecretsConfig::resolve() now auto-detects (env var then keychain probe)
  instead of reading a saved source from bootstrap.json
- DatabaseConfig::resolve() reads DATABASE_URL from env only (dotenvy
  loads ~/.ironclaw/.env into the environment early in startup)
- check_onboard_needed() is now sync (just checks env vars)
- Wizard save_and_summarize() works for both postgres and libsql backends
- One-time migration from bootstrap.json to .env preserved

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: Ensure load_ironclaw_env() runs in all Config paths, fix .env priority

- Config::from_env() and Config::from_db() now call load_ironclaw_env()
  internally (after dotenvy::dotenv()), so CLI commands like `memory`
  and `config` correctly load DATABASE_URL from ~/.ironclaw/.env
- Fix load order: standard ./.env first (higher priority), then
  ~/.ironclaw/.env, matching the documented priority chain
- Collapse nested if/if-let into let-chains (clippy::collapsible_if)
  in oauth_defaults.rs, tool.rs, and secrets/store.rs
- Fix rename_to_migrated to take &Path instead of &PathBuf

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: Address PR review comments (quoting, SSRF, error mapping)

- Quote DATABASE_URL in .env writes so `#` in passwords isn't treated
  as a dotenv comment (e.g., `DATABASE_URL="postgres://..."`)
- Add SSRF defenses to refresh_oauth_token(): require HTTPS, reject
  private/loopback IPs (with DNS resolution), disable redirects.
  token_url comes from tool capabilities JSON, so a malicious tool
  could otherwise exfiltrate refresh tokens.
- Fix IPv4 bind error mapping: only map AddrInUse to PortInUse,
  use generic Io variant for other bind failures

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-02-14 21:21:22 +00:00

586 lines
21 KiB
Rust

//! Session management for NEAR AI authentication.
//!
//! Handles session token persistence, expiration detection, and renewal via
//! OAuth flow. Tokens are stored in `~/.ironclaw/session.json` and refreshed
//! automatically when expired.
use std::path::PathBuf;
use std::sync::Arc;
use chrono::{DateTime, Utc};
use reqwest::Client;
use secrecy::SecretString;
use serde::{Deserialize, Serialize};
use tokio::sync::{Mutex, RwLock};
use crate::error::LlmError;
/// Session data persisted to disk.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionData {
pub session_token: String,
pub created_at: DateTime<Utc>,
#[serde(default)]
pub auth_provider: Option<String>,
}
/// Configuration for session management.
#[derive(Debug, Clone)]
pub struct SessionConfig {
/// Base URL for auth endpoints (e.g., https://private.near.ai).
pub auth_base_url: String,
/// Path to session file (e.g., ~/.ironclaw/session.json).
pub session_path: PathBuf,
}
impl Default for SessionConfig {
fn default() -> Self {
Self {
auth_base_url: "https://private.near.ai".to_string(),
session_path: default_session_path(),
}
}
}
/// Get the default session file path (~/.ironclaw/session.json).
pub fn default_session_path() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".ironclaw")
.join("session.json")
}
/// Manages NEAR AI session tokens with persistence and automatic renewal.
pub struct SessionManager {
config: SessionConfig,
client: Client,
/// Current token in memory.
token: RwLock<Option<SecretString>>,
/// Prevents thundering herd during concurrent 401s.
renewal_lock: Mutex<()>,
/// Optional database store for persisting session to the settings table.
store: RwLock<Option<Arc<dyn crate::db::Database>>>,
/// User ID for DB settings (default: "default").
user_id: RwLock<String>,
}
impl SessionManager {
/// Create a new session manager and load any existing token from disk.
pub fn new(config: SessionConfig) -> Self {
let manager = Self {
config,
client: Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.unwrap_or_else(|_| Client::new()),
token: RwLock::new(None),
renewal_lock: Mutex::new(()),
store: RwLock::new(None),
user_id: RwLock::new("default".to_string()),
};
// Try to load existing session synchronously during construction
if let Ok(data) = std::fs::read_to_string(&manager.config.session_path)
&& let Ok(session) = serde_json::from_str::<SessionData>(&data)
{
// We can't await here, so we use try_write
if let Ok(mut guard) = manager.token.try_write() {
*guard = Some(SecretString::from(session.session_token));
tracing::info!(
"Loaded session token from {}",
manager.config.session_path.display()
);
}
}
manager
}
/// Create a session manager and load token asynchronously.
pub async fn new_async(config: SessionConfig) -> Self {
let manager = Self {
config,
client: Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.unwrap_or_else(|_| Client::new()),
token: RwLock::new(None),
renewal_lock: Mutex::new(()),
store: RwLock::new(None),
user_id: RwLock::new("default".to_string()),
};
if let Err(e) = manager.load_session().await {
tracing::debug!("No existing session found: {}", e);
}
manager
}
/// Attach a database store for persisting session tokens.
///
/// When a store is attached, session tokens are saved to the `settings`
/// table (key: `nearai.session_token`) in addition to the disk file.
/// On load, DB is preferred over disk.
pub async fn attach_store(&self, store: Arc<dyn crate::db::Database>, user_id: &str) {
*self.store.write().await = Some(store);
*self.user_id.write().await = user_id.to_string();
// Try to load from DB (may have been saved by a previous run)
if let Err(e) = self.load_session_from_db().await {
tracing::debug!("No session in DB: {}", e);
}
}
/// Get the current session token, returning an error if not authenticated.
pub async fn get_token(&self) -> Result<SecretString, LlmError> {
let guard = self.token.read().await;
guard.clone().ok_or_else(|| LlmError::AuthFailed {
provider: "nearai".to_string(),
})
}
/// Check if we have a valid token (doesn't verify with server).
pub async fn has_token(&self) -> bool {
self.token.read().await.is_some()
}
/// Ensure we have a valid session, triggering login flow if needed.
///
/// If no token exists, triggers the OAuth login flow. If a token exists,
/// validates it by making a test API call. If validation fails, triggers
/// the login flow.
pub async fn ensure_authenticated(&self) -> Result<(), LlmError> {
if !self.has_token().await {
// No token, need to authenticate
return self.initiate_login().await;
}
// Token exists, validate it by calling /v1/users/me
println!("Validating session...");
match self.validate_token().await {
Ok(()) => {
println!("Session valid.");
Ok(())
}
Err(e) => {
println!("Session expired or invalid: {}", e);
self.initiate_login().await
}
}
}
/// Validate the current token by calling the /v1/users/me endpoint.
async fn validate_token(&self) -> Result<(), LlmError> {
use secrecy::ExposeSecret;
let token = self.get_token().await?;
let url = format!("{}/v1/users/me", self.config.auth_base_url);
let response = self
.client
.get(&url)
.header("Authorization", format!("Bearer {}", token.expose_secret()))
.send()
.await
.map_err(|e| LlmError::SessionRenewalFailed {
provider: "nearai".to_string(),
reason: format!("Validation request failed: {}", e),
})?;
if response.status().is_success() {
return Ok(());
}
if response.status().as_u16() == 401 {
return Err(LlmError::SessionExpired {
provider: "nearai".to_string(),
});
}
let status = response.status();
let body = response.text().await.unwrap_or_default();
Err(LlmError::SessionRenewalFailed {
provider: "nearai".to_string(),
reason: format!("Validation failed: HTTP {}: {}", status, body),
})
}
/// Handle an authentication failure (401 response).
///
/// Triggers the OAuth login flow to get a new session token.
pub async fn handle_auth_failure(&self) -> Result<(), LlmError> {
// Acquire renewal lock to prevent thundering herd
let _guard = self.renewal_lock.lock().await;
tracing::info!("Session expired or invalid, re-authenticating...");
self.initiate_login().await
}
/// Start the OAuth login flow.
///
/// 1. Bind the fixed callback port
/// 2. Print the auth URL and attempt to open browser
/// 3. Wait for OAuth callback with session token
/// 4. Save and return the token
async fn initiate_login(&self) -> Result<(), LlmError> {
use crate::cli::oauth_defaults::{self, OAUTH_CALLBACK_PORT};
let listener = oauth_defaults::bind_callback_listener()
.await
.map_err(|e| LlmError::SessionRenewalFailed {
provider: "nearai".to_string(),
reason: e.to_string(),
})?;
let callback_url = format!("http://127.0.0.1:{}", OAUTH_CALLBACK_PORT);
// Show auth provider menu
println!();
println!("╔════════════════════════════════════════════════════════════════╗");
println!("║ NEAR AI Authentication ║");
println!("╠════════════════════════════════════════════════════════════════╣");
println!("║ Choose an authentication method: ║");
println!("║ ║");
println!("║ [1] GitHub ║");
println!("║ [2] Google ║");
println!("║ [3] NEAR Wallet (coming soon) ║");
println!("║ ║");
println!("╚════════════════════════════════════════════════════════════════╝");
println!();
print!("Enter choice [1-3]: ");
// Flush stdout to ensure prompt is displayed
use std::io::Write;
std::io::stdout().flush().ok();
// Read user choice
let mut choice = String::new();
std::io::stdin()
.read_line(&mut choice)
.map_err(|e| LlmError::SessionRenewalFailed {
provider: "nearai".to_string(),
reason: format!("Failed to read input: {}", e),
})?;
let (auth_provider, auth_url) = match choice.trim() {
"1" | "" => {
let url = format!(
"{}/v1/auth/github?frontend_callback={}",
self.config.auth_base_url,
urlencoding::encode(&callback_url)
);
("github", url)
}
"2" => {
let url = format!(
"{}/v1/auth/google?frontend_callback={}",
self.config.auth_base_url,
urlencoding::encode(&callback_url)
);
("google", url)
}
"3" => {
println!();
println!("NEAR Wallet authentication is not yet implemented.");
println!("Please use GitHub or Google for now.");
return Err(LlmError::SessionRenewalFailed {
provider: "nearai".to_string(),
reason: "NEAR Wallet auth not yet implemented".to_string(),
});
}
_ => {
return Err(LlmError::SessionRenewalFailed {
provider: "nearai".to_string(),
reason: format!("Invalid choice: {}", choice.trim()),
});
}
};
println!();
println!("Opening {} authentication...", auth_provider);
println!();
println!(" {}", auth_url);
println!();
// Try to open browser automatically
if let Err(e) = open::that(&auth_url) {
tracing::debug!("Could not open browser automatically: {}", e);
println!("(Could not open browser automatically, please copy the URL above)");
} else {
println!("(Opening browser...)");
}
println!();
println!("Waiting for authentication...");
// The NEAR AI API redirects to: {frontend_callback}/auth/callback?token=X&...
let session_token =
oauth_defaults::wait_for_callback(listener, "/auth/callback", "token", "NEAR AI")
.await
.map_err(|e| LlmError::SessionRenewalFailed {
provider: "nearai".to_string(),
reason: e.to_string(),
})?;
let auth_provider = Some(auth_provider.to_string());
// Save the token
self.save_session(&session_token, auth_provider.as_deref())
.await?;
// Update in-memory token
{
let mut guard = self.token.write().await;
*guard = Some(SecretString::from(session_token));
}
println!();
println!("✓ Authentication successful!");
println!();
Ok(())
}
/// Save session data to disk and (if available) to the database.
async fn save_session(&self, token: &str, auth_provider: Option<&str>) -> Result<(), LlmError> {
let session = SessionData {
session_token: token.to_string(),
created_at: Utc::now(),
auth_provider: auth_provider.map(String::from),
};
// Save to disk (always, as bootstrap fallback)
if let Some(parent) = self.config.session_path.parent() {
tokio::fs::create_dir_all(parent).await.map_err(|e| {
LlmError::Io(std::io::Error::new(
e.kind(),
format!("Failed to create session directory: {}", e),
))
})?;
}
let json =
serde_json::to_string_pretty(&session).map_err(|e| LlmError::SessionRenewalFailed {
provider: "nearai".to_string(),
reason: format!("Failed to serialize session: {}", e),
})?;
tokio::fs::write(&self.config.session_path, json)
.await
.map_err(|e| {
LlmError::Io(std::io::Error::new(
e.kind(),
format!(
"Failed to write session file {}: {}",
self.config.session_path.display(),
e
),
))
})?;
// Restrictive permissions: session file contains a secret token
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o600);
tokio::fs::set_permissions(&self.config.session_path, perms)
.await
.map_err(|e| {
LlmError::Io(std::io::Error::new(
e.kind(),
format!(
"Failed to set permissions on {}: {}",
self.config.session_path.display(),
e
),
))
})?;
}
tracing::debug!("Session saved to {}", self.config.session_path.display());
// Also save to DB if a store is attached
if let Some(ref store) = *self.store.read().await {
let user_id = self.user_id.read().await.clone();
let session_json = serde_json::to_value(&session)
.unwrap_or(serde_json::Value::String(token.to_string()));
if let Err(e) = store
.set_setting(&user_id, "nearai.session_token", &session_json)
.await
{
tracing::warn!("Failed to save session to DB: {}", e);
} else {
tracing::debug!("Session also saved to DB settings");
}
}
Ok(())
}
/// Try to load session from the database.
async fn load_session_from_db(&self) -> Result<(), LlmError> {
let store_guard = self.store.read().await;
let store = store_guard
.as_ref()
.ok_or_else(|| LlmError::SessionRenewalFailed {
provider: "nearai".to_string(),
reason: "No DB store attached".to_string(),
})?;
let user_id = self.user_id.read().await.clone();
let value = store
.get_setting(&user_id, "nearai.session_token")
.await
.map_err(|e| LlmError::SessionRenewalFailed {
provider: "nearai".to_string(),
reason: format!("DB query failed: {}", e),
})?
.ok_or_else(|| LlmError::SessionRenewalFailed {
provider: "nearai".to_string(),
reason: "No session in DB".to_string(),
})?;
let session: SessionData =
serde_json::from_value(value).map_err(|e| LlmError::SessionRenewalFailed {
provider: "nearai".to_string(),
reason: format!("Failed to parse DB session: {}", e),
})?;
let mut guard = self.token.write().await;
*guard = Some(SecretString::from(session.session_token));
tracing::info!("Loaded session from DB settings");
Ok(())
}
/// Load session data from disk.
async fn load_session(&self) -> Result<(), LlmError> {
let data = tokio::fs::read_to_string(&self.config.session_path)
.await
.map_err(|e| {
LlmError::Io(std::io::Error::new(
e.kind(),
format!(
"Failed to read session file {}: {}",
self.config.session_path.display(),
e
),
))
})?;
let session: SessionData =
serde_json::from_str(&data).map_err(|e| LlmError::SessionRenewalFailed {
provider: "nearai".to_string(),
reason: format!("Failed to parse session file: {}", e),
})?;
{
let mut guard = self.token.write().await;
*guard = Some(SecretString::from(session.session_token));
}
tracing::info!(
"Loaded session from {} (created: {})",
self.config.session_path.display(),
session.created_at
);
Ok(())
}
/// Set token directly (useful for testing or migration from env var).
pub async fn set_token(&self, token: SecretString) {
let mut guard = self.token.write().await;
*guard = Some(token);
}
}
/// Create a session manager from a config, migrating from env var if present.
pub async fn create_session_manager(config: SessionConfig) -> Arc<SessionManager> {
let manager = SessionManager::new_async(config).await;
// Check for legacy env var and migrate if present and no file token
if !manager.has_token().await
&& let Ok(token) = std::env::var("NEARAI_SESSION_TOKEN")
&& !token.is_empty()
{
tracing::info!("Migrating session token from NEARAI_SESSION_TOKEN env var to file");
manager.set_token(SecretString::from(token.clone())).await;
if let Err(e) = manager.save_session(&token, None).await {
tracing::warn!("Failed to save migrated session: {}", e);
}
}
Arc::new(manager)
}
#[cfg(test)]
mod tests {
use super::*;
use secrecy::ExposeSecret;
use tempfile::tempdir;
#[tokio::test]
async fn test_session_save_load() {
let dir = tempdir().unwrap();
let session_path = dir.path().join("session.json");
let config = SessionConfig {
auth_base_url: "https://example.com".to_string(),
session_path: session_path.clone(),
};
let manager = SessionManager::new_async(config.clone()).await;
// No token initially
assert!(!manager.has_token().await);
// Save a token
manager
.save_session("test_token_123", Some("near"))
.await
.unwrap();
manager
.set_token(SecretString::from("test_token_123"))
.await;
// Verify it's set
assert!(manager.has_token().await);
let token = manager.get_token().await.unwrap();
assert_eq!(token.expose_secret(), "test_token_123");
// Create new manager and verify it loads the token
let manager2 = SessionManager::new_async(config).await;
assert!(manager2.has_token().await);
let token2 = manager2.get_token().await.unwrap();
assert_eq!(token2.expose_secret(), "test_token_123");
// Verify file contents
let data: SessionData =
serde_json::from_str(&std::fs::read_to_string(&session_path).unwrap()).unwrap();
assert_eq!(data.session_token, "test_token_123");
assert_eq!(data.auth_provider, Some("near".to_string()));
}
#[tokio::test]
async fn test_get_token_without_auth_fails() {
let dir = tempdir().unwrap();
let config = SessionConfig {
auth_base_url: "https://example.com".to_string(),
session_path: dir.path().join("nonexistent.json"),
};
let manager = SessionManager::new_async(config).await;
let result = manager.get_token().await;
assert!(result.is_err());
assert!(matches!(result, Err(LlmError::AuthFailed { .. })));
}
#[test]
fn test_default_session_path() {
let path = default_session_path();
assert!(path.ends_with("session.json"));
assert!(path.to_string_lossy().contains(".ironclaw"));
}
}