From 38b3e33d08f6af181fc8efefa589279b181fe38a Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 18 Jul 2026 04:26:32 +0800 Subject: [PATCH 1/2] feat: implement Step 1 onboarding readiness --- .env.example | 4 +- .github/workflows/ci.yml | 7 + apps/desktop/src-tauri/src/commands.rs | 481 ++--- apps/desktop/src-tauri/src/kernel/deepseek.rs | 243 +-- .../src/kernel/deepseek_credential.rs | 1637 +++++++++++++++++ .../src-tauri/src/kernel/deepseek_pricing.rs | 33 +- .../src-tauri/src/kernel/local_directory.rs | 425 ++++- apps/desktop/src-tauri/src/kernel/mod.rs | 1 + .../src-tauri/src/kernel/work_package.rs | 113 +- apps/desktop/src-tauri/src/main.rs | 41 +- apps/desktop/src/App.tsx | 582 +++--- apps/desktop/src/i18n.ts | 176 +- apps/desktop/src/settingsPanel.ts | 16 +- apps/desktop/src/styles.css | 30 + apps/desktop/src/types.ts | 108 +- scripts/app-update-flow.test.mjs | 2 +- .../deepseek-operations-briefing-smoke.mjs | 2 +- scripts/deepseek-smoke.mjs | 2 +- scripts/desktop-test.mjs | 1 + scripts/onboarding-readiness.test.mjs | 177 ++ scripts/release-local-check.mjs | 37 +- scripts/release-source-check.mjs | 158 +- scripts/settings-panel.test.mjs | 30 +- scripts/windows-installed-ui-smoke.mjs | 359 +++- scripts/windows-local-smoke.mjs | 2 +- 25 files changed, 3864 insertions(+), 803 deletions(-) create mode 100644 apps/desktop/src-tauri/src/kernel/deepseek_credential.rs create mode 100644 scripts/onboarding-readiness.test.mjs diff --git a/.env.example b/.env.example index 34b53bb..a5dfbc4 100644 --- a/.env.example +++ b/.env.example @@ -9,12 +9,12 @@ DEEPSEEK_API_KEY= # DEEPSEEK_API_BASE_URL=https://api.deepseek.com # Optional. Used only by `pnpm test:deepseek` local smoke tests. -# DEEPSEEK_SMOKE_MODEL=deepseek-chat +# DEEPSEEK_SMOKE_MODEL=deepseek-v4-flash # Optional. Used only by `pnpm test:deepseek:briefing` local workflow smoke tests. # The bundled evidence folder is SMOKE SAMPLE evidence for local verification only. # Replace before operational use when pointing the workflow at real business evidence. -# DEEPSEEK_BRIEFING_SMOKE_MODEL=deepseek-chat +# DEEPSEEK_BRIEFING_SMOKE_MODEL=deepseek-v4-flash # DEEPSEEK_BRIEFING_EVIDENCE_DIR=docs/templates/operations-briefing-smoke-evidence # DEEPSEEK_BRIEFING_SMOKE_MAX_TOKENS=900 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6a6a553..c55534a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,6 +44,13 @@ jobs: - name: Build desktop frontend run: pnpm --filter @deepseek-agent-os/desktop build + - name: Run onboarding and settings contract tests + run: | + node scripts/settings-panel.test.mjs + node scripts/onboarding-readiness.test.mjs + node scripts/windows-installed-ui-smoke.mjs --self-test + node scripts/release-local-check.mjs --self-test + - name: Run Rust tests env: CARGO_TARGET_DIR: ${{ runner.temp }}\deepseek-agent-os-cargo-target diff --git a/apps/desktop/src-tauri/src/commands.rs b/apps/desktop/src-tauri/src/commands.rs index f8f0727..d6e1170 100644 --- a/apps/desktop/src-tauri/src/commands.rs +++ b/apps/desktop/src-tauri/src/commands.rs @@ -9,6 +9,7 @@ use chrono::{DateTime, Duration, Utc}; use serde::{Deserialize, Serialize}; use tauri::{AppHandle, Manager, State}; use uuid::Uuid; +use zeroize::Zeroizing; use crate::connected_work_commands::{ dispatch_connected_work_agent_action, is_connected_work_agent_action, @@ -71,12 +72,16 @@ use crate::kernel::connectors::runtime_registry::{ ConnectorMutationRegistry, ConnectorOAuthRegistry, ConnectorRuntimeRegistries, }; use crate::kernel::deepseek::{ - build_deepseek_chat_completion_request, current_deepseek_credential_status, - execute_deepseek_chat_completion, execute_deepseek_chat_completion_with_cache, - execute_deepseek_user_balance, DeepSeekChatCacheState, DeepSeekChatCacheStatus, - DeepSeekChatCompletionTransport, DeepSeekChatTelemetry, DeepSeekCredentialStatus, - DeepSeekMemoryChatCompletionCache, DeepSeekOperationsBriefingSynthesizer, - DeepSeekUserBalanceResponse, HttpDeepSeekChatCompletionTransport, DEEPSEEK_API_KEY_ENV, + build_deepseek_chat_completion_request, execute_deepseek_chat_completion, + execute_deepseek_chat_completion_with_cache, DeepSeekChatCacheState, DeepSeekChatCacheStatus, + DeepSeekChatCompletionTransport, DeepSeekChatTelemetry, DeepSeekMemoryChatCompletionCache, + DeepSeekOperationsBriefingSynthesizer, HttpDeepSeekChatCompletionTransport, +}; +#[cfg(windows)] +use crate::kernel::deepseek_credential::{ + build_onboarding_readiness_projection, DeepSeekCredentialRuntime, DeepSeekReadinessProjection, + FileDeepSeekCredentialStateStore, OnboardingReadinessProjection, + WindowsDeepSeekCredentialStore, }; use crate::kernel::deepseek_pricing::{ estimate_deepseek_chat_cost_micro_usd, load_deepseek_pricing_state, @@ -93,8 +98,9 @@ use crate::kernel::expert_team::{ use crate::kernel::local_directory::{ load_local_directory_state, local_directory_readiness_from_state, save_local_directory_settings as persist_local_directory_settings, + workspace_readiness_projection_from_setup_error, workspace_readiness_projection_from_state, LocalDirectoryReadinessStatus, LocalDirectorySettings, LocalDirectoryState, - LOCAL_MEMORY_DIR_NAME, + WorkspaceReadinessProjection, LOCAL_MEMORY_DIR_NAME, }; use crate::kernel::models::FoundationState; use crate::kernel::models::TaskRecord; @@ -187,6 +193,8 @@ pub struct AppState { connector_runtime: Arc>, computer_control_unlock: Arc>, deepseek_chat_cache: Arc, + #[cfg(windows)] + deepseek_credentials: Arc>, } impl AppState { @@ -200,15 +208,31 @@ impl AppState { event_store .recover_workspace_mutation_checkpoints(Utc::now()) .map_err(event_store_error)?; + #[cfg(windows)] + let connector_vault_root = connector_vault_root.as_ref().to_path_buf(); + #[cfg(windows)] + let deepseek_vault_root = connector_vault_root + .parent() + .unwrap_or(&connector_vault_root) + .join("deepseek-credential"); + #[cfg(windows)] + let deepseek_credentials = Arc::new(DeepSeekCredentialRuntime::new( + WindowsDeepSeekCredentialStore::new(&deepseek_vault_root) + .map_err(|code| code.as_str().to_string())?, + FileDeepSeekCredentialStateStore::new(&deepseek_vault_root) + .map_err(|code| code.as_str().to_string())?, + )); Ok(Self { event_store: Arc::new(Mutex::new(event_store)), connector_registries: Arc::new(ConnectorRuntimeRegistries::empty()), #[cfg(windows)] connector_runtime: Arc::new(ConnectorRuntime::new( - WindowsConnectorCredentialStore::new(connector_vault_root)?, + WindowsConnectorCredentialStore::new(&connector_vault_root)?, )), computer_control_unlock: Arc::new(Mutex::new(ComputerControlUnlockState::generated())), deepseek_chat_cache: Arc::new(DeepSeekMemoryChatCompletionCache::default()), + #[cfg(windows)] + deepseek_credentials, }) } @@ -4407,8 +4431,8 @@ fn dispatch_agent_app_update_action_with_store_mutex( fn current_work_package_tool_readiness( local_directories: LocalDirectoryReadinessStatus, + deepseek: DeepSeekReadinessProjection, ) -> WorkPackageToolReadiness { - let deepseek = current_deepseek_credential_status(); let foundation_state = FoundationState::default(); let tool_strategy = model_driven_tool_strategy_for_current_platform( foundation_state.large_model_provider, @@ -4457,6 +4481,45 @@ fn current_local_directory_readiness( Ok(local_directory_readiness_from_state(&directory_state)) } +#[cfg(windows)] +fn current_workspace_readiness(app: &AppHandle) -> WorkspaceReadinessProjection { + let app_data_dir = match app.path().app_data_dir() { + Ok(path) => path, + Err(_) => return WorkspaceReadinessProjection::settings_invalid(), + }; + match load_local_directory_state(app_data_dir) { + Ok(state) => workspace_readiness_projection_from_state(&state), + Err(_) => WorkspaceReadinessProjection::settings_invalid(), + } +} + +#[cfg(windows)] +fn onboarding_projection( + app: &AppHandle, + _state: &AppState, + deepseek: DeepSeekReadinessProjection, +) -> OnboardingReadinessProjection { + build_onboarding_readiness_projection( + deepseek, + current_workspace_readiness(app), + env!("CARGO_PKG_VERSION"), + ) +} + +#[cfg(windows)] +fn current_onboarding_projection( + app: &AppHandle, + state: &AppState, +) -> OnboardingReadinessProjection { + onboarding_projection( + app, + state, + state + .deepseek_credentials + .projection(|name| std::env::var(name).ok()), + ) +} + fn computer_tool_strategy_for_command( large_model_provider: LargeModelProvider, network_search_source_model: Option, @@ -4530,19 +4593,6 @@ fn agent_file_write_client( }) } -fn operations_briefing_deepseek_api_key_for_provider( - read_env: impl Fn(&str) -> Option, - large_model_provider: LargeModelProvider, -) -> Option { - if large_model_provider != LargeModelProvider::DeepSeek { - return None; - } - - read_env(DEEPSEEK_API_KEY_ENV) - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) -} - fn deepseek_telemetry_with_pricing( mut telemetry: Vec, pricing_settings: Option<&DeepSeekPricingSettings>, @@ -4628,48 +4678,6 @@ fn deepseek_cache_status_context_label(status: DeepSeekChatCacheStatus) -> &'sta } } -fn agent_chat_api_key_from_env(read_env: impl Fn(&str) -> Option) -> Option { - read_env(DEEPSEEK_API_KEY_ENV).and_then(|value| normalize_agent_chat_api_key(Some(value))) -} - -fn normalize_agent_chat_api_key(value: Option) -> Option { - value.and_then(|value| { - let trimmed = value.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed.to_string()) - } - }) -} - -pub fn agent_chat_api_key_from_sources( - session_api_key: Option, - read_env: impl Fn(&str) -> Option, -) -> Option { - normalize_agent_chat_api_key(session_api_key).or_else(|| agent_chat_api_key_from_env(read_env)) -} - -fn push_unique_agent_chat_api_key(candidates: &mut Vec, value: Option) { - if let Some(api_key) = normalize_agent_chat_api_key(value) { - if !candidates.iter().any(|candidate| candidate == &api_key) { - candidates.push(api_key); - } - } -} - -pub fn agent_chat_api_key_candidates_from_sources( - primary_api_key: Option, - fallback_api_key: Option, - read_env: impl Fn(&str) -> Option, -) -> Vec { - let mut candidates = Vec::new(); - push_unique_agent_chat_api_key(&mut candidates, primary_api_key); - push_unique_agent_chat_api_key(&mut candidates, fallback_api_key); - push_unique_agent_chat_api_key(&mut candidates, read_env(DEEPSEEK_API_KEY_ENV)); - candidates -} - fn agent_chat_response_from_telemetry( content: String, telemetry: &DeepSeekChatTelemetry, @@ -8557,9 +8565,9 @@ fn agent_chat_with_transport_and_runtime_context_with_api_key_fallback( request: AgentChatRequest, runtime_context: AgentChatRuntimeContext, pricing_settings: Option<&DeepSeekPricingSettings>, -) -> Result<(AgentChatResponse, DeepSeekChatTelemetry, String), String> { +) -> Result<(AgentChatResponse, DeepSeekChatTelemetry, usize), String> { let mut errors = Vec::new(); - for api_key in api_keys { + for (index, api_key) in api_keys.iter().enumerate() { match agent_chat_with_transport_and_runtime_context( transport, cache, @@ -8568,7 +8576,7 @@ fn agent_chat_with_transport_and_runtime_context_with_api_key_fallback( runtime_context.clone(), pricing_settings, ) { - Ok((response, telemetry)) => return Ok((response, telemetry, api_key.clone())), + Ok((response, telemetry)) => return Ok((response, telemetry, index)), Err(error) => errors.push(error), } } @@ -8576,7 +8584,7 @@ fn agent_chat_with_transport_and_runtime_context_with_api_key_fallback( Err(errors .last() .cloned() - .unwrap_or_else(|| "DeepSeek Chat is not configured. Provide a DeepSeek API key for this session or set DEEPSEEK_API_KEY in the local desktop process.".to_string())) + .unwrap_or_else(|| "key_missing".to_string())) } fn run_agent_chat_with_clients( @@ -8666,7 +8674,7 @@ fn run_agent_chat_with_clients_and_api_keys_and_computer_use( let mut runtime_context = runtime_context; runtime_context.memory_context = memory_context; runtime_context.skill_catalog = skill_catalog; - let (response, first_telemetry, followup_api_key) = + let (response, first_telemetry, followup_api_key_index) = agent_chat_with_transport_and_runtime_context_with_api_key_fallback( transport, cache, @@ -8723,7 +8731,7 @@ fn run_agent_chat_with_clients_and_api_keys_and_computer_use( let result = agent_chat_with_transport_and_runtime_context( transport, cache, - &followup_api_key, + &api_keys[followup_api_key_index], AgentChatRequest { prompt: followup_prompt, ..request.clone() @@ -13885,8 +13893,49 @@ pub fn execute_agent_tool( } #[tauri::command] -pub fn get_deepseek_credential_status() -> DeepSeekCredentialStatus { - current_deepseek_credential_status() +pub fn get_onboarding_readiness( + app: AppHandle, + state: State<'_, AppState>, +) -> OnboardingReadinessProjection { + current_onboarding_projection(&app, &state) +} + +#[tauri::command] +pub fn save_deepseek_api_key( + app: AppHandle, + api_key: String, + state: State<'_, AppState>, +) -> Result { + let transport = HttpDeepSeekChatCompletionTransport::new() + .map_err(|_| "provider_protocol_error".to_string())?; + let deepseek = state + .deepseek_credentials + .save_and_verify(api_key, &transport, |name| std::env::var(name).ok()); + Ok(onboarding_projection(&app, &state, deepseek)) +} + +#[tauri::command] +pub fn verify_deepseek_api_key( + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + let transport = HttpDeepSeekChatCompletionTransport::new() + .map_err(|_| "provider_protocol_error".to_string())?; + let deepseek = state + .deepseek_credentials + .verify(&transport, |name| std::env::var(name).ok()); + Ok(onboarding_projection(&app, &state, deepseek)) +} + +#[tauri::command] +pub fn remove_deepseek_api_key( + app: AppHandle, + state: State<'_, AppState>, +) -> OnboardingReadinessProjection { + let deepseek = state + .deepseek_credentials + .remove(|name| std::env::var(name).ok()); + onboarding_projection(&app, &state, deepseek) } #[tauri::command] @@ -13907,19 +13956,13 @@ pub fn run_agent_chat( thinking_level: ThinkingLevel, access_mode: AccessMode, network_search_source_model: Option, - api_key_override: Option, - fallback_api_key_override: Option, state: State<'_, AppState>, ) -> Result { - let api_keys = agent_chat_api_key_candidates_from_sources( - api_key_override, - fallback_api_key_override, - |name| std::env::var(name).ok(), - ); - if api_keys.is_empty() { - return Err("DeepSeek Chat is not configured. Provide a DeepSeek API key for this session or set DEEPSEEK_API_KEY in the local desktop process." - .to_string()); - } + let api_key = state + .deepseek_credentials + .resolve_ready_key(|name| std::env::var(name).ok()) + .map_err(|code| code.as_str().to_string())?; + let api_keys = Zeroizing::new(vec![api_key.expose().to_string()]); let pricing_settings = app .path() .app_data_dir() @@ -13947,11 +13990,11 @@ pub fn run_agent_chat( &computer_strategy, computer_screenshot_evidence_base_dir(&app_data_dir, &directory_state), ); - run_agent_chat_with_clients_and_api_keys_and_computer_use( + let result = run_agent_chat_with_clients_and_api_keys_and_computer_use( &state.event_store, &transport, &state.deepseek_chat_cache, - &api_keys, + api_keys.as_slice(), AgentChatRequest { prompt, model_route, @@ -13965,7 +14008,8 @@ pub fn run_agent_chat( &search_client, &browser_client, &computer_use_client, - ) + ); + result } #[tauri::command] @@ -13979,22 +14023,16 @@ pub async fn run_next_queued_agent_chat_worker( thinking_level: ThinkingLevel, access_mode: AccessMode, network_search_source_model: Option, - api_key_override: Option, - fallback_api_key_override: Option, state: State<'_, AppState>, ) -> Result, String> { let event_store = Arc::clone(&state.event_store); let cache = Arc::clone(&state.deepseek_chat_cache); + let credentials = Arc::clone(&state.deepseek_credentials); tauri::async_runtime::spawn_blocking(move || { - let api_keys = agent_chat_api_key_candidates_from_sources( - api_key_override, - fallback_api_key_override, - |name| std::env::var(name).ok(), - ); - if api_keys.is_empty() { - return Err("DeepSeek Chat is not configured. Provide a DeepSeek API key for this session or set DEEPSEEK_API_KEY in the local desktop process." - .to_string()); - } + let api_key = credentials + .resolve_ready_key(|name| std::env::var(name).ok()) + .map_err(|code| code.as_str().to_string())?; + let api_keys = Zeroizing::new(vec![api_key.expose().to_string()]); let pricing_settings = app .path() .app_data_dir() @@ -14010,7 +14048,8 @@ pub async fn run_next_queued_agent_chat_worker( let transport = HttpDeepSeekChatCompletionTransport::new()?; let app_data_dir = app.path().app_data_dir().map_err(event_store_error)?; runtime_context.expert_staging_root = Some(app_data_dir.join("expert-team-staging")); - let directory_state = load_local_directory_state(&app_data_dir).map_err(event_store_error)?; + let directory_state = + load_local_directory_state(&app_data_dir).map_err(event_store_error)?; let desktop_dir = runtime_context.desktop_dir.clone(); let file_client = LocalFileContentClient::new(512 * 1024); let file_write_client = agent_file_write_client(&directory_state, desktop_dir)?; @@ -14024,11 +14063,11 @@ pub async fn run_next_queued_agent_chat_worker( computer_screenshot_evidence_base_dir(&app_data_dir, &directory_state), ); - run_queued_agent_chat_with_clients_and_api_keys_and_computer_use( + let result = run_queued_agent_chat_with_clients_and_api_keys_and_computer_use( &event_store, &transport, &cache, - &api_keys, + api_keys.as_slice(), run_id, execution_prompt, worker_id, @@ -14042,41 +14081,13 @@ pub async fn run_next_queued_agent_chat_worker( &search_client, &browser_client, &computer_use_client, - ) + ); + result }) .await .map_err(|error| format!("agent worker task failed: {error}"))? } -#[tauri::command] -pub fn get_deepseek_user_balance( - api_key_override: Option, - fallback_api_key_override: Option, -) -> Result { - let api_keys = agent_chat_api_key_candidates_from_sources( - api_key_override, - fallback_api_key_override, - |name| std::env::var(name).ok(), - ); - if api_keys.is_empty() { - return Err("DeepSeek API key is required to query user balance.".to_string()); - } - - let transport = HttpDeepSeekChatCompletionTransport::new()?; - let mut errors = Vec::new(); - for api_key in api_keys { - match execute_deepseek_user_balance(&transport, &api_key) { - Ok(balance) => return Ok(balance), - Err(error) => errors.push(error), - } - } - - Err(errors - .last() - .cloned() - .unwrap_or_else(|| "DeepSeek balance query failed.".to_string())) -} - #[tauri::command] pub fn resume_agent_chat_action( app: AppHandle, @@ -14276,8 +14287,10 @@ pub fn save_deepseek_pricing_settings( } #[tauri::command] -pub fn get_network_search_route_status() -> NetworkSearchRouteStatus { - let deepseek_status = current_deepseek_credential_status(); +pub fn get_network_search_route_status(state: State<'_, AppState>) -> NetworkSearchRouteStatus { + let deepseek_status = state + .deepseek_credentials + .projection(|name| std::env::var(name).ok()); let foundation_state = FoundationState::default(); let tool_strategy = model_driven_tool_strategy_for_current_platform( foundation_state.large_model_provider, @@ -14725,8 +14738,11 @@ pub fn list_durable_computer_use_steps( pub fn get_network_search_route_status_for_model( large_model_provider: LargeModelProvider, network_search_source_model: Option, + state: State<'_, AppState>, ) -> NetworkSearchRouteStatus { - let deepseek_status = current_deepseek_credential_status(); + let deepseek_status = state + .deepseek_credentials + .projection(|name| std::env::var(name).ok()); let tool_strategy = model_driven_tool_strategy_for_current_platform( large_model_provider, network_search_source_model, @@ -14758,9 +14774,8 @@ pub fn get_model_driven_tool_strategy( } #[tauri::command] -pub fn get_local_directory_state(app: AppHandle) -> Result { - let app_data_dir = app.path().app_data_dir().map_err(event_store_error)?; - load_local_directory_state(app_data_dir).map_err(event_store_error) +pub fn get_local_directory_state(app: AppHandle) -> WorkspaceReadinessProjection { + current_workspace_readiness(&app) } #[tauri::command] @@ -14783,12 +14798,25 @@ pub fn save_local_directory_settings( app: AppHandle, workspace_dir: String, workspace_name: String, -) -> Result { + state: State<'_, AppState>, +) -> Result { let app_data_dir = app.path().app_data_dir().map_err(event_store_error)?; - let settings = - LocalDirectorySettings::from_workspace_dir_and_name(workspace_dir, workspace_name) - .map_err(event_store_error)?; - persist_local_directory_settings(app_data_dir, settings).map_err(event_store_error) + let workspace = + match LocalDirectorySettings::from_workspace_dir_and_name(workspace_dir, workspace_name) { + Ok(settings) => match persist_local_directory_settings(app_data_dir, settings) { + Ok(directory_state) => workspace_readiness_projection_from_state(&directory_state), + Err(error) => workspace_readiness_projection_from_setup_error(&error), + }, + Err(error) => workspace_readiness_projection_from_setup_error(&error), + }; + let deepseek = state + .deepseek_credentials + .projection(|name| std::env::var(name).ok()); + Ok(build_onboarding_readiness_projection( + deepseek, + workspace, + env!("CARGO_PKG_VERSION"), + )) } #[tauri::command] @@ -15369,19 +15397,18 @@ pub fn list_memory_maintenance_reviews( #[tauri::command] pub fn run_memory_background_maintenance( - api_key_override: Option, - fallback_api_key_override: Option, + app: AppHandle, state: State<'_, AppState>, ) -> Result { - let api_keys = agent_chat_api_key_candidates_from_sources( - api_key_override, - fallback_api_key_override, - |name| std::env::var(name).ok(), - ); - let store = state.event_store.lock().map_err(|_| lock_error())?; - if let Some(api_key) = api_keys.first() { + let app_data_dir = app.path().app_data_dir().map_err(event_store_error)?; + let store = + EventStore::open(app_data_dir.join("kernel-events.sqlite3")).map_err(event_store_error)?; + if let Ok(api_key) = state + .deepseek_credentials + .resolve_ready_key(|name| std::env::var(name).ok()) + { let transport = HttpDeepSeekChatCompletionTransport::new()?; - run_memory_background_maintenance_with_model_in_store(&store, &transport, api_key) + run_memory_background_maintenance_with_model_in_store(&store, &transport, api_key.expose()) } else { run_memory_background_maintenance_in_store(&store) } @@ -17010,7 +17037,12 @@ pub fn write_drive_boundary( task_records, memory_candidates, operations_briefing_runs, - current_work_package_tool_readiness(current_local_directory_readiness(&app)?), + current_work_package_tool_readiness( + current_local_directory_readiness(&app)?, + state + .deepseek_credentials + .projection(|name| std::env::var(name).ok()), + ), ); serde_json::to_string_pretty(&package).map_err(event_store_error)? }; @@ -17068,15 +17100,20 @@ pub fn run_operations_briefing( approval_granted, }; let mut deepseek_telemetry = Vec::new(); - let mut outcome = if let Some(api_key) = operations_briefing_deepseek_api_key_for_provider( - |name| std::env::var(name).ok(), - large_model_provider, - ) { + let resolved_api_key = (large_model_provider == LargeModelProvider::DeepSeek) + .then(|| { + state + .deepseek_credentials + .resolve_ready_key(|name| std::env::var(name).ok()) + .ok() + }) + .flatten(); + let mut outcome = if let Some(api_key) = resolved_api_key.as_ref() { let transport = HttpDeepSeekChatCompletionTransport::new()?; let synthesizer = DeepSeekOperationsBriefingSynthesizer::new_with_cache( &transport, state.deepseek_chat_cache.as_ref(), - api_key, + api_key.expose().to_string(), model_route, thinking_level, ); @@ -17433,7 +17470,12 @@ pub fn export_work_package( task_records, memory_candidates, operations_briefing_runs, - current_work_package_tool_readiness(current_local_directory_readiness(&app)?), + current_work_package_tool_readiness( + current_local_directory_readiness(&app)?, + state + .deepseek_credentials + .projection(|name| std::env::var(name).ok()), + ), )) } @@ -17490,7 +17532,6 @@ mod tests { NetworkSearchAgentToolExecutor, TerminalReadAgentToolExecutor, WorkspaceCheckpointStore, }; use crate::commands::{ - agent_chat_api_key_candidates_from_sources, agent_chat_api_key_from_sources, agent_chat_with_dispatch_and_tool_followup, agent_chat_with_transport, apply_memory_candidate_update_if_current, block_subagent_mutating_actions, computer_screenshot_evidence_base_dir, computer_tool_strategy_for_command, @@ -17510,9 +17551,9 @@ mod tests { dispatch_agent_skill_activate_tool_with_executor, dispatch_agent_skill_create_action, dispatch_agent_skill_uninstall_action, ensure_system_skill_builder, link_existing_memory_records, list_memory_maintenance_reviews_from_store, - normalize_agent_action_proposal, operations_briefing_deepseek_api_key_for_provider, - operations_briefing_model_route_context, operations_briefing_report_export_dir, - operations_briefing_template_seed_dir, operations_briefing_token_cache_context, + normalize_agent_action_proposal, operations_briefing_model_route_context, + operations_briefing_report_export_dir, operations_briefing_template_seed_dir, + operations_briefing_token_cache_context, propose_memory_update_candidate_from_feedback_in_store, record_agent_action_permission_requests, record_memory_maintenance_review_action_in_store, register_completed_office_artifact, resume_agent_chat_action_with_clients, @@ -17592,6 +17633,55 @@ mod tests { use std::time::Duration as StdDuration; use uuid::Uuid; + fn command_source_section(source: &'static str, signature: &str) -> &'static str { + let start = source + .find(signature) + .expect("command signature is present"); + let remainder = &source[start + signature.len()..]; + let end = remainder + .find("\n#[tauri::command]") + .unwrap_or(remainder.len()); + &source[start..start + signature.len() + end] + } + + #[test] + fn deepseek_command_consumers_use_the_kernel_credential_runtime() { + const COMMANDS: &str = include_str!("commands.rs"); + for signature in [ + "pub fn run_agent_chat(", + "pub async fn run_next_queued_agent_chat_worker(", + "pub fn run_memory_background_maintenance(", + "pub fn run_operations_briefing(", + ] { + let section = command_source_section(COMMANDS, signature); + assert!(section.contains("deepseek_credentials"), "{signature}"); + assert!(section.contains("resolve_ready_key"), "{signature}"); + assert!( + !section.contains("std::env::var(DEEPSEEK_API_KEY_ENV)"), + "{signature}" + ); + } + + let save = command_source_section(COMMANDS, "pub fn save_deepseek_api_key("); + let verify = command_source_section(COMMANDS, "pub fn verify_deepseek_api_key("); + assert!(save.contains("deepseek_credentials")); + assert!(save.contains("save_and_verify")); + assert!(verify.contains("deepseek_credentials")); + assert!(verify.contains(".verify(")); + + const CREDENTIAL_RUNTIME: &str = include_str!("kernel/deepseek_credential.rs"); + let balance = CREDENTIAL_RUNTIME + .find("verifier.fetch_user_balance") + .expect("balance verification is present"); + let models = CREDENTIAL_RUNTIME + .find("verifier.fetch_models") + .expect("model verification is present"); + assert!( + balance < models, + "balance verification must precede model verification" + ); + } + #[test] fn agent_chat_prompt_keeps_internal_receipts_out_of_ordinary_replies() { let prompt = super::AGENT_CHAT_SYSTEM_PROMPT; @@ -29567,48 +29657,6 @@ schema_version: 1 assert!(transport.recorded_requests().is_empty()); } - #[test] - fn agent_chat_api_key_prefers_session_override_without_persisting_secret() { - let api_key = agent_chat_api_key_from_sources(Some(" session-key ".to_string()), |_| { - Some("env-key".to_string()) - }) - .expect("session key should be accepted"); - - assert_eq!(api_key, "session-key"); - } - - #[test] - fn agent_chat_api_key_falls_back_to_environment_when_override_blank() { - let api_key = agent_chat_api_key_from_sources(Some(" ".to_string()), |_| { - Some(" env-key ".to_string()) - }) - .expect("env key should be accepted"); - - assert_eq!(api_key, "env-key"); - } - - #[test] - fn agent_chat_api_key_candidates_keep_primary_fallback_then_environment() { - let api_keys = agent_chat_api_key_candidates_from_sources( - Some(" primary-key ".to_string()), - Some(" fallback-key ".to_string()), - |name| (name == DEEPSEEK_API_KEY_ENV).then_some(" env-key ".to_string()), - ); - - assert_eq!(api_keys, vec!["primary-key", "fallback-key", "env-key"]); - } - - #[test] - fn agent_chat_api_key_candidates_drop_blanks_and_duplicates() { - let api_keys = agent_chat_api_key_candidates_from_sources( - Some(" same-key ".to_string()), - Some("same-key".to_string()), - |name| (name == DEEPSEEK_API_KEY_ENV).then_some(" ".to_string()), - ); - - assert_eq!(api_keys, vec!["same-key"]); - } - #[test] fn computer_screenshot_evidence_base_prefers_user_evidence_dir() { let app_data_dir = std::path::PathBuf::from("fixtures/app-data"); @@ -29960,31 +30008,6 @@ schema_version: 1 } } - #[test] - fn operations_briefing_uses_deepseek_key_only_for_deepseek_provider() { - let key = operations_briefing_deepseek_api_key_for_provider( - |name| (name == "DEEPSEEK_API_KEY").then_some("test-secret-token".to_string()), - LargeModelProvider::DeepSeek, - ); - let chatgpt_key = operations_briefing_deepseek_api_key_for_provider( - |name| (name == "DEEPSEEK_API_KEY").then_some("test-secret-token".to_string()), - LargeModelProvider::ChatGpt, - ); - - assert_eq!(key.as_deref(), Some("test-secret-token")); - assert_eq!(chatgpt_key, None); - } - - #[test] - fn operations_briefing_ignores_blank_deepseek_key() { - let key = operations_briefing_deepseek_api_key_for_provider( - |name| (name == "DEEPSEEK_API_KEY").then_some(" ".to_string()), - LargeModelProvider::DeepSeek, - ); - - assert_eq!(key, None); - } - #[test] fn deepseek_telemetry_with_pricing_sets_cost_estimate() { let telemetry = DeepSeekChatTelemetry { diff --git a/apps/desktop/src-tauri/src/kernel/deepseek.rs b/apps/desktop/src-tauri/src/kernel/deepseek.rs index 5cde9db..3b06e35 100644 --- a/apps/desktop/src-tauri/src/kernel/deepseek.rs +++ b/apps/desktop/src-tauri/src/kernel/deepseek.rs @@ -6,8 +6,10 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::collections::HashMap; +use std::io::Read; use std::sync::Mutex; use std::time::Instant; +use zeroize::{Zeroize, Zeroizing}; pub const DEEPSEEK_AUTO_LABEL: &str = "DeepSeek Auto"; pub const DEEPSEEK_FLASH_MODEL: &str = "deepseek-v4-flash"; @@ -15,20 +17,10 @@ pub const DEEPSEEK_PRO_MODEL: &str = "deepseek-v4-pro"; pub const DEEPSEEK_API_BASE_URL: &str = "https://api.deepseek.com"; pub const DEEPSEEK_CHAT_COMPLETIONS_PATH: &str = "/chat/completions"; pub const DEEPSEEK_USER_BALANCE_PATH: &str = "/user/balance"; +pub const DEEPSEEK_MODELS_PATH: &str = "/models"; pub const DEEPSEEK_API_KEY_ENV: &str = "DEEPSEEK_API_KEY"; pub const DEEPSEEK_CHAT_HTTP_TIMEOUT_SECS: u64 = 90; - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -pub struct DeepSeekCredentialStatus { - pub base_url: String, - pub chat_completions_url: String, - pub api_key_env_var: String, - pub api_key_configured: bool, - pub chat_completion_ready: bool, - pub flash_model: String, - pub pro_model: String, - pub readiness_note: String, -} +const DEEPSEEK_RESPONSE_MAX_BYTES: usize = 256 * 1024; #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct DeepSeekUserBalanceInfo { @@ -45,6 +37,41 @@ pub struct DeepSeekUserBalanceResponse { pub balance_infos: Vec, } +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +pub struct DeepSeekModelDescriptor { + pub id: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +pub struct DeepSeekModelListResponse { + #[serde(default)] + pub data: Vec, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)] +pub enum DeepSeekTransportFailure { + #[error("deepseek_http_status_{0}")] + HttpStatus(u16), + #[error("deepseek_network_unavailable")] + NetworkUnavailable, + #[error("deepseek_network_timeout")] + Timeout, + #[error("deepseek_provider_protocol_error")] + Protocol, +} + +pub trait DeepSeekReadinessTransport { + fn fetch_user_balance( + &self, + api_key: &str, + ) -> Result; + + fn fetch_models( + &self, + api_key: &str, + ) -> Result; +} + #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "lowercase")] pub enum DeepSeekChatRole { @@ -229,10 +256,16 @@ pub struct DeepSeekOperationsBriefingSynthesizer<'a, T: DeepSeekChatCompletionTr thinking: ThinkingLevel, } +impl Drop for DeepSeekOperationsBriefingSynthesizer<'_, T> { + fn drop(&mut self) { + self.api_key.zeroize(); + } +} + impl HttpDeepSeekChatCompletionTransport { pub fn new() -> Result { let client = reqwest::blocking::Client::builder() - .user_agent("DeepSeek-Agent-OS/0.1.0 deepseek-chat") + .user_agent("DS-Agent/1.0.2 deepseek-v4") .timeout(std::time::Duration::from_secs( DEEPSEEK_CHAT_HTTP_TIMEOUT_SECS, )) @@ -256,53 +289,99 @@ impl DeepSeekChatCompletionTransport for HttpDeepSeekChatCompletionTransport { .bearer_auth(api_key) .json(request) .send() - .map_err(|error| format!("deepseek chat request failed: {error}"))?; + .map_err(|error| safe_transport_failure(classify_reqwest_error(&error)))?; let status = response.status(); - let body = response - .text() - .map_err(|error| format!("deepseek chat response could not be read: {error}"))?; - if !status.is_success() { - return Err(format!( - "deepseek chat request returned HTTP {}: {}", - status.as_u16(), - truncate_for_error(&body, 240) + return Err(safe_transport_failure( + DeepSeekTransportFailure::HttpStatus(status.as_u16()), )); } - - serde_json::from_str::(&body) - .map_err(|error| format!("deepseek chat response could not be parsed: {error}")) + let mut body = read_bounded_response(response).map_err(safe_transport_failure)?; + let parsed = serde_json::from_slice::(&body) + .map_err(|_| safe_transport_failure(DeepSeekTransportFailure::Protocol)); + body.zeroize(); + parsed } } impl HttpDeepSeekChatCompletionTransport { - pub fn get_user_balance( + fn fetch_json Deserialize<'de>>( &self, endpoint: &str, api_key: &str, - ) -> Result { + ) -> Result { let response = self .client .get(endpoint) .bearer_auth(api_key) .send() - .map_err(|error| format!("deepseek balance request failed: {error}"))?; + .map_err(|error| classify_reqwest_error(&error))?; let status = response.status(); - let body = response - .text() - .map_err(|error| format!("deepseek balance response could not be read: {error}"))?; - if !status.is_success() { - return Err(format!( - "deepseek balance request returned HTTP {}: {}", - status.as_u16(), - truncate_for_error(&body, 240) - )); + return Err(DeepSeekTransportFailure::HttpStatus(status.as_u16())); } + let mut body = read_bounded_response(response)?; + let parsed = serde_json::from_slice(&body).map_err(|_| DeepSeekTransportFailure::Protocol); + body.zeroize(); + parsed + } +} + +impl DeepSeekReadinessTransport for HttpDeepSeekChatCompletionTransport { + fn fetch_user_balance( + &self, + api_key: &str, + ) -> Result { + self.fetch_json( + &format!("{DEEPSEEK_API_BASE_URL}{DEEPSEEK_USER_BALANCE_PATH}"), + api_key, + ) + } + + fn fetch_models( + &self, + api_key: &str, + ) -> Result { + self.fetch_json( + &format!("{DEEPSEEK_API_BASE_URL}{DEEPSEEK_MODELS_PATH}"), + api_key, + ) + } +} + +fn classify_reqwest_error(error: &reqwest::Error) -> DeepSeekTransportFailure { + if error.is_timeout() { + DeepSeekTransportFailure::Timeout + } else if error.is_connect() { + DeepSeekTransportFailure::NetworkUnavailable + } else { + DeepSeekTransportFailure::Protocol + } +} - serde_json::from_str::(&body) - .map_err(|error| format!("deepseek balance response could not be parsed: {error}")) +fn safe_transport_failure(error: DeepSeekTransportFailure) -> String { + error.to_string() +} + +fn read_bounded_response( + mut response: reqwest::blocking::Response, +) -> Result, DeepSeekTransportFailure> { + if response + .content_length() + .map(|length| length > DEEPSEEK_RESPONSE_MAX_BYTES as u64) + .unwrap_or(false) + { + return Err(DeepSeekTransportFailure::Protocol); + } + let mut body = Vec::new(); + Read::take(&mut response, (DEEPSEEK_RESPONSE_MAX_BYTES + 1) as u64) + .read_to_end(&mut body) + .map_err(|_| DeepSeekTransportFailure::Protocol)?; + if body.len() > DEEPSEEK_RESPONSE_MAX_BYTES { + body.zeroize(); + return Err(DeepSeekTransportFailure::Protocol); } + Ok(body) } impl<'a, T: DeepSeekChatCompletionTransport> DeepSeekOperationsBriefingSynthesizer<'a, T> { @@ -384,30 +463,6 @@ impl OperationsBriefingSynthesizer } } -pub fn deepseek_credential_status_from_env( - read_env: impl Fn(&str) -> Option, -) -> DeepSeekCredentialStatus { - let api_key_configured = read_env(DEEPSEEK_API_KEY_ENV) - .map(|value| !value.trim().is_empty()) - .unwrap_or(false); - - DeepSeekCredentialStatus { - base_url: DEEPSEEK_API_BASE_URL.to_string(), - chat_completions_url: deepseek_chat_completions_url(), - api_key_env_var: DEEPSEEK_API_KEY_ENV.to_string(), - api_key_configured, - chat_completion_ready: api_key_configured, - flash_model: DEEPSEEK_FLASH_MODEL.to_string(), - pro_model: DEEPSEEK_PRO_MODEL.to_string(), - readiness_note: if api_key_configured { - "DEEPSEEK_API_KEY is configured for Chat Completions requests".to_string() - } else { - "set DEEPSEEK_API_KEY in the local process environment to enable Chat Completions requests" - .to_string() - }, - } -} - const OPERATIONS_BRIEFING_SYSTEM_PROMPT: &str = "You are an operations briefing analyst. Return strict JSON only. The JSON object must contain summary, anomalies, action_plan, and warnings. Do not invent evidence beyond the provided manifest."; const OPERATIONS_BRIEFING_MAX_MANIFEST_CHARS: usize = 12_000; @@ -453,10 +508,6 @@ fn truncate_for_prompt(value: &str, max_chars: usize) -> String { output } -pub fn current_deepseek_credential_status() -> DeepSeekCredentialStatus { - deepseek_credential_status_from_env(|name| std::env::var(name).ok()) -} - pub fn deepseek_chat_completions_url() -> String { format!("{DEEPSEEK_API_BASE_URL}{DEEPSEEK_CHAT_COMPLETIONS_PATH}") } @@ -524,21 +575,6 @@ pub fn execute_deepseek_chat_completion( .map_err(|error| redact_secret(&error, &api_key)) } -pub fn execute_deepseek_user_balance( - transport: &HttpDeepSeekChatCompletionTransport, - api_key: &str, -) -> Result { - let api_key = api_key.trim().to_string(); - if api_key.is_empty() { - return Err(format!("{DEEPSEEK_API_KEY_ENV} is required")); - } - - let endpoint = format!("{DEEPSEEK_API_BASE_URL}{DEEPSEEK_USER_BALANCE_PATH}"); - transport - .get_user_balance(&endpoint, &api_key) - .map_err(|error| redact_secret(&error, &api_key)) -} - pub fn execute_deepseek_chat_completion_with_cache( transport: &impl DeepSeekChatCompletionTransport, cache: &(impl DeepSeekChatCompletionCache + ?Sized), @@ -633,8 +669,8 @@ fn normalize_prompt(value: &str, label: &str) -> Result { Ok(normalized) } -fn normalize_api_key(value: &str) -> Result { - let normalized = value.trim().to_string(); +fn normalize_api_key(value: &str) -> Result, String> { + let normalized = Zeroizing::new(value.trim().to_string()); if normalized.is_empty() { return Err(format!("{DEEPSEEK_API_KEY_ENV} is required")); } @@ -672,8 +708,7 @@ mod tests { use std::thread::JoinHandle; use super::{ - build_deepseek_chat_completion_request, deepseek_credential_status_from_env, - effective_model, execute_deepseek_chat_completion, + build_deepseek_chat_completion_request, effective_model, execute_deepseek_chat_completion, execute_deepseek_chat_completion_with_cache, thinking_budget_name, DeepSeekChatCacheStatus, DeepSeekChatCompletionCache, DeepSeekChatCompletionResponse, DeepSeekChatCompletionTransport, DeepSeekChatCompletionUsage, @@ -837,38 +872,6 @@ mod tests { assert_eq!(thinking_budget_name(ThinkingLevel::Deep), "max"); } - #[test] - fn credential_status_reports_missing_env_key_without_secret() { - let status = deepseek_credential_status_from_env(|_| None); - - assert_eq!(status.base_url, DEEPSEEK_API_BASE_URL); - assert_eq!( - status.chat_completions_url, - format!("{DEEPSEEK_API_BASE_URL}{DEEPSEEK_CHAT_COMPLETIONS_PATH}") - ); - assert_eq!(status.api_key_env_var, DEEPSEEK_API_KEY_ENV); - assert!(!status.api_key_configured); - assert!(!status.chat_completion_ready); - assert_eq!(status.flash_model, DEEPSEEK_FLASH_MODEL); - assert_eq!(status.pro_model, DEEPSEEK_PRO_MODEL); - } - - #[test] - fn credential_status_reports_present_env_key_without_serializing_secret() { - let status = deepseek_credential_status_from_env(|name| { - if name == DEEPSEEK_API_KEY_ENV { - Some("test-secret-token".to_string()) - } else { - None - } - }); - let serialized = serde_json::to_string(&status).expect("status serializes"); - - assert!(status.api_key_configured); - assert!(status.chat_completion_ready); - assert!(!serialized.contains("test-secret-token")); - } - #[test] fn chat_completion_request_uses_route_model_messages_and_deep_thinking() { let request = build_deepseek_chat_completion_request( @@ -957,7 +960,7 @@ mod tests { ) .expect("request builds"); let response_body = serde_json::json!({ - "model": "deepseek-chat", + "model": "deepseek-v4-pro", "choices": [ { "message": { @@ -979,7 +982,7 @@ mod tests { assert_eq!(response.first_text(), Some("ok")); assert!(recorded.raw.starts_with("POST / HTTP/1.1")); - assert!(normalized_headers.contains("user-agent: deepseek-agent-os/0.1.0 deepseek-chat")); + assert!(normalized_headers.contains("user-agent: ds-agent/1.0.2 deepseek-v4")); } #[test] diff --git a/apps/desktop/src-tauri/src/kernel/deepseek_credential.rs b/apps/desktop/src-tauri/src/kernel/deepseek_credential.rs new file mode 100644 index 0000000..ef84316 --- /dev/null +++ b/apps/desktop/src-tauri/src/kernel/deepseek_credential.rs @@ -0,0 +1,1637 @@ +use std::fs::{self, OpenOptions}; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use uuid::Uuid; +use zeroize::Zeroize; + +use crate::kernel::deepseek::{ + DeepSeekReadinessTransport, DeepSeekTransportFailure, DEEPSEEK_API_BASE_URL, + DEEPSEEK_API_KEY_ENV, DEEPSEEK_FLASH_MODEL, DEEPSEEK_PRO_MODEL, +}; +use crate::kernel::local_directory::{WorkspaceReadinessCode, WorkspaceReadinessProjection}; + +const DEEPSEEK_KEY_MAX_BYTES: usize = 8 * 1024; +const DEEPSEEK_PROTECTED_KEY_MAX_BYTES: usize = 32 * 1024; +const DEEPSEEK_STATE_MAX_BYTES: usize = 32 * 1024; +const DEEPSEEK_KEY_FILE: &str = "deepseek-api-key.credential"; +const DEEPSEEK_STATE_FILE: &str = "verification-state.json"; + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum OnboardingOverallStatus { + SetupRequired, + Checking, + Blocked, + Ready, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum OnboardingNextStep { + DeepseekKey, + Workspace, + Doctor, + Ready, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum VersionReadinessStatus { + Current, + UpdateAvailable, + CheckUnavailable, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct VersionReadinessProjection { + pub current_version: String, + pub status: VersionReadinessStatus, + pub blocking: bool, + pub message_key: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct OnboardingReadinessProjection { + pub schema_version: u32, + pub overall: OnboardingOverallStatus, + pub next_step: OnboardingNextStep, + pub deepseek: DeepSeekReadinessProjection, + pub workspace: WorkspaceReadinessProjection, + pub version: VersionReadinessProjection, + pub checked_at: Option>, +} + +pub fn build_onboarding_readiness_projection( + deepseek: DeepSeekReadinessProjection, + workspace: WorkspaceReadinessProjection, + current_version: impl Into, +) -> OnboardingReadinessProjection { + let key_needs_input = matches!( + deepseek.code, + DeepSeekReadinessCode::KeyMissing + | DeepSeekReadinessCode::KeyFormatInvalid + | DeepSeekReadinessCode::AuthenticationFailed + ); + let workspace_missing = workspace.code == WorkspaceReadinessCode::WorkspaceMissing; + let ready = deepseek.chat_completion_ready && workspace.code == WorkspaceReadinessCode::Ready; + let checking = deepseek.verification == DeepSeekVerificationState::Checking; + let overall = if ready { + OnboardingOverallStatus::Ready + } else if checking { + OnboardingOverallStatus::Checking + } else if key_needs_input || workspace_missing { + OnboardingOverallStatus::SetupRequired + } else { + OnboardingOverallStatus::Blocked + }; + let next_step = if key_needs_input { + OnboardingNextStep::DeepseekKey + } else if deepseek.chat_completion_ready && workspace_missing { + OnboardingNextStep::Workspace + } else if ready { + OnboardingNextStep::Ready + } else { + OnboardingNextStep::Doctor + }; + let checked_at = deepseek.last_verified_at; + OnboardingReadinessProjection { + schema_version: 1, + overall, + next_step, + deepseek, + workspace, + version: VersionReadinessProjection { + current_version: current_version.into(), + status: VersionReadinessStatus::Current, + blocking: false, + message_key: "onboarding.version.current".to_string(), + }, + checked_at, + } +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum DeepSeekCredentialSource { + Stored, + Environment, + Missing, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum DeepSeekVerificationState { + NotChecked, + Checking, + Verified, + Blocked, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum DeepSeekReadinessCode { + Ready, + NotChecked, + KeyMissing, + KeyFormatInvalid, + AuthenticationFailed, + InsufficientBalance, + RateLimited, + NetworkUnavailable, + NetworkTimeout, + ModelUnavailable, + RequestInvalid, + ProviderUnavailable, + ProviderProtocolError, + CredentialStoreUnavailable, +} + +impl DeepSeekReadinessCode { + pub fn as_str(self) -> &'static str { + match self { + Self::Ready => "ready", + Self::NotChecked => "not_checked", + Self::KeyMissing => "key_missing", + Self::KeyFormatInvalid => "key_format_invalid", + Self::AuthenticationFailed => "authentication_failed", + Self::InsufficientBalance => "insufficient_balance", + Self::RateLimited => "rate_limited", + Self::NetworkUnavailable => "network_unavailable", + Self::NetworkTimeout => "network_timeout", + Self::ModelUnavailable => "model_unavailable", + Self::RequestInvalid => "request_invalid", + Self::ProviderUnavailable => "provider_unavailable", + Self::ProviderProtocolError => "provider_protocol_error", + Self::CredentialStoreUnavailable => "credential_store_unavailable", + } + } + + pub fn retryable(self) -> bool { + matches!( + self, + Self::InsufficientBalance + | Self::RateLimited + | Self::NetworkUnavailable + | Self::NetworkTimeout + | Self::ModelUnavailable + | Self::ProviderUnavailable + | Self::ProviderProtocolError + ) + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(default)] +pub struct DeepSeekReadinessProjection { + pub source: DeepSeekCredentialSource, + pub configured: bool, + pub verification: DeepSeekVerificationState, + pub code: DeepSeekReadinessCode, + pub chat_completion_ready: bool, + pub balance_available: Option, + pub flash_model: String, + pub pro_model: String, + pub flash_available: Option, + pub pro_available: Option, + pub retryable: bool, + pub last_verified_at: Option>, + pub message_key: String, +} + +impl Default for DeepSeekReadinessProjection { + fn default() -> Self { + Self::missing() + } +} + +impl DeepSeekReadinessProjection { + fn missing() -> Self { + Self::for_code( + DeepSeekCredentialSource::Missing, + DeepSeekVerificationState::NotChecked, + DeepSeekReadinessCode::KeyMissing, + None, + ) + } + + fn for_code( + source: DeepSeekCredentialSource, + verification: DeepSeekVerificationState, + code: DeepSeekReadinessCode, + receipt: Option<&DeepSeekVerificationReceipt>, + ) -> Self { + let ready = verification == DeepSeekVerificationState::Verified + && code == DeepSeekReadinessCode::Ready; + Self { + source, + configured: source != DeepSeekCredentialSource::Missing, + verification, + code, + chat_completion_ready: ready, + balance_available: receipt.and_then(|value| value.balance_available), + flash_model: DEEPSEEK_FLASH_MODEL.to_string(), + pro_model: DEEPSEEK_PRO_MODEL.to_string(), + flash_available: receipt.and_then(|value| value.flash_available), + pro_available: receipt.and_then(|value| value.pro_available), + retryable: code.retryable(), + last_verified_at: receipt.map(|value| value.verified_at), + message_key: format!("onboarding.deepseek.{}", code.as_str()), + } + } +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum DeepSeekVerificationReceiptStatus { + Verified, + Blocked, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct DeepSeekVerificationReceipt { + pub schema_version: u32, + pub credential_revision: u64, + pub status: DeepSeekVerificationReceiptStatus, + pub code: DeepSeekReadinessCode, + pub balance_available: Option, + pub flash_available: Option, + pub pro_available: Option, + pub verified_at: DateTime, + pub endpoint_origin: String, +} + +impl DeepSeekVerificationReceipt { + fn blocked( + credential_revision: u64, + code: DeepSeekReadinessCode, + balance_available: Option, + flash_available: Option, + pro_available: Option, + ) -> Self { + Self { + schema_version: 1, + credential_revision, + status: DeepSeekVerificationReceiptStatus::Blocked, + code, + balance_available, + flash_available, + pro_available, + verified_at: Utc::now(), + endpoint_origin: DEEPSEEK_API_BASE_URL.to_string(), + } + } + + fn verified(credential_revision: u64, flash_available: bool, pro_available: bool) -> Self { + Self { + schema_version: 1, + credential_revision, + status: DeepSeekVerificationReceiptStatus::Verified, + code: DeepSeekReadinessCode::Ready, + balance_available: Some(true), + flash_available: Some(flash_available), + pro_available: Some(pro_available), + verified_at: Utc::now(), + endpoint_origin: DEEPSEEK_API_BASE_URL.to_string(), + } + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +struct DeepSeekCredentialState { + schema_version: u32, + credential_revision: u64, + receipt: Option, +} + +impl Default for DeepSeekCredentialState { + fn default() -> Self { + Self { + schema_version: 1, + credential_revision: 0, + receipt: None, + } + } +} + +pub struct DeepSeekSecret(String); + +impl DeepSeekSecret { + pub fn new(mut value: String) -> Result { + let normalized = value.trim().to_string(); + value.zeroize(); + if normalized.is_empty() || normalized.len() > DEEPSEEK_KEY_MAX_BYTES { + let mut normalized = normalized; + normalized.zeroize(); + return Err(DeepSeekReadinessCode::KeyFormatInvalid); + } + Ok(Self(normalized)) + } + + pub(crate) fn expose(&self) -> &str { + &self.0 + } +} + +impl Drop for DeepSeekSecret { + fn drop(&mut self) { + self.0.zeroize(); + } +} + +pub trait DeepSeekCredentialStore: Send { + fn put(&mut self, secret: DeepSeekSecret) -> Result<(), DeepSeekReadinessCode>; + fn read(&self) -> Result; + fn delete(&mut self) -> Result<(), DeepSeekReadinessCode>; + fn contains(&self) -> Result; +} + +pub struct FileDeepSeekCredentialStateStore { + root: PathBuf, +} + +impl FileDeepSeekCredentialStateStore { + pub fn new(root: impl AsRef) -> Result { + fs::create_dir_all(root.as_ref()) + .map_err(|_| DeepSeekReadinessCode::CredentialStoreUnavailable)?; + let root = fs::canonicalize(root.as_ref()) + .map_err(|_| DeepSeekReadinessCode::CredentialStoreUnavailable)?; + if !root.is_dir() { + return Err(DeepSeekReadinessCode::CredentialStoreUnavailable); + } + cleanup_staged_files(&root, ".deepseek-state-")?; + Ok(Self { root }) + } + + fn path(&self) -> PathBuf { + self.root.join(DEEPSEEK_STATE_FILE) + } + + fn load(&self) -> Result { + let path = self.path(); + let metadata = match fs::symlink_metadata(&path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(DeepSeekCredentialState::default()) + } + Err(_) => return Err(DeepSeekReadinessCode::CredentialStoreUnavailable), + }; + if metadata.file_type().is_symlink() + || !metadata.is_file() + || metadata.len() as usize > DEEPSEEK_STATE_MAX_BYTES + { + return Err(DeepSeekReadinessCode::CredentialStoreUnavailable); + } + let canonical = fs::canonicalize(&path) + .map_err(|_| DeepSeekReadinessCode::CredentialStoreUnavailable)?; + if !canonical.starts_with(&self.root) { + return Err(DeepSeekReadinessCode::CredentialStoreUnavailable); + } + let mut file = OpenOptions::new() + .read(true) + .open(canonical) + .map_err(|_| DeepSeekReadinessCode::CredentialStoreUnavailable)?; + let mut bytes = Vec::new(); + Read::take(&mut file, (DEEPSEEK_STATE_MAX_BYTES + 1) as u64) + .read_to_end(&mut bytes) + .map_err(|_| DeepSeekReadinessCode::CredentialStoreUnavailable)?; + if bytes.len() > DEEPSEEK_STATE_MAX_BYTES { + bytes.zeroize(); + return Err(DeepSeekReadinessCode::CredentialStoreUnavailable); + } + let parsed = serde_json::from_slice::(&bytes) + .map_err(|_| DeepSeekReadinessCode::CredentialStoreUnavailable); + bytes.zeroize(); + let state = parsed?; + if state.schema_version != 1 { + return Err(DeepSeekReadinessCode::CredentialStoreUnavailable); + } + Ok(state) + } + + fn save(&self, state: &DeepSeekCredentialState) -> Result<(), DeepSeekReadinessCode> { + let bytes = serde_json::to_vec_pretty(state) + .map_err(|_| DeepSeekReadinessCode::CredentialStoreUnavailable)?; + if bytes.len() > DEEPSEEK_STATE_MAX_BYTES { + return Err(DeepSeekReadinessCode::CredentialStoreUnavailable); + } + atomic_write(&self.root, &self.path(), ".deepseek-state-", &bytes) + } + + fn delete(&self) -> Result<(), DeepSeekReadinessCode> { + remove_regular_file_if_present(&self.path()) + } +} + +struct EnvironmentReceipt { + key_digest: [u8; 32], + receipt: DeepSeekVerificationReceipt, +} + +impl Drop for EnvironmentReceipt { + fn drop(&mut self) { + self.key_digest.zeroize(); + } +} + +pub struct DeepSeekCredentialRuntime { + store: Mutex, + state_store: FileDeepSeekCredentialStateStore, + lifecycle: Mutex<()>, + environment_receipt: Mutex>, +} + +impl DeepSeekCredentialRuntime { + pub fn new(store: S, state_store: FileDeepSeekCredentialStateStore) -> Self { + Self { + store: Mutex::new(store), + state_store, + lifecycle: Mutex::new(()), + environment_receipt: Mutex::new(None), + } + } + + pub fn projection( + &self, + read_env: impl Fn(&str) -> Option, + ) -> DeepSeekReadinessProjection { + let _lifecycle = match self.lifecycle.lock() { + Ok(lock) => lock, + Err(_) => return credential_store_unavailable_projection(), + }; + self.projection_locked(read_env) + } + + fn projection_locked( + &self, + read_env: impl Fn(&str) -> Option, + ) -> DeepSeekReadinessProjection { + let has_stored = match self.store.lock() { + Ok(store) => match store.contains() { + Ok(contains) => contains, + Err(_) => return credential_store_unavailable_projection(), + }, + Err(_) => return credential_store_unavailable_projection(), + }; + if has_stored { + let state = match self.state_store.load() { + Ok(state) if state.credential_revision > 0 => state, + _ => return credential_store_unavailable_projection(), + }; + return projection_from_receipt( + DeepSeekCredentialSource::Stored, + state.credential_revision, + state.receipt.as_ref(), + ); + } + + let Some(secret) = environment_secret(read_env) else { + return DeepSeekReadinessProjection::missing(); + }; + let digest = secret_digest(&secret); + let projection = self + .environment_receipt + .lock() + .ok() + .and_then(|receipt| { + receipt + .as_ref() + .filter(|receipt| receipt.key_digest == digest) + .map(|receipt| { + projection_from_receipt( + DeepSeekCredentialSource::Environment, + receipt.receipt.credential_revision, + Some(&receipt.receipt), + ) + }) + }) + .unwrap_or_else(|| { + DeepSeekReadinessProjection::for_code( + DeepSeekCredentialSource::Environment, + DeepSeekVerificationState::NotChecked, + DeepSeekReadinessCode::NotChecked, + None, + ) + }); + drop(secret); + projection + } + + pub fn save_and_verify( + &self, + api_key: String, + verifier: &impl DeepSeekReadinessTransport, + read_env: impl Fn(&str) -> Option + Copy, + ) -> DeepSeekReadinessProjection { + let secret = match DeepSeekSecret::new(api_key) { + Ok(secret) => secret, + Err(code) => { + let source = self.projection(read_env).source; + return DeepSeekReadinessProjection::for_code( + source, + DeepSeekVerificationState::Blocked, + code, + None, + ); + } + }; + let revision = { + let _lifecycle = match self.lifecycle.lock() { + Ok(lock) => lock, + Err(_) => return credential_store_unavailable_projection(), + }; + let mut state = match self.state_store.load() { + Ok(state) => state, + Err(_) => return credential_store_unavailable_projection(), + }; + let mut store = match self.store.lock() { + Ok(store) => store, + Err(_) => return credential_store_unavailable_projection(), + }; + state.credential_revision = state.credential_revision.saturating_add(1).max(1); + state.receipt = None; + if self.state_store.save(&state).is_err() { + return credential_store_unavailable_projection(); + } + if let Ok(mut receipt) = self.environment_receipt.lock() { + *receipt = None; + } + if store.put(secret).is_err() { + return credential_store_unavailable_projection(); + } + state.credential_revision + }; + self.verify_stored_revision(revision, verifier, read_env) + } + + pub fn verify( + &self, + verifier: &impl DeepSeekReadinessTransport, + read_env: impl Fn(&str) -> Option + Copy, + ) -> DeepSeekReadinessProjection { + let source = { + let _lifecycle = match self.lifecycle.lock() { + Ok(lock) => lock, + Err(_) => return credential_store_unavailable_projection(), + }; + let has_stored = match self.store.lock() { + Ok(store) => match store.contains() { + Ok(contains) => contains, + Err(_) => return credential_store_unavailable_projection(), + }, + Err(_) => return credential_store_unavailable_projection(), + }; + if has_stored { + let state = match self.state_store.load() { + Ok(state) if state.credential_revision > 0 => state, + _ => return credential_store_unavailable_projection(), + }; + Some((DeepSeekCredentialSource::Stored, state.credential_revision)) + } else if environment_secret(read_env).is_some() { + Some((DeepSeekCredentialSource::Environment, 0)) + } else { + None + } + }; + match source { + Some((DeepSeekCredentialSource::Stored, revision)) => { + self.verify_stored_revision(revision, verifier, read_env) + } + Some((DeepSeekCredentialSource::Environment, _)) => { + self.verify_environment(verifier, read_env) + } + _ => DeepSeekReadinessProjection::missing(), + } + } + + fn verify_stored_revision( + &self, + revision: u64, + verifier: &impl DeepSeekReadinessTransport, + read_env: impl Fn(&str) -> Option + Copy, + ) -> DeepSeekReadinessProjection { + let secret = { + let store = match self.store.lock() { + Ok(store) => store, + Err(_) => return credential_store_unavailable_projection(), + }; + match store.read() { + Ok(secret) => secret, + Err(_) => return credential_store_unavailable_projection(), + } + }; + let receipt = run_verification(verifier, &secret, revision); + drop(secret); + let _lifecycle = match self.lifecycle.lock() { + Ok(lock) => lock, + Err(_) => return credential_store_unavailable_projection(), + }; + let mut state = match self.state_store.load() { + Ok(state) => state, + Err(_) => return credential_store_unavailable_projection(), + }; + if state.credential_revision != revision { + return self.projection_locked(read_env); + } + state.receipt = Some(receipt.clone()); + if self.state_store.save(&state).is_err() { + return credential_store_unavailable_projection(); + } + projection_from_receipt(DeepSeekCredentialSource::Stored, revision, Some(&receipt)) + } + + fn verify_environment( + &self, + verifier: &impl DeepSeekReadinessTransport, + read_env: impl Fn(&str) -> Option + Copy, + ) -> DeepSeekReadinessProjection { + let Some(secret) = environment_secret(read_env) else { + return DeepSeekReadinessProjection::missing(); + }; + let digest = secret_digest(&secret); + let receipt = run_verification(verifier, &secret, 0); + drop(secret); + let Some(current_secret) = environment_secret(read_env) else { + return DeepSeekReadinessProjection::missing(); + }; + let current_digest = secret_digest(¤t_secret); + drop(current_secret); + if current_digest != digest { + return DeepSeekReadinessProjection::for_code( + DeepSeekCredentialSource::Environment, + DeepSeekVerificationState::NotChecked, + DeepSeekReadinessCode::NotChecked, + None, + ); + } + match self.environment_receipt.lock() { + Ok(mut environment_receipt) => { + *environment_receipt = Some(EnvironmentReceipt { + key_digest: digest, + receipt: receipt.clone(), + }); + } + Err(_) => return credential_store_unavailable_projection(), + } + projection_from_receipt(DeepSeekCredentialSource::Environment, 0, Some(&receipt)) + } + + pub fn remove(&self, read_env: impl Fn(&str) -> Option) -> DeepSeekReadinessProjection { + let _lifecycle = match self.lifecycle.lock() { + Ok(lock) => lock, + Err(_) => return credential_store_unavailable_projection(), + }; + let mut store = match self.store.lock() { + Ok(store) => store, + Err(_) => return credential_store_unavailable_projection(), + }; + if store.delete().is_err() || self.state_store.delete().is_err() { + return credential_store_unavailable_projection(); + } + if let Ok(mut receipt) = self.environment_receipt.lock() { + *receipt = None; + } + drop(store); + self.projection_locked(read_env) + } + + pub fn resolve_ready_key( + &self, + read_env: impl Fn(&str) -> Option, + ) -> Result { + let _lifecycle = self + .lifecycle + .lock() + .map_err(|_| DeepSeekReadinessCode::CredentialStoreUnavailable)?; + let has_stored = self + .store + .lock() + .map_err(|_| DeepSeekReadinessCode::CredentialStoreUnavailable)? + .contains()?; + if has_stored { + let state = self.state_store.load()?; + let ready = state + .receipt + .as_ref() + .filter(|receipt| receipt.credential_revision == state.credential_revision) + .map(|receipt| { + receipt.status == DeepSeekVerificationReceiptStatus::Verified + && receipt.code == DeepSeekReadinessCode::Ready + }) + .unwrap_or(false); + if !ready { + return Err(state + .receipt + .as_ref() + .map(|receipt| receipt.code) + .unwrap_or(DeepSeekReadinessCode::NotChecked)); + } + return self + .store + .lock() + .map_err(|_| DeepSeekReadinessCode::CredentialStoreUnavailable)? + .read(); + } + let secret = environment_secret(read_env).ok_or(DeepSeekReadinessCode::KeyMissing)?; + let digest = secret_digest(&secret); + let ready = self + .environment_receipt + .lock() + .map_err(|_| DeepSeekReadinessCode::CredentialStoreUnavailable)? + .as_ref() + .filter(|receipt| receipt.key_digest == digest) + .map(|receipt| { + receipt.receipt.status == DeepSeekVerificationReceiptStatus::Verified + && receipt.receipt.code == DeepSeekReadinessCode::Ready + }) + .unwrap_or(false); + if ready { + Ok(secret) + } else { + Err(DeepSeekReadinessCode::NotChecked) + } + } +} + +fn projection_from_receipt( + source: DeepSeekCredentialSource, + revision: u64, + receipt: Option<&DeepSeekVerificationReceipt>, +) -> DeepSeekReadinessProjection { + let receipt = receipt.filter(|receipt| receipt.credential_revision == revision); + match receipt { + Some(receipt) if receipt.status == DeepSeekVerificationReceiptStatus::Verified => { + DeepSeekReadinessProjection::for_code( + source, + DeepSeekVerificationState::Verified, + receipt.code, + Some(receipt), + ) + } + Some(receipt) => DeepSeekReadinessProjection::for_code( + source, + DeepSeekVerificationState::Blocked, + receipt.code, + Some(receipt), + ), + None => DeepSeekReadinessProjection::for_code( + source, + DeepSeekVerificationState::NotChecked, + DeepSeekReadinessCode::NotChecked, + None, + ), + } +} + +fn run_verification( + verifier: &impl DeepSeekReadinessTransport, + secret: &DeepSeekSecret, + revision: u64, +) -> DeepSeekVerificationReceipt { + let balance = match verifier.fetch_user_balance(secret.expose()) { + Ok(balance) => balance, + Err(error) => { + return DeepSeekVerificationReceipt::blocked( + revision, + readiness_code_from_transport(error, false), + None, + None, + None, + ) + } + }; + if !balance.is_available { + return DeepSeekVerificationReceipt::blocked( + revision, + DeepSeekReadinessCode::InsufficientBalance, + Some(false), + None, + None, + ); + } + let models = match verifier.fetch_models(secret.expose()) { + Ok(models) => models, + Err(error) => { + return DeepSeekVerificationReceipt::blocked( + revision, + readiness_code_from_transport(error, true), + Some(true), + None, + None, + ) + } + }; + let flash_available = models + .data + .iter() + .any(|model| model.id == DEEPSEEK_FLASH_MODEL); + let pro_available = models + .data + .iter() + .any(|model| model.id == DEEPSEEK_PRO_MODEL); + if !flash_available || !pro_available { + return DeepSeekVerificationReceipt::blocked( + revision, + DeepSeekReadinessCode::ModelUnavailable, + Some(true), + Some(flash_available), + Some(pro_available), + ); + } + DeepSeekVerificationReceipt::verified(revision, flash_available, pro_available) +} + +fn readiness_code_from_transport( + failure: DeepSeekTransportFailure, + during_models: bool, +) -> DeepSeekReadinessCode { + match failure { + DeepSeekTransportFailure::HttpStatus(401) => DeepSeekReadinessCode::AuthenticationFailed, + DeepSeekTransportFailure::HttpStatus(402) => DeepSeekReadinessCode::InsufficientBalance, + DeepSeekTransportFailure::HttpStatus(429) => DeepSeekReadinessCode::RateLimited, + DeepSeekTransportFailure::HttpStatus(400 | 422) => DeepSeekReadinessCode::RequestInvalid, + DeepSeekTransportFailure::HttpStatus(500 | 503) => { + DeepSeekReadinessCode::ProviderUnavailable + } + DeepSeekTransportFailure::HttpStatus(404) if during_models => { + DeepSeekReadinessCode::ModelUnavailable + } + DeepSeekTransportFailure::NetworkUnavailable => DeepSeekReadinessCode::NetworkUnavailable, + DeepSeekTransportFailure::Timeout => DeepSeekReadinessCode::NetworkTimeout, + DeepSeekTransportFailure::Protocol | DeepSeekTransportFailure::HttpStatus(_) => { + DeepSeekReadinessCode::ProviderProtocolError + } + } +} + +fn credential_store_unavailable_projection() -> DeepSeekReadinessProjection { + DeepSeekReadinessProjection::for_code( + DeepSeekCredentialSource::Missing, + DeepSeekVerificationState::Blocked, + DeepSeekReadinessCode::CredentialStoreUnavailable, + None, + ) +} + +fn environment_secret(read_env: impl Fn(&str) -> Option) -> Option { + read_env(DEEPSEEK_API_KEY_ENV).and_then(|value| DeepSeekSecret::new(value).ok()) +} + +fn secret_digest(secret: &DeepSeekSecret) -> [u8; 32] { + Sha256::digest(secret.expose().as_bytes()).into() +} + +fn cleanup_staged_files(root: &Path, prefix: &str) -> Result<(), DeepSeekReadinessCode> { + let mut entries_seen = 0usize; + let mut staged_seen = 0usize; + for entry in + fs::read_dir(root).map_err(|_| DeepSeekReadinessCode::CredentialStoreUnavailable)? + { + let entry = entry.map_err(|_| DeepSeekReadinessCode::CredentialStoreUnavailable)?; + entries_seen += 1; + if entries_seen > 4096 { + return Err(DeepSeekReadinessCode::CredentialStoreUnavailable); + } + let name = entry.file_name(); + let name = name.to_string_lossy(); + if !name.starts_with(prefix) || !name.ends_with(".tmp") { + continue; + } + staged_seen += 1; + if staged_seen > 64 { + return Err(DeepSeekReadinessCode::CredentialStoreUnavailable); + } + let metadata = fs::symlink_metadata(entry.path()) + .map_err(|_| DeepSeekReadinessCode::CredentialStoreUnavailable)?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(DeepSeekReadinessCode::CredentialStoreUnavailable); + } + fs::remove_file(entry.path()) + .map_err(|_| DeepSeekReadinessCode::CredentialStoreUnavailable)?; + } + Ok(()) +} + +fn atomic_write( + root: &Path, + destination: &Path, + prefix: &str, + bytes: &[u8], +) -> Result<(), DeepSeekReadinessCode> { + if let Ok(metadata) = fs::symlink_metadata(destination) { + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(DeepSeekReadinessCode::CredentialStoreUnavailable); + } + } + let temp = root.join(format!("{prefix}{}.tmp", Uuid::new_v4())); + let result = (|| { + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&temp) + .map_err(|_| DeepSeekReadinessCode::CredentialStoreUnavailable)?; + file.write_all(bytes) + .and_then(|_| file.sync_all()) + .map_err(|_| DeepSeekReadinessCode::CredentialStoreUnavailable)?; + drop(file); + atomic_replace(&temp, destination) + })(); + if result.is_err() { + let _ = fs::remove_file(&temp); + } + result +} + +#[cfg(windows)] +fn atomic_replace(source: &Path, destination: &Path) -> Result<(), DeepSeekReadinessCode> { + use std::os::windows::ffi::OsStrExt; + use windows::core::PCWSTR; + use windows::Win32::Storage::FileSystem::{ + MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, + }; + + let source_wide = source + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + let destination_wide = destination + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + unsafe { + MoveFileExW( + PCWSTR(source_wide.as_ptr()), + PCWSTR(destination_wide.as_ptr()), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, + ) + } + .map_err(|_| DeepSeekReadinessCode::CredentialStoreUnavailable) +} + +#[cfg(not(windows))] +fn atomic_replace(source: &Path, destination: &Path) -> Result<(), DeepSeekReadinessCode> { + fs::rename(source, destination).map_err(|_| DeepSeekReadinessCode::CredentialStoreUnavailable) +} + +fn remove_regular_file_if_present(path: &Path) -> Result<(), DeepSeekReadinessCode> { + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { + Err(DeepSeekReadinessCode::CredentialStoreUnavailable) + } + Ok(_) => { + fs::remove_file(path).map_err(|_| DeepSeekReadinessCode::CredentialStoreUnavailable) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(_) => Err(DeepSeekReadinessCode::CredentialStoreUnavailable), + } +} + +#[cfg(windows)] +pub struct WindowsDeepSeekCredentialStore { + root: PathBuf, +} + +#[cfg(windows)] +impl WindowsDeepSeekCredentialStore { + pub fn new(root: impl AsRef) -> Result { + fs::create_dir_all(root.as_ref()) + .map_err(|_| DeepSeekReadinessCode::CredentialStoreUnavailable)?; + let root = fs::canonicalize(root.as_ref()) + .map_err(|_| DeepSeekReadinessCode::CredentialStoreUnavailable)?; + if !root.is_dir() { + return Err(DeepSeekReadinessCode::CredentialStoreUnavailable); + } + cleanup_staged_files(&root, ".deepseek-key-")?; + Ok(Self { root }) + } + + fn path(&self) -> PathBuf { + self.root.join(DEEPSEEK_KEY_FILE) + } + + fn entropy() -> [u8; 32] { + Sha256::digest(b"ds-agent.deepseek-credential.v1").into() + } + + fn protect(secret: &DeepSeekSecret) -> Result, DeepSeekReadinessCode> { + use windows::core::w; + use windows::Win32::Foundation::{LocalFree, HLOCAL}; + use windows::Win32::Security::Cryptography::{ + CryptProtectData, CRYPTPROTECT_UI_FORBIDDEN, CRYPT_INTEGER_BLOB, + }; + + let mut plaintext = secret.expose().as_bytes().to_vec(); + let input = CRYPT_INTEGER_BLOB { + cbData: plaintext.len() as u32, + pbData: plaintext.as_mut_ptr(), + }; + let mut entropy = Self::entropy(); + let entropy_blob = CRYPT_INTEGER_BLOB { + cbData: entropy.len() as u32, + pbData: entropy.as_mut_ptr(), + }; + let mut output = CRYPT_INTEGER_BLOB::default(); + let result = unsafe { + CryptProtectData( + &input, + w!("DS Agent DeepSeek credential"), + Some(&entropy_blob), + None, + None, + CRYPTPROTECT_UI_FORBIDDEN, + &mut output, + ) + }; + plaintext.zeroize(); + entropy.zeroize(); + result.map_err(|_| DeepSeekReadinessCode::CredentialStoreUnavailable)?; + if output.pbData.is_null() || output.cbData as usize > DEEPSEEK_PROTECTED_KEY_MAX_BYTES { + if !output.pbData.is_null() { + let protected = unsafe { + std::slice::from_raw_parts_mut(output.pbData, output.cbData as usize) + }; + protected.zeroize(); + unsafe { LocalFree(Some(HLOCAL(output.pbData.cast()))) }; + } + return Err(DeepSeekReadinessCode::CredentialStoreUnavailable); + } + let protected = + unsafe { std::slice::from_raw_parts_mut(output.pbData, output.cbData as usize) }; + let value = protected.to_vec(); + protected.zeroize(); + unsafe { LocalFree(Some(HLOCAL(output.pbData.cast()))) }; + Ok(value) + } + + fn unprotect(mut protected: Vec) -> Result { + use windows::Win32::Foundation::{LocalFree, HLOCAL}; + use windows::Win32::Security::Cryptography::{ + CryptUnprotectData, CRYPTPROTECT_UI_FORBIDDEN, CRYPT_INTEGER_BLOB, + }; + + if protected.is_empty() || protected.len() > DEEPSEEK_PROTECTED_KEY_MAX_BYTES { + protected.zeroize(); + return Err(DeepSeekReadinessCode::CredentialStoreUnavailable); + } + let input = CRYPT_INTEGER_BLOB { + cbData: protected.len() as u32, + pbData: protected.as_mut_ptr(), + }; + let mut entropy = Self::entropy(); + let entropy_blob = CRYPT_INTEGER_BLOB { + cbData: entropy.len() as u32, + pbData: entropy.as_mut_ptr(), + }; + let mut output = CRYPT_INTEGER_BLOB::default(); + let result = unsafe { + CryptUnprotectData( + &input, + None, + Some(&entropy_blob), + None, + None, + CRYPTPROTECT_UI_FORBIDDEN, + &mut output, + ) + }; + protected.zeroize(); + entropy.zeroize(); + result.map_err(|_| DeepSeekReadinessCode::CredentialStoreUnavailable)?; + if output.pbData.is_null() || output.cbData as usize > DEEPSEEK_KEY_MAX_BYTES { + if !output.pbData.is_null() { + let plaintext = unsafe { + std::slice::from_raw_parts_mut(output.pbData, output.cbData as usize) + }; + plaintext.zeroize(); + unsafe { LocalFree(Some(HLOCAL(output.pbData.cast()))) }; + } + return Err(DeepSeekReadinessCode::CredentialStoreUnavailable); + } + let plaintext = + unsafe { std::slice::from_raw_parts_mut(output.pbData, output.cbData as usize) }; + let value = String::from_utf8(plaintext.to_vec()); + plaintext.zeroize(); + unsafe { LocalFree(Some(HLOCAL(output.pbData.cast()))) }; + match value { + Ok(value) => DeepSeekSecret::new(value), + Err(error) => { + let mut bytes = error.into_bytes(); + bytes.zeroize(); + Err(DeepSeekReadinessCode::CredentialStoreUnavailable) + } + } + } + + fn read_protected(&self) -> Result, DeepSeekReadinessCode> { + let path = self.path(); + let metadata = fs::symlink_metadata(&path) + .map_err(|_| DeepSeekReadinessCode::CredentialStoreUnavailable)?; + if metadata.file_type().is_symlink() + || !metadata.is_file() + || metadata.len() as usize > DEEPSEEK_PROTECTED_KEY_MAX_BYTES + { + return Err(DeepSeekReadinessCode::CredentialStoreUnavailable); + } + let canonical = fs::canonicalize(&path) + .map_err(|_| DeepSeekReadinessCode::CredentialStoreUnavailable)?; + if !canonical.starts_with(&self.root) { + return Err(DeepSeekReadinessCode::CredentialStoreUnavailable); + } + let mut file = OpenOptions::new() + .read(true) + .open(canonical) + .map_err(|_| DeepSeekReadinessCode::CredentialStoreUnavailable)?; + let mut protected = Vec::new(); + Read::take(&mut file, (DEEPSEEK_PROTECTED_KEY_MAX_BYTES + 1) as u64) + .read_to_end(&mut protected) + .map_err(|_| DeepSeekReadinessCode::CredentialStoreUnavailable)?; + if protected.len() > DEEPSEEK_PROTECTED_KEY_MAX_BYTES { + protected.zeroize(); + return Err(DeepSeekReadinessCode::CredentialStoreUnavailable); + } + Ok(protected) + } +} + +#[cfg(windows)] +impl DeepSeekCredentialStore for WindowsDeepSeekCredentialStore { + fn put(&mut self, secret: DeepSeekSecret) -> Result<(), DeepSeekReadinessCode> { + let mut protected = Self::protect(&secret)?; + let result = atomic_write(&self.root, &self.path(), ".deepseek-key-", &protected); + protected.zeroize(); + result + } + + fn read(&self) -> Result { + Self::unprotect(self.read_protected()?) + } + + fn delete(&mut self) -> Result<(), DeepSeekReadinessCode> { + remove_regular_file_if_present(&self.path()) + } + + fn contains(&self) -> Result { + match fs::symlink_metadata(self.path()) { + Ok(metadata) if metadata.is_file() && !metadata.file_type().is_symlink() => Ok(true), + Ok(_) => Err(DeepSeekReadinessCode::CredentialStoreUnavailable), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(_) => Err(DeepSeekReadinessCode::CredentialStoreUnavailable), + } + } +} + +#[cfg(test)] +mod tests { + use std::collections::VecDeque; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::Arc; + + use crate::kernel::deepseek::{ + DeepSeekModelDescriptor, DeepSeekModelListResponse, DeepSeekReadinessTransport, + DeepSeekTransportFailure, DeepSeekUserBalanceResponse, + }; + + use super::*; + + #[derive(Default)] + struct FakeStore { + secret: Option, + inside_call: Option>, + fail_put: bool, + } + + impl DeepSeekCredentialStore for FakeStore { + fn put(&mut self, secret: DeepSeekSecret) -> Result<(), DeepSeekReadinessCode> { + self.enter(); + if self.fail_put { + self.exit(); + return Err(DeepSeekReadinessCode::CredentialStoreUnavailable); + } + self.secret = Some(secret); + self.exit(); + Ok(()) + } + + fn read(&self) -> Result { + self.enter(); + let result = self + .secret + .as_ref() + .map(|secret| DeepSeekSecret::new(secret.expose().to_string()).unwrap()) + .ok_or(DeepSeekReadinessCode::CredentialStoreUnavailable); + self.exit(); + result + } + + fn delete(&mut self) -> Result<(), DeepSeekReadinessCode> { + self.enter(); + self.secret = None; + self.exit(); + Ok(()) + } + + fn contains(&self) -> Result { + Ok(self.secret.is_some()) + } + } + + impl FakeStore { + fn enter(&self) { + if let Some(flag) = &self.inside_call { + flag.store(true, Ordering::SeqCst); + } + } + + fn exit(&self) { + if let Some(flag) = &self.inside_call { + flag.store(false, Ordering::SeqCst); + } + } + } + + struct ScriptedVerifier { + balance: Mutex>>, + models: Mutex, DeepSeekTransportFailure>>>, + store_inside_call: Option>, + } + + impl ScriptedVerifier { + fn success() -> Self { + Self::new( + vec![Ok(true)], + vec![Ok(vec![DEEPSEEK_FLASH_MODEL, DEEPSEEK_PRO_MODEL])], + ) + } + + fn new( + balance: Vec>, + models: Vec, DeepSeekTransportFailure>>, + ) -> Self { + Self { + balance: Mutex::new(balance.into()), + models: Mutex::new(models.into()), + store_inside_call: None, + } + } + } + + impl DeepSeekReadinessTransport for ScriptedVerifier { + fn fetch_user_balance( + &self, + _api_key: &str, + ) -> Result { + if let Some(flag) = &self.store_inside_call { + assert!( + !flag.load(Ordering::SeqCst), + "vault lock crossed network I/O" + ); + } + self.balance + .lock() + .unwrap() + .pop_front() + .unwrap_or(Ok(true)) + .map(|is_available| DeepSeekUserBalanceResponse { + is_available, + balance_infos: Vec::new(), + }) + } + + fn fetch_models( + &self, + _api_key: &str, + ) -> Result { + if let Some(flag) = &self.store_inside_call { + assert!( + !flag.load(Ordering::SeqCst), + "vault lock crossed network I/O" + ); + } + self.models + .lock() + .unwrap() + .pop_front() + .unwrap_or_else(|| Ok(vec![DEEPSEEK_FLASH_MODEL, DEEPSEEK_PRO_MODEL])) + .map(|models| DeepSeekModelListResponse { + data: models + .into_iter() + .map(|id| DeepSeekModelDescriptor { id: id.to_string() }) + .collect(), + }) + } + } + + fn runtime(store: FakeStore) -> (tempfile::TempDir, DeepSeekCredentialRuntime) { + let temp = tempfile::tempdir().unwrap(); + let state_store = FileDeepSeekCredentialStateStore::new(temp.path()).unwrap(); + (temp, DeepSeekCredentialRuntime::new(store, state_store)) + } + + #[test] + fn key_input_is_bounded_and_zeroizable() { + assert!(matches!( + DeepSeekSecret::new(" ".to_string()), + Err(DeepSeekReadinessCode::KeyFormatInvalid) + )); + assert!(matches!( + DeepSeekSecret::new("x".repeat(DEEPSEEK_KEY_MAX_BYTES + 1)), + Err(DeepSeekReadinessCode::KeyFormatInvalid) + )); + assert_eq!( + DeepSeekSecret::new(" key ".to_string()).unwrap().expose(), + "key" + ); + } + + #[test] + fn invalid_submitted_key_does_not_create_or_replace_a_stored_key() { + let (_temp, runtime) = runtime(FakeStore::default()); + let projection = + runtime.save_and_verify(" ".to_string(), &ScriptedVerifier::success(), |_| None); + assert_eq!(projection.source, DeepSeekCredentialSource::Missing); + assert!(!projection.configured); + assert_eq!(projection.code, DeepSeekReadinessCode::KeyFormatInvalid); + assert_eq!( + runtime.projection(|_| None).code, + DeepSeekReadinessCode::KeyMissing + ); + } + + #[test] + fn fake_store_put_read_replace_delete_round_trip() { + let mut store = FakeStore::default(); + store + .put(DeepSeekSecret::new("first".to_string()).unwrap()) + .unwrap(); + assert_eq!(store.read().unwrap().expose(), "first"); + store + .put(DeepSeekSecret::new("second".to_string()).unwrap()) + .unwrap(); + assert_eq!(store.read().unwrap().expose(), "second"); + store.delete().unwrap(); + assert!(!store.contains().expect("fake store remains available")); + } + + #[test] + fn failed_atomic_replacement_preserves_old_secret_and_invalidates_readiness() { + let (_temp, runtime) = runtime(FakeStore::default()); + let ready = + runtime.save_and_verify("old-key".to_string(), &ScriptedVerifier::success(), |_| { + None + }); + assert!(ready.chat_completion_ready); + + runtime.store.lock().unwrap().fail_put = true; + let failed = + runtime.save_and_verify("new-key".to_string(), &ScriptedVerifier::success(), |_| { + None + }); + assert_eq!( + failed.code, + DeepSeekReadinessCode::CredentialStoreUnavailable + ); + assert!(!failed.chat_completion_ready); + + let store = runtime.store.lock().unwrap(); + assert_eq!(store.secret.as_ref().unwrap().expose(), "old-key"); + drop(store); + assert!(matches!( + runtime.resolve_ready_key(|_| None), + Err(DeepSeekReadinessCode::NotChecked) + )); + } + + #[test] + fn environment_presence_is_not_readiness() { + let (_temp, runtime) = runtime(FakeStore::default()); + let projection = runtime.projection(|_| Some("env-key".to_string())); + assert_eq!(projection.source, DeepSeekCredentialSource::Environment); + assert_eq!( + projection.verification, + DeepSeekVerificationState::NotChecked + ); + assert!(!projection.chat_completion_ready); + } + + #[test] + fn stored_key_precedes_environment_and_requires_receipt() { + let (_temp, runtime) = runtime(FakeStore::default()); + let projection = runtime.save_and_verify( + "stored-key".to_string(), + &ScriptedVerifier::success(), + |_| Some("env-key".to_string()), + ); + assert_eq!(projection.source, DeepSeekCredentialSource::Stored); + assert!(projection.chat_completion_ready); + assert_eq!( + runtime + .resolve_ready_key(|_| Some("env-key".to_string())) + .unwrap() + .expose(), + "stored-key" + ); + } + + #[test] + fn replacing_key_increments_revision_and_invalidates_old_receipt() { + let (temp, runtime) = runtime(FakeStore::default()); + runtime.save_and_verify("one".to_string(), &ScriptedVerifier::success(), |_| None); + let first = FileDeepSeekCredentialStateStore::new(temp.path()) + .unwrap() + .load() + .unwrap(); + runtime.save_and_verify("two".to_string(), &ScriptedVerifier::success(), |_| None); + let second = FileDeepSeekCredentialStateStore::new(temp.path()) + .unwrap() + .load() + .unwrap(); + assert_eq!(second.credential_revision, first.credential_revision + 1); + assert_eq!( + second.receipt.unwrap().credential_revision, + second.credential_revision + ); + } + + #[test] + fn verification_does_not_hold_vault_call_across_network_io() { + let flag = Arc::new(AtomicBool::new(false)); + let store = FakeStore { + secret: None, + inside_call: Some(flag.clone()), + fail_put: false, + }; + let (_temp, runtime) = runtime(store); + let mut verifier = ScriptedVerifier::success(); + verifier.store_inside_call = Some(flag); + let projection = runtime.save_and_verify("key".to_string(), &verifier, |_| None); + assert!(projection.chat_completion_ready); + } + + #[test] + fn unavailable_balance_is_blocked_without_amounts() { + let (_temp, runtime) = runtime(FakeStore::default()); + let verifier = ScriptedVerifier::new(vec![Ok(false)], vec![]); + let projection = runtime.save_and_verify("key".to_string(), &verifier, |_| None); + assert_eq!(projection.code, DeepSeekReadinessCode::InsufficientBalance); + assert_eq!(projection.balance_available, Some(false)); + assert!(!projection.chat_completion_ready); + } + + #[test] + fn missing_required_model_is_blocked() { + let (_temp, runtime) = runtime(FakeStore::default()); + let verifier = ScriptedVerifier::new(vec![Ok(true)], vec![Ok(vec![DEEPSEEK_FLASH_MODEL])]); + let projection = runtime.save_and_verify("key".to_string(), &verifier, |_| None); + assert_eq!(projection.code, DeepSeekReadinessCode::ModelUnavailable); + assert_eq!(projection.flash_available, Some(true)); + assert_eq!(projection.pro_available, Some(false)); + } + + #[test] + fn transport_failures_map_to_frozen_codes() { + let cases = [ + ( + DeepSeekTransportFailure::HttpStatus(401), + false, + DeepSeekReadinessCode::AuthenticationFailed, + ), + ( + DeepSeekTransportFailure::HttpStatus(402), + false, + DeepSeekReadinessCode::InsufficientBalance, + ), + ( + DeepSeekTransportFailure::HttpStatus(429), + false, + DeepSeekReadinessCode::RateLimited, + ), + ( + DeepSeekTransportFailure::HttpStatus(400), + false, + DeepSeekReadinessCode::RequestInvalid, + ), + ( + DeepSeekTransportFailure::HttpStatus(422), + false, + DeepSeekReadinessCode::RequestInvalid, + ), + ( + DeepSeekTransportFailure::HttpStatus(500), + false, + DeepSeekReadinessCode::ProviderUnavailable, + ), + ( + DeepSeekTransportFailure::HttpStatus(503), + false, + DeepSeekReadinessCode::ProviderUnavailable, + ), + ( + DeepSeekTransportFailure::NetworkUnavailable, + false, + DeepSeekReadinessCode::NetworkUnavailable, + ), + ( + DeepSeekTransportFailure::Timeout, + false, + DeepSeekReadinessCode::NetworkTimeout, + ), + ( + DeepSeekTransportFailure::Protocol, + false, + DeepSeekReadinessCode::ProviderProtocolError, + ), + ( + DeepSeekTransportFailure::HttpStatus(404), + true, + DeepSeekReadinessCode::ModelUnavailable, + ), + ]; + for (failure, during_models, expected) in cases { + assert_eq!( + readiness_code_from_transport(failure, during_models), + expected + ); + } + } + + #[test] + fn receipt_and_projection_are_secret_path_and_provider_body_free() { + let (_temp, runtime) = runtime(FakeStore::default()); + let projection = runtime.save_and_verify( + "marker-secret-value".to_string(), + &ScriptedVerifier::success(), + |_| None, + ); + let json = serde_json::to_string(&projection).unwrap(); + assert!(!json.contains("marker-secret-value")); + assert!(!json.contains("provider raw body")); + assert!(!json.contains("app_data")); + assert!(!json.contains("vault")); + assert!(!json.contains("total_balance")); + assert!(json.contains(DEEPSEEK_FLASH_MODEL)); + assert!(json.contains(DEEPSEEK_PRO_MODEL)); + } + + #[test] + fn remove_deletes_stored_key_and_receipt_then_reveals_environment() { + let (temp, runtime) = runtime(FakeStore::default()); + runtime.save_and_verify("stored".to_string(), &ScriptedVerifier::success(), |_| None); + let projection = runtime.remove(|_| Some("env".to_string())); + assert_eq!(projection.source, DeepSeekCredentialSource::Environment); + assert_eq!( + projection.verification, + DeepSeekVerificationState::NotChecked + ); + assert!(!temp.path().join(DEEPSEEK_STATE_FILE).exists()); + } + + #[test] + fn onboarding_transition_table_is_kernel_owned() { + let missing_workspace = WorkspaceReadinessProjection { + configured: false, + workspace_name: None, + workspace_root_display: None, + root_exists: false, + managed_directories_ready: false, + writable: None, + code: WorkspaceReadinessCode::WorkspaceMissing, + retryable: true, + message_key: "onboarding.workspace.workspace_missing".to_string(), + }; + let ready_workspace = WorkspaceReadinessProjection { + configured: true, + workspace_name: Some("Work".to_string()), + workspace_root_display: Some("Work".to_string()), + root_exists: true, + managed_directories_ready: true, + writable: Some(true), + code: WorkspaceReadinessCode::Ready, + retryable: false, + message_key: "onboarding.workspace.ready".to_string(), + }; + let missing = build_onboarding_readiness_projection( + DeepSeekReadinessProjection::missing(), + missing_workspace.clone(), + "1.0.2", + ); + assert_eq!(missing.next_step, OnboardingNextStep::DeepseekKey); + let verified = DeepSeekReadinessProjection::for_code( + DeepSeekCredentialSource::Stored, + DeepSeekVerificationState::Verified, + DeepSeekReadinessCode::Ready, + Some(&DeepSeekVerificationReceipt::verified(1, true, true)), + ); + let needs_workspace = + build_onboarding_readiness_projection(verified.clone(), missing_workspace, "1.0.2"); + assert_eq!(needs_workspace.next_step, OnboardingNextStep::Workspace); + let ready = build_onboarding_readiness_projection(verified, ready_workspace, "1.0.2"); + assert_eq!(ready.overall, OnboardingOverallStatus::Ready); + assert_eq!(ready.next_step, OnboardingNextStep::Ready); + } +} diff --git a/apps/desktop/src-tauri/src/kernel/deepseek_pricing.rs b/apps/desktop/src-tauri/src/kernel/deepseek_pricing.rs index 0d72b98..f20f16a 100644 --- a/apps/desktop/src-tauri/src/kernel/deepseek_pricing.rs +++ b/apps/desktop/src-tauri/src/kernel/deepseek_pricing.rs @@ -90,8 +90,6 @@ impl DeepSeekPricingSettings { #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct DeepSeekPricingState { - pub app_data_dir: String, - pub settings_file: String, pub settings: DeepSeekPricingSettings, pub pricing_configured: bool, pub note: String, @@ -112,11 +110,7 @@ pub fn load_deepseek_pricing_state( DeepSeekPricingSettings::default() }; - Ok(deepseek_pricing_state_from_settings( - app_data_dir, - &settings_file, - settings, - )) + Ok(deepseek_pricing_state_from_settings(settings)) } pub fn save_deepseek_pricing_settings( @@ -131,11 +125,7 @@ pub fn save_deepseek_pricing_settings( serde_json::to_string_pretty(&settings).map_err(DeepSeekPricingError::Json)?; fs::write(&settings_file, settings_json).map_err(DeepSeekPricingError::Write)?; - Ok(deepseek_pricing_state_from_settings( - app_data_dir, - &settings_file, - settings, - )) + Ok(deepseek_pricing_state_from_settings(settings)) } pub fn estimate_deepseek_chat_cost_micro_usd( @@ -171,15 +161,9 @@ pub fn try_estimate_deepseek_chat_cost_micro_usd( Ok(Some(total.min(u64::MAX as u128) as u64)) } -fn deepseek_pricing_state_from_settings( - app_data_dir: &Path, - settings_file: &Path, - settings: DeepSeekPricingSettings, -) -> DeepSeekPricingState { +fn deepseek_pricing_state_from_settings(settings: DeepSeekPricingSettings) -> DeepSeekPricingState { let pricing_configured = settings.enabled && settings.has_any_rate(); DeepSeekPricingState { - app_data_dir: app_data_dir.to_string_lossy().to_string(), - settings_file: settings_file.to_string_lossy().to_string(), settings, pricing_configured, note: if pricing_configured { @@ -313,9 +297,10 @@ mod tests { assert!(!state.settings.enabled); assert!(!state.pricing_configured); - assert!(state - .settings_file - .ends_with(DEEPSEEK_PRICING_SETTINGS_FILE)); + let state_json = serde_json::to_string(&state).expect("state serializes"); + assert!(!state_json.contains(&temp_dir.path().to_string_lossy().to_string())); + assert!(!state_json.contains("app_data_dir")); + assert!(!state_json.contains("settings_file")); } #[test] @@ -335,6 +320,10 @@ mod tests { assert!(saved.pricing_configured); assert_eq!(saved.settings.flash_prompt_usd_per_million_tokens, "0.14"); + assert!(temp_dir + .path() + .join(DEEPSEEK_PRICING_SETTINGS_FILE) + .is_file()); let loaded = load_deepseek_pricing_state(temp_dir.path()).expect("state reloads"); assert_eq!(loaded, saved); diff --git a/apps/desktop/src-tauri/src/kernel/local_directory.rs b/apps/desktop/src-tauri/src/kernel/local_directory.rs index 9af7b40..a130229 100644 --- a/apps/desktop/src-tauri/src/kernel/local_directory.rs +++ b/apps/desktop/src-tauri/src/kernel/local_directory.rs @@ -1,8 +1,10 @@ use std::ffi::OsStr; -use std::fs; +use std::fs::{self, OpenOptions}; +use std::io::Write; use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; +use uuid::Uuid; pub const LOCAL_DIRECTORY_SETTINGS_FILE: &str = "local-directories.json"; pub const LOCAL_EVIDENCE_DIR_NAME: &str = "evidence"; @@ -48,6 +50,9 @@ pub enum LocalDirectoryError { #[error("local directory settings are invalid json: {0}")] Json(serde_json::Error), + + #[error("local workspace managed directories must stay inside the workspace root")] + ManagedDirectoryEscape, } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] @@ -168,8 +173,23 @@ impl LocalDirectorySettings { } fn ensure_directory_structure(&self) -> Result<(), LocalDirectoryError> { + let workspace = Path::new(&self.workspace_dir); + fs::create_dir_all(workspace).map_err(LocalDirectoryError::Create)?; + let canonical_workspace = workspace + .canonicalize() + .map_err(LocalDirectoryError::Create)?; for directory in self.standard_directories() { - fs::create_dir_all(directory).map_err(LocalDirectoryError::Create)?; + let directory = PathBuf::from(directory); + if !directory.starts_with(workspace) { + return Err(LocalDirectoryError::ManagedDirectoryEscape); + } + fs::create_dir_all(&directory).map_err(LocalDirectoryError::Create)?; + let canonical = directory + .canonicalize() + .map_err(LocalDirectoryError::Create)?; + if !canonical.starts_with(&canonical_workspace) { + return Err(LocalDirectoryError::ManagedDirectoryEscape); + } } Ok(()) @@ -246,6 +266,108 @@ pub struct LocalDirectoryReadinessStatus { pub note: String, } +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum WorkspaceReadinessCode { + Ready, + WorkspaceMissing, + WorkspaceUnavailable, + WorkspacePermissionDenied, + WorkspaceProbeCleanupFailed, + WorkspaceSettingsInvalid, +} + +impl WorkspaceReadinessCode { + pub fn as_str(self) -> &'static str { + match self { + Self::Ready => "ready", + Self::WorkspaceMissing => "workspace_missing", + Self::WorkspaceUnavailable => "workspace_unavailable", + Self::WorkspacePermissionDenied => "workspace_permission_denied", + Self::WorkspaceProbeCleanupFailed => "workspace_probe_cleanup_failed", + Self::WorkspaceSettingsInvalid => "workspace_settings_invalid", + } + } + + pub fn retryable(self) -> bool { + !matches!(self, Self::Ready | Self::WorkspaceSettingsInvalid) + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct WorkspaceReadinessProjection { + pub configured: bool, + pub workspace_name: Option, + pub workspace_root_display: Option, + pub root_exists: bool, + pub managed_directories_ready: bool, + pub writable: Option, + pub code: WorkspaceReadinessCode, + pub retryable: bool, + pub message_key: String, +} + +impl WorkspaceReadinessProjection { + fn for_code( + settings: Option<&LocalDirectorySettings>, + code: WorkspaceReadinessCode, + root_exists: bool, + managed_directories_ready: bool, + writable: Option, + ) -> Self { + Self { + configured: settings.is_some(), + workspace_name: settings.map(|settings| settings.workspace_name.clone()), + workspace_root_display: settings + .map(|settings| derive_workspace_name(&settings.workspace_dir)), + root_exists, + managed_directories_ready, + writable, + code, + retryable: code.retryable(), + message_key: format!("onboarding.workspace.{}", code.as_str()), + } + } + + pub fn settings_invalid() -> Self { + Self::for_code( + None, + WorkspaceReadinessCode::WorkspaceSettingsInvalid, + false, + false, + None, + ) + } +} + +pub fn workspace_readiness_projection_from_setup_error( + error: &LocalDirectoryError, +) -> WorkspaceReadinessProjection { + let code = match error { + LocalDirectoryError::WorkspaceNotDirectory + | LocalDirectoryError::EvidenceNotDirectory + | LocalDirectoryError::ExportNotDirectory => WorkspaceReadinessCode::WorkspaceUnavailable, + LocalDirectoryError::Create(error) | LocalDirectoryError::Migrate(error) => { + match error.kind() { + std::io::ErrorKind::PermissionDenied => { + WorkspaceReadinessCode::WorkspacePermissionDenied + } + _ => WorkspaceReadinessCode::WorkspaceUnavailable, + } + } + LocalDirectoryError::MissingWorkspace + | LocalDirectoryError::MissingEvidence + | LocalDirectoryError::MissingExport + | LocalDirectoryError::Read(_) + | LocalDirectoryError::Write(_) + | LocalDirectoryError::Json(_) + | LocalDirectoryError::ManagedDirectoryEscape => { + WorkspaceReadinessCode::WorkspaceSettingsInvalid + } + }; + WorkspaceReadinessProjection::for_code(None, code, false, false, None) +} + impl Default for LocalDirectoryReadinessStatus { fn default() -> Self { Self { @@ -286,6 +408,133 @@ pub fn local_directory_readiness_from_state( } } +pub fn workspace_readiness_projection_from_state( + state: &LocalDirectoryState, +) -> WorkspaceReadinessProjection { + let Some(settings) = state.settings.as_ref() else { + return WorkspaceReadinessProjection::for_code( + None, + WorkspaceReadinessCode::WorkspaceMissing, + false, + false, + None, + ); + }; + let workspace = Path::new(&settings.workspace_dir); + if !workspace.is_dir() { + return WorkspaceReadinessProjection::for_code( + Some(settings), + WorkspaceReadinessCode::WorkspaceUnavailable, + false, + false, + None, + ); + } + let canonical_workspace = match workspace.canonicalize() { + Ok(path) => path, + Err(_) => { + return WorkspaceReadinessProjection::for_code( + Some(settings), + WorkspaceReadinessCode::WorkspaceUnavailable, + true, + false, + None, + ) + } + }; + let managed_directories_ready = settings + .standard_directories() + .into_iter() + .all(|directory| { + let directory = PathBuf::from(directory); + directory.is_dir() + && directory + .canonicalize() + .map(|canonical| canonical.starts_with(&canonical_workspace)) + .unwrap_or(false) + }); + if !managed_directories_ready { + return WorkspaceReadinessProjection::for_code( + Some(settings), + WorkspaceReadinessCode::WorkspaceSettingsInvalid, + true, + false, + None, + ); + } + match workspace_write_probe(workspace) { + Ok(()) => WorkspaceReadinessProjection::for_code( + Some(settings), + WorkspaceReadinessCode::Ready, + true, + true, + Some(true), + ), + Err(WorkspaceReadinessCode::WorkspaceProbeCleanupFailed) => { + WorkspaceReadinessProjection::for_code( + Some(settings), + WorkspaceReadinessCode::WorkspaceProbeCleanupFailed, + true, + true, + Some(false), + ) + } + Err(_) => WorkspaceReadinessProjection::for_code( + Some(settings), + WorkspaceReadinessCode::WorkspacePermissionDenied, + true, + true, + Some(false), + ), + } +} + +fn workspace_write_probe(root: &Path) -> Result<(), WorkspaceReadinessCode> { + workspace_write_probe_with_cleanup(root, |path| fs::remove_file(path)) +} + +fn workspace_write_probe_with_cleanup( + root: &Path, + cleanup: impl FnOnce(&Path) -> std::io::Result<()>, +) -> Result<(), WorkspaceReadinessCode> { + let canonical_root = root + .canonicalize() + .map_err(|_| WorkspaceReadinessCode::WorkspaceUnavailable)?; + let probe = root.join(format!(".ds-agent-readiness-{}.tmp", Uuid::new_v4())); + if probe.parent() != Some(root) || !probe.starts_with(root) { + return Err(WorkspaceReadinessCode::WorkspaceSettingsInvalid); + } + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&probe) + .map_err(|_| WorkspaceReadinessCode::WorkspacePermissionDenied)?; + let write_result = file + .write_all(b"ds-agent-readiness-v1") + .and_then(|_| file.sync_all()); + drop(file); + if write_result.is_err() { + let _ = fs::remove_file(&probe); + return Err(WorkspaceReadinessCode::WorkspacePermissionDenied); + } + let canonical_probe = match probe.canonicalize() { + Ok(path) if path.starts_with(&canonical_root) => path, + _ => { + let _ = fs::remove_file(&probe); + return Err(WorkspaceReadinessCode::WorkspaceSettingsInvalid); + } + }; + if cleanup(&canonical_probe).is_err() { + let _ = fs::remove_file(&canonical_probe); + return Err(WorkspaceReadinessCode::WorkspaceProbeCleanupFailed); + } + if canonical_probe.exists() { + let _ = fs::remove_file(&canonical_probe); + return Err(WorkspaceReadinessCode::WorkspaceProbeCleanupFailed); + } + Ok(()) +} + pub fn load_local_directory_state( app_data_dir: impl AsRef, ) -> Result { @@ -298,7 +547,8 @@ pub fn load_local_directory_state( serde_json::from_str(&settings_json).map_err(LocalDirectoryError::Json)?; settings.normalize_derived_directories()?; if settings.workspace_exists() { - settings.ensure_directory_structure()?; + // Existing compatible settings are repaired in place without moving data. + let _ = settings.ensure_directory_structure(); } Some(settings) } else { @@ -494,10 +744,12 @@ mod tests { use std::fs; use super::{ - load_local_directory_state, save_local_directory_settings, LocalDirectorySettings, - LOCAL_DIRECTORY_SETTINGS_FILE, LOCAL_EVIDENCE_DIR_NAME, LOCAL_EXPORT_DIR_NAME, - LOCAL_LOGS_DIR_NAME, LOCAL_MEMORY_DIR_NAME, LOCAL_REPORTS_DIR_NAME, LOCAL_RUNS_DIR_NAME, - LOCAL_SOURCES_DIR_NAME, LOCAL_WORK_PACKAGES_DIR_NAME, + load_local_directory_state, save_local_directory_settings, + workspace_readiness_projection_from_setup_error, workspace_readiness_projection_from_state, + workspace_write_probe_with_cleanup, LocalDirectoryError, LocalDirectorySettings, + WorkspaceReadinessCode, LOCAL_DIRECTORY_SETTINGS_FILE, LOCAL_EVIDENCE_DIR_NAME, + LOCAL_EXPORT_DIR_NAME, LOCAL_LOGS_DIR_NAME, LOCAL_MEMORY_DIR_NAME, LOCAL_REPORTS_DIR_NAME, + LOCAL_RUNS_DIR_NAME, LOCAL_SOURCES_DIR_NAME, LOCAL_WORK_PACKAGES_DIR_NAME, }; #[test] @@ -579,6 +831,36 @@ mod tests { assert!(temp_dir.path().join(LOCAL_DIRECTORY_SETTINGS_FILE).exists()); } + #[test] + fn first_run_workspace_setup_does_not_migrate_unrelated_existing_data() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let app_data_dir = temp_dir.path().join("fresh-app-data"); + let unrelated_workspace = temp_dir.path().join("old-workspace"); + let unrelated_file = unrelated_workspace + .join(LOCAL_EVIDENCE_DIR_NAME) + .join("keep.txt"); + fs::create_dir_all(unrelated_file.parent().expect("unrelated parent")) + .expect("create unrelated workspace"); + fs::write(&unrelated_file, "keep in place").expect("write unrelated data"); + let new_workspace = temp_dir.path().join("new-workspace"); + + save_local_directory_settings( + &app_data_dir, + LocalDirectorySettings::from_workspace_dir(new_workspace.to_string_lossy().to_string()) + .expect("new settings"), + ) + .expect("first-run settings save"); + + assert_eq!( + fs::read_to_string(&unrelated_file).expect("unrelated data remains"), + "keep in place" + ); + assert!(!new_workspace + .join(LOCAL_EVIDENCE_DIR_NAME) + .join("keep.txt") + .exists()); + } + #[test] fn saving_local_directory_settings_preserves_workspace_name() { let temp_dir = tempfile::tempdir().expect("temp dir"); @@ -733,6 +1015,22 @@ mod tests { assert!(workspace_dir.join(LOCAL_EVIDENCE_DIR_NAME).is_dir()); } + #[test] + fn corrupt_workspace_settings_are_not_rewritten_or_deleted() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let settings_file = temp_dir.path().join(LOCAL_DIRECTORY_SETTINGS_FILE); + fs::write(&settings_file, "{not valid json").expect("write corrupt settings"); + + let error = load_local_directory_state(temp_dir.path()) + .expect_err("corrupt settings must be reported"); + + assert!(matches!(error, super::LocalDirectoryError::Json(_))); + assert_eq!( + fs::read_to_string(settings_file).expect("corrupt settings preserved"), + "{not valid json" + ); + } + #[test] fn local_directory_settings_reject_blank_required_paths() { let error = LocalDirectorySettings::from_workspace_dir(" ".to_string()) @@ -740,4 +1038,117 @@ mod tests { assert_eq!(error.to_string(), "workspace directory is required"); } + + #[test] + fn workspace_readiness_uses_bounded_probe_and_redacted_display() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let workspace_dir = temp_dir.path().join("Hotel Workspace"); + let state = save_local_directory_settings( + temp_dir.path().join("app-data"), + LocalDirectorySettings::from_workspace_dir_and_name( + workspace_dir.to_string_lossy().to_string(), + "Hotel Ops".to_string(), + ) + .expect("settings"), + ) + .expect("save"); + + let projection = workspace_readiness_projection_from_state(&state); + let json = serde_json::to_string(&projection).expect("projection json"); + + assert_eq!(projection.code, WorkspaceReadinessCode::Ready); + assert_eq!(projection.writable, Some(true)); + assert_eq!(projection.workspace_name.as_deref(), Some("Hotel Ops")); + assert_eq!( + projection.workspace_root_display.as_deref(), + Some("Hotel Workspace") + ); + assert!(!json.contains(&temp_dir.path().to_string_lossy().to_string())); + assert!(fs::read_dir(&workspace_dir) + .expect("workspace entries") + .all(|entry| !entry + .expect("entry") + .file_name() + .to_string_lossy() + .starts_with(".ds-agent-readiness-"))); + } + + #[test] + fn workspace_managed_directories_cannot_escape_root() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let workspace_dir = temp_dir.path().join("workspace"); + let outside = temp_dir.path().join("outside"); + let settings = LocalDirectorySettings::from_optional_dirs( + workspace_dir.to_string_lossy().to_string(), + None, + Some(outside.to_string_lossy().to_string()), + None, + ) + .expect("settings parse"); + + let error = save_local_directory_settings(temp_dir.path().join("app-data"), settings) + .expect_err("escape must fail"); + + assert!(matches!( + error, + super::LocalDirectoryError::ManagedDirectoryEscape + )); + assert!(!outside.exists()); + } + + #[test] + fn workspace_probe_cleanup_failure_blocks_readiness_without_residue() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let error = workspace_write_probe_with_cleanup(temp_dir.path(), |_| { + Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "injected cleanup failure", + )) + }) + .expect_err("cleanup failure must block"); + + assert_eq!(error, WorkspaceReadinessCode::WorkspaceProbeCleanupFailed); + assert!(fs::read_dir(temp_dir.path()) + .expect("root entries") + .next() + .is_none()); + } + + #[test] + fn workspace_probe_maps_create_failure_to_permission_denied() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let file_root = temp_dir.path().join("not-a-directory"); + fs::write(&file_root, "occupied").expect("write file root"); + + let error = workspace_write_probe_with_cleanup(&file_root, |_| Ok(())) + .expect_err("probe create must fail"); + + assert_eq!(error, WorkspaceReadinessCode::WorkspacePermissionDenied); + assert_eq!( + fs::read_to_string(file_root).expect("file root remains"), + "occupied" + ); + } + + #[test] + fn workspace_setup_errors_map_to_stable_secret_free_codes() { + let permission = + workspace_readiness_projection_from_setup_error(&LocalDirectoryError::Create( + std::io::Error::new(std::io::ErrorKind::PermissionDenied, "private path detail"), + )); + let invalid = workspace_readiness_projection_from_setup_error( + &LocalDirectoryError::ManagedDirectoryEscape, + ); + let permission_json = serde_json::to_string(&permission).expect("projection serializes"); + + assert_eq!( + permission.code, + WorkspaceReadinessCode::WorkspacePermissionDenied + ); + assert_eq!( + invalid.code, + WorkspaceReadinessCode::WorkspaceSettingsInvalid + ); + assert!(!permission_json.contains("private path detail")); + } } diff --git a/apps/desktop/src-tauri/src/kernel/mod.rs b/apps/desktop/src-tauri/src/kernel/mod.rs index da6a8f7..157631c 100644 --- a/apps/desktop/src-tauri/src/kernel/mod.rs +++ b/apps/desktop/src-tauri/src/kernel/mod.rs @@ -15,6 +15,7 @@ pub mod computer_use_runtime; pub mod computer_use_session; pub mod connectors; pub mod deepseek; +pub mod deepseek_credential; pub mod deepseek_pricing; pub mod event_store; pub mod expert_team; diff --git a/apps/desktop/src-tauri/src/kernel/work_package.rs b/apps/desktop/src-tauri/src/kernel/work_package.rs index 68e6c7e..185a3f0 100644 --- a/apps/desktop/src-tauri/src/kernel/work_package.rs +++ b/apps/desktop/src-tauri/src/kernel/work_package.rs @@ -1,7 +1,7 @@ use crate::kernel::computer_use::{ computer_use_backend_status_for_strategy_with_codex_bridge_config, ComputerUseBackendStatus, }; -use crate::kernel::deepseek::{deepseek_credential_status_from_env, DeepSeekCredentialStatus}; +use crate::kernel::deepseek_credential::DeepSeekReadinessProjection; use crate::kernel::local_directory::LocalDirectoryReadinessStatus; use crate::kernel::models::{FoundationState, MemoryCandidate, TaskRecord}; use crate::kernel::network_search::{ @@ -45,7 +45,11 @@ pub struct WorkPackage { #[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] pub struct WorkPackageToolReadiness { - pub deepseek: DeepSeekCredentialStatus, + #[serde( + default, + deserialize_with = "deserialize_work_package_deepseek_readiness" + )] + pub deepseek: DeepSeekReadinessProjection, pub network_search: NetworkSearchRouteStatus, pub computer_use: ComputerUseBackendStatus, #[serde(default)] @@ -54,9 +58,33 @@ pub struct WorkPackageToolReadiness { pub tool_strategy: ModelDrivenToolStrategy, } +fn deserialize_work_package_deepseek_readiness<'de, D>( + deserializer: D, +) -> Result +where + D: serde::Deserializer<'de>, +{ + let value = ::deserialize(deserializer)?; + if value.get("source").is_none() + || value.get("verification").is_none() + || value.get("code").is_none() + { + return Ok(DeepSeekReadinessProjection::default()); + } + let projection = serde_json::from_value::(value) + .map_err(serde::de::Error::custom)?; + let coherent_ready = projection.verification + == crate::kernel::deepseek_credential::DeepSeekVerificationState::Verified + && projection.code == crate::kernel::deepseek_credential::DeepSeekReadinessCode::Ready; + if projection.chat_completion_ready != coherent_ready { + return Ok(DeepSeekReadinessProjection::default()); + } + Ok(projection) +} + impl Default for WorkPackageToolReadiness { fn default() -> Self { - let deepseek = deepseek_credential_status_from_env(|_| None); + let deepseek = DeepSeekReadinessProjection::default(); let foundation_state = FoundationState::default(); let tool_strategy = model_driven_tool_strategy_with_native_network_search_bridge( foundation_state.large_model_provider, @@ -343,8 +371,10 @@ mod tests { export_work_package, export_work_package_with_tool_readiness, parse_work_package_json, WorkPackageError, WorkPackageToolReadiness, REDACTED_SOURCE_MACHINE_EVIDENCE_HANDLE, }; - use crate::kernel::deepseek::{ - deepseek_credential_status_from_env, DeepSeekCredentialStatus, DEEPSEEK_API_KEY_ENV, + use crate::kernel::deepseek::{DEEPSEEK_FLASH_MODEL, DEEPSEEK_PRO_MODEL}; + use crate::kernel::deepseek_credential::{ + DeepSeekCredentialSource, DeepSeekReadinessCode, DeepSeekReadinessProjection, + DeepSeekVerificationState, }; use crate::kernel::local_directory::{ local_directory_readiness_from_state, LocalDirectorySettings, LocalDirectoryState, @@ -608,13 +638,21 @@ mod tests { #[test] fn tool_readiness_export_package_stays_secret_safe() { - let deepseek_status = deepseek_credential_status_from_env(|name| { - if name == DEEPSEEK_API_KEY_ENV { - Some("test-secret-token".to_string()) - } else { - None - } - }); + let deepseek_status = DeepSeekReadinessProjection { + source: DeepSeekCredentialSource::Environment, + configured: true, + verification: DeepSeekVerificationState::NotChecked, + code: DeepSeekReadinessCode::NotChecked, + chat_completion_ready: false, + balance_available: None, + flash_model: DEEPSEEK_FLASH_MODEL.to_string(), + pro_model: DEEPSEEK_PRO_MODEL.to_string(), + flash_available: None, + pro_available: None, + retryable: false, + last_verified_at: None, + message_key: "onboarding.deepseek.not_checked".to_string(), + }; let package = export_work_package_with_tool_readiness( FoundationState::default(), Vec::new(), @@ -628,8 +666,9 @@ mod tests { let package_json = serde_json::to_string(&package).expect("package serializes"); - assert!(package.tool_readiness.deepseek.api_key_configured); - assert!(package_json.contains(DEEPSEEK_API_KEY_ENV)); + assert!(package.tool_readiness.deepseek.configured); + assert!(package_json.contains("\"source\":\"environment\"")); + assert!(!package_json.contains("DEEPSEEK_API_KEY")); assert!(package_json.contains("pending_user_confirmation")); assert!(package_json.contains("\"local_directories\"")); if cfg!(target_os = "macos") { @@ -707,22 +746,44 @@ mod tests { assert_eq!( package.tool_readiness.deepseek, - DeepSeekCredentialStatus { - base_url: "https://api.deepseek.com".to_string(), - chat_completions_url: "https://api.deepseek.com/chat/completions".to_string(), - api_key_env_var: DEEPSEEK_API_KEY_ENV.to_string(), - api_key_configured: false, - chat_completion_ready: false, - flash_model: "deepseek-v4-flash".to_string(), - pro_model: "deepseek-v4-pro".to_string(), - readiness_note: - "set DEEPSEEK_API_KEY in the local process environment to enable Chat Completions requests" - .to_string(), - } + DeepSeekReadinessProjection::default() ); assert_eq!(package.tool_readiness, WorkPackageToolReadiness::default()); } + #[test] + fn tool_readiness_legacy_deepseek_shape_cannot_import_false_ready_authority() { + let mut tool_readiness = serde_json::to_value(WorkPackageToolReadiness::default()) + .expect("readiness serializes"); + tool_readiness["deepseek"] = serde_json::json!({ + "base_url": "https://api.deepseek.com", + "chat_completions_url": "https://api.deepseek.com/chat/completions", + "api_key_env_var": "DEEPSEEK_API_KEY", + "api_key_configured": true, + "chat_completion_ready": true, + "flash_model": "deepseek-v4-flash", + "pro_model": "deepseek-v4-pro" + }); + let package_json = serde_json::json!({ + "version": "deepseek-agent-os.work-package.v1", + "exported_at": chrono::Utc::now(), + "foundation_state": FoundationState::default(), + "tool_readiness": tool_readiness, + "task_records": [], + "memory_candidates": [], + "operations_briefing_runs": [] + }) + .to_string(); + + let package = parse_work_package_json(&package_json).expect("legacy package parses"); + + assert_eq!( + package.tool_readiness.deepseek, + DeepSeekReadinessProjection::default() + ); + assert!(!package.tool_readiness.deepseek.chat_completion_ready); + } + #[test] fn operations_export_legacy_package_json_defaults_briefing_runs() { let package_json = serde_json::json!({ diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index 1ce4ab0..21cb963 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -33,15 +33,15 @@ use commands::{ export_operations_briefing_report, export_work_package, finish_agent_run_record, get_agent_soul_profile, get_computer_control_unlock_status, get_computer_use_backend_status, get_computer_use_backend_status_for_model, get_deepseek_chat_cache_state, - get_deepseek_credential_status, get_deepseek_pricing_state, get_deepseek_user_balance, - get_foundation_state, get_local_directory_state, get_model_driven_tool_strategy, - get_network_search_route_status, get_network_search_route_status_for_model, - import_work_package, ingest_evidence_folder, install_local_skill_manifest, - install_local_skill_zip_package, install_remote_skill_zip_package, - install_skill_from_repository_url, link_memory_candidate_to_conflicts, link_memory_records, - list_agent_context_receipts, list_agent_run_records, list_agent_tool_contracts, - list_agent_tool_invocations, list_capability_access_records, list_capability_catalog, - list_capability_invocations, list_deepseek_chat_telemetry, list_durable_computer_use_sessions, + get_deepseek_pricing_state, get_foundation_state, get_local_directory_state, + get_model_driven_tool_strategy, get_network_search_route_status, + get_network_search_route_status_for_model, get_onboarding_readiness, import_work_package, + ingest_evidence_folder, install_local_skill_manifest, install_local_skill_zip_package, + install_remote_skill_zip_package, install_skill_from_repository_url, + link_memory_candidate_to_conflicts, link_memory_records, list_agent_context_receipts, + list_agent_run_records, list_agent_tool_contracts, list_agent_tool_invocations, + list_capability_access_records, list_capability_catalog, list_capability_invocations, + list_deepseek_chat_telemetry, list_durable_computer_use_sessions, list_durable_computer_use_steps, list_memory_candidate_records, list_memory_maintenance_reviews, list_memory_records, list_operations_briefing_runs, list_pending_capability_access_records, list_permission_audit_entries, @@ -54,19 +54,20 @@ use commands::{ queue_expert_team_retries, queue_parent_agent_synthesis, read_drive_boundary, read_email_boundary, read_local_file, record_agent_run_artifact_record, record_agent_run_step_record, record_memory_maintenance_review_action, record_permission_audit, - record_selected_memory_feedback, reobserve_durable_computer_use_session, - replace_memory_candidate_conflicts, request_agent_run_cancel_record, request_capability_access, - reset_skill_trust, resolve_capability_access_request, resolve_memory_candidate, - resume_agent_chat_action, run_agent_chat, run_durable_computer_use_step, - run_memory_background_maintenance, run_next_queued_agent_chat_worker, run_operations_briefing, - run_skill_update_sweep, run_terminal_read, run_terminal_write, save_agent_soul_profile, + record_selected_memory_feedback, remove_deepseek_api_key, + reobserve_durable_computer_use_session, replace_memory_candidate_conflicts, + request_agent_run_cancel_record, request_capability_access, reset_skill_trust, + resolve_capability_access_request, resolve_memory_candidate, resume_agent_chat_action, + run_agent_chat, run_durable_computer_use_step, run_memory_background_maintenance, + run_next_queued_agent_chat_worker, run_operations_briefing, run_skill_update_sweep, + run_terminal_read, run_terminal_write, save_agent_soul_profile, save_deepseek_api_key, save_deepseek_pricing_settings, save_local_directory_settings, search_memory_records, search_network_boundary, seed_operations_briefing_evidence_templates, send_email_boundary, set_skill_enabled, stage_agent_attachments, start_agent_run_record, start_durable_computer_use_session, submit_browser_boundary, take_over_durable_computer_use_step, uninstall_skill, unlock_computer_control, - update_memory_candidate_conflict, update_memory_record, verify_skill_source, - write_drive_boundary, write_file_boundary, AppState, + update_memory_candidate_conflict, update_memory_record, verify_deepseek_api_key, + verify_skill_source, write_drive_boundary, write_file_boundary, AppState, }; use connected_work_commands::{ approve_and_run_connected_work_review, list_connected_work_reviews, @@ -313,7 +314,10 @@ fn main() { list_agent_tool_contracts, list_agent_tool_invocations, execute_agent_tool, - get_deepseek_credential_status, + get_onboarding_readiness, + save_deepseek_api_key, + verify_deepseek_api_key, + remove_deepseek_api_key, get_network_search_route_status, get_computer_use_backend_status, get_network_search_route_status_for_model, @@ -373,7 +377,6 @@ fn main() { record_agent_run_step_record, record_agent_run_artifact_record, finish_agent_run_record, - get_deepseek_user_balance, clear_deepseek_chat_cache, list_deepseek_chat_telemetry, list_agent_context_receipts, diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index b45cf27..fd1c5fc 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -74,7 +74,6 @@ import { } from "./agentAttachments"; import type { AgentAttachment } from "./agentAttachments"; import { - deepSeekApiKeyCandidates, settingsPanelItems, shouldExposePluginsSidebarEntry, } from "./settingsPanel"; @@ -112,13 +111,11 @@ import type { DeepSeekChatCacheState, DeepSeekChatTelemetry, DeepSeekPricingState, - DeepSeekUserBalanceResponse, ComputerUseBackendStatus, - DeepSeekCredentialStatus, FoundationState, LargeModelProvider, Language, - LocalDirectoryState, + OnboardingReadinessProjection, MemoryBackgroundMaintenanceSummary, MemoryCandidate, MemoryCandidateRecord, @@ -192,16 +189,43 @@ const fallbackState: FoundationState = { }, }; -const fallbackDeepSeekCredentialStatus: DeepSeekCredentialStatus = { - base_url: "https://api.deepseek.com", - chat_completions_url: "https://api.deepseek.com/chat/completions", - api_key_env_var: "DEEPSEEK_API_KEY", - api_key_configured: false, - chat_completion_ready: false, - flash_model: "deepseek-v4-flash", - pro_model: "deepseek-v4-pro", - readiness_note: - "set DEEPSEEK_API_KEY in the local process environment to enable Chat Completions requests", +const fallbackOnboardingReadiness: OnboardingReadinessProjection = { + schema_version: 1, + overall: "setup_required", + next_step: "deepseek_key", + deepseek: { + source: "missing", + configured: false, + verification: "not_checked", + code: "key_missing", + chat_completion_ready: false, + balance_available: null, + flash_model: "deepseek-v4-flash", + pro_model: "deepseek-v4-pro", + flash_available: null, + pro_available: null, + retryable: false, + last_verified_at: null, + message_key: "onboarding.deepseek.key_missing", + }, + workspace: { + configured: false, + workspace_name: null, + workspace_root_display: null, + root_exists: false, + managed_directories_ready: false, + writable: null, + code: "workspace_missing", + retryable: true, + message_key: "onboarding.workspace.workspace_missing", + }, + version: { + current_version: "1.0.2", + status: "current", + blocking: false, + message_key: "onboarding.version.current", + }, + checked_at: null, }; const fallbackDeepSeekChatCacheState: DeepSeekChatCacheState = { @@ -299,13 +323,6 @@ const fallbackModelDrivenToolStrategy: ModelDrivenToolStrategy = { "Selected model route needs a separate source-linked web-search option before search can run.", }; -const fallbackLocalDirectoryState: LocalDirectoryState = { - app_data_dir: "", - settings_file: "", - settings: null, - needs_setup: true, -}; - const fallbackAgentSoulProfileState: AgentSoulProfileState = { exists: false, content: "", @@ -324,8 +341,6 @@ const fallbackAppUpdateStatus: AppUpdateStatus = { }; const fallbackDeepSeekPricingState: DeepSeekPricingState = { - app_data_dir: "", - settings_file: "", pricing_configured: false, note: "DeepSeek cost estimates are disabled until a local pricing table is configured", settings: { @@ -417,7 +432,7 @@ type MemoryEditDraft = { type WorkflowStepState = "done" | "current" | "waiting" | "needs_action" | "blocked"; type WorkflowStatusTone = "ready" | "running" | "needs_action" | "done" | "blocked"; -type AgentChatSetupPrompt = "deepseek_key" | "workspace" | "network_search"; +type AgentChatSetupPrompt = "deepseek_key" | "workspace" | "doctor" | "network_search"; type QueuedAgentPrompt = { prompt: string; displayPrompt: string; @@ -861,8 +876,11 @@ function capabilityFamilyIcon(family: CapabilityFamily) { export function App() { const [state, setState] = useState(fallbackState); - const [deepSeekCredentialStatus, setDeepSeekCredentialStatus] = - useState(fallbackDeepSeekCredentialStatus); + const [onboardingReadiness, setOnboardingReadiness] = + useState(fallbackOnboardingReadiness); + const deepSeekCredentialStatus = onboardingReadiness.deepseek; + const workspaceReadiness = onboardingReadiness.workspace; + const localDirectoryNeedsSetup = workspaceReadiness.code !== "ready"; const [deepSeekChatCacheState, setDeepSeekChatCacheState] = useState(fallbackDeepSeekChatCacheState); const [deepSeekTelemetry, setDeepSeekTelemetry] = useState([]); @@ -876,8 +894,6 @@ export function App() { const [computerUseSteps, setComputerUseSteps] = useState([]); const [modelToolStrategy, setModelToolStrategy] = useState(fallbackModelDrivenToolStrategy); - const [localDirectoryState, setLocalDirectoryState] = - useState(fallbackLocalDirectoryState); const [soulProfileState, setSoulProfileState] = useState(fallbackAgentSoulProfileState); const [appUpdateStatus, setAppUpdateStatus] = @@ -1014,10 +1030,7 @@ export function App() { const [agentChatNotice, setAgentChatNotice] = useState(""); const [agentSetupPrompt, setAgentSetupPrompt] = useState(null); const [pendingAgentPrompt, setPendingAgentPrompt] = useState(""); - const [sessionDeepSeekApiKey, setSessionDeepSeekApiKey] = useState(""); - const [fallbackDeepSeekApiKey, setFallbackDeepSeekApiKey] = useState(""); const [deepSeekApiKeyDraft, setDeepSeekApiKeyDraft] = useState(""); - const [deepSeekBalance, setDeepSeekBalance] = useState(null); const [exportedPackageJson, setExportedPackageJson] = useState(""); const [importPackageJson, setImportPackageJson] = useState(""); const [importPreview, setImportPreview] = useState(null); @@ -1081,7 +1094,8 @@ export function App() { const [deepSeekCacheError, setDeepSeekCacheError] = useState(""); const [deepSeekPricingNotice, setDeepSeekPricingNotice] = useState(""); const [deepSeekPricingError, setDeepSeekPricingError] = useState(""); - const [deepSeekBalanceError, setDeepSeekBalanceError] = useState(""); + const [deepSeekReadinessNotice, setDeepSeekReadinessNotice] = useState(""); + const [deepSeekReadinessError, setDeepSeekReadinessError] = useState(""); const [packagePending, setPackagePending] = useState(false); const [memoryPending, setMemoryPending] = useState(false); const [memoryCandidatePending, setMemoryCandidatePending] = useState(false); @@ -1112,7 +1126,7 @@ export function App() { const [appUpdateInstallPending, setAppUpdateInstallPending] = useState(false); const [deepSeekCachePending, setDeepSeekCachePending] = useState(false); const [deepSeekPricingPending, setDeepSeekPricingPending] = useState(false); - const [deepSeekBalancePending, setDeepSeekBalancePending] = useState(false); + const [deepSeekReadinessPending, setDeepSeekReadinessPending] = useState(false); const [capabilityPending, setCapabilityPending] = useState(null); const [resolutionPending, setResolutionPending] = useState(null); const agentMessagesRef = useRef(agentMessages); @@ -1389,42 +1403,29 @@ export function App() { unlockHint: "The local unlock above is also required before execution.", screenshotHint: "Capture a fresh screenshot in Computer Use tools before re-observing.", }; - const deepSeekBalanceStatus = deepSeekBalance - ? deepSeekBalance.is_available - ? copy.settingsPanel.balanceAvailable - : copy.settingsPanel.balanceUnavailable - : copy.settingsPanel.balanceNotQueried; - const deepSeekBalanceDetails = deepSeekBalance - ? deepSeekBalance.balance_infos.length > 0 - ? deepSeekBalance.balance_infos - .map( - (info) => - `${info.currency} ${info.total_balance} (${info.topped_up_balance} + ${info.granted_balance})`, - ) - .join(" / ") - : copy.settingsPanel.balanceEmpty - : ""; - const primaryDeepSeekApiKeyPlaceholder = sessionDeepSeekApiKey - ? copy.settingsPanel.apiKeyPlaceholder - : deepSeekCredentialStatus.api_key_configured + const deepSeekReadinessMessage = + copy.onboarding.deepseekMessages[deepSeekCredentialStatus.code]; + const workspaceReadinessMessage = + copy.onboarding.workspaceMessages[workspaceReadiness.code]; + const primaryDeepSeekApiKeyPlaceholder = deepSeekCredentialStatus.configured ? copy.settingsPanel.apiKeyConfiguredPlaceholder : copy.settingsPanel.apiKeyPlaceholder; const primaryDeepSeekApiKeyReady = deepSeekCredentialStatus.chat_completion_ready; - const fallbackDeepSeekApiKeyReady = false; - - const hydrateLocalDirectoryInputs = (directoryState: LocalDirectoryState) => { - if (!directoryState.settings) { - return; + const canRetryDeepSeekReadiness = + deepSeekCredentialStatus.retryable || + deepSeekCredentialStatus.code === "not_checked" || + deepSeekCredentialStatus.code === "ready"; + const modelAvailabilityMessage = (available: boolean | null) => + available === null + ? copy.onboarding.notChecked + : available + ? copy.onboarding.available + : copy.onboarding.unavailable; + + const hydrateWorkspaceInputs = (readiness: OnboardingReadinessProjection) => { + if (readiness.workspace.workspace_name) { + setSetupWorkspaceName(readiness.workspace.workspace_name); } - - const { workspace_dir, workspace_name, evidence_dir, export_dir } = - directoryState.settings; - setSetupWorkspaceName(workspace_name); - setSetupWorkspaceDir(workspace_dir); - setBriefingFolderPath((current) => current || evidence_dir); - setFolderPath((current) => current || evidence_dir); - setDriveLocation((current) => current || workspace_dir); - setDriveWriteLocation((current) => current || export_dir); }; const hydrateDeepSeekPricingInputs = (pricingState: DeepSeekPricingState) => { @@ -1479,11 +1480,10 @@ export function App() { useEffect(() => { if (!hasDesktopRuntime()) { setState(fallbackState); - setDeepSeekCredentialStatus(fallbackDeepSeekCredentialStatus); + setOnboardingReadiness(fallbackOnboardingReadiness); setDeepSeekChatCacheState(fallbackDeepSeekChatCacheState); setDeepSeekTelemetry([]); setComputerControlUnlockStatus(fallbackComputerControlUnlockStatus); - setLocalDirectoryState(fallbackLocalDirectoryState); applySoulProfileState(fallbackAgentSoulProfileState); setAppUpdateStatus(fallbackAppUpdateStatus); setDeepSeekPricingState(fallbackDeepSeekPricingState); @@ -1500,9 +1500,18 @@ export function App() { } }) .catch(() => setState(fallbackState)); - void invoke("get_deepseek_credential_status") - .then(setDeepSeekCredentialStatus) - .catch(() => setDeepSeekCredentialStatus(fallbackDeepSeekCredentialStatus)); + void invoke("get_onboarding_readiness") + .then((readiness) => { + setOnboardingReadiness(readiness); + hydrateWorkspaceInputs(readiness); + if (readiness.overall !== "ready") { + setAgentSetupPrompt(readiness.next_step === "ready" ? "doctor" : readiness.next_step); + } + }) + .catch(() => { + setOnboardingReadiness(fallbackOnboardingReadiness); + setDeepSeekReadinessError(copy.onboarding.loadFailed); + }); void invoke("get_deepseek_chat_cache_state") .then(setDeepSeekChatCacheState) .catch(() => setDeepSeekChatCacheState(fallbackDeepSeekChatCacheState)); @@ -1516,15 +1525,6 @@ export function App() { setComputerUseSessions([]); setComputerUseSteps([]); }); - void invoke("get_local_directory_state") - .then((directoryState) => { - setLocalDirectoryState(directoryState); - hydrateLocalDirectoryInputs(directoryState); - }) - .catch(() => { - setLocalDirectoryState(fallbackLocalDirectoryState); - setSetupError(copy.localSetup.loadFailed); - }); void invoke("get_agent_soul_profile") .then(applySoulProfileState) .catch(() => { @@ -2304,7 +2304,7 @@ export function App() { const persistLocalDirectorySetup = async ( options: { workspaceDir?: string; workspaceName?: string } = {}, - ): Promise => { + ): Promise => { const workspaceDir = options.workspaceDir ?? setupWorkspaceDir; const workspaceName = options.workspaceName ?? setupWorkspaceName; setSetupPending(true); @@ -2312,20 +2312,22 @@ export function App() { setSetupNotice(""); try { - const directoryState = await invoke( + const readiness = await invoke( "save_local_directory_settings", { workspaceDir, workspaceName, }, ); - setLocalDirectoryState(directoryState); - hydrateLocalDirectoryInputs(directoryState); - setSetupNotice(copy.localSetup.saved); - return true; - } catch (error) { - setSetupError(String(error) || copy.localSetup.failed); - return false; + setOnboardingReadiness(readiness); + hydrateWorkspaceInputs(readiness); + if (readiness.workspace.code === "ready") { + setSetupNotice(copy.localSetup.saved); + } + return readiness; + } catch { + setSetupError(copy.localSetup.failed); + return null; } finally { setSetupPending(false); } @@ -2385,32 +2387,67 @@ export function App() { } }; - const queryDeepSeekBalance = async () => { - setDeepSeekBalancePending(true); - setDeepSeekBalanceError(""); + const saveDeepSeekKey = async (apiKey: string): Promise => { + setDeepSeekReadinessPending(true); + setDeepSeekReadinessError(""); + setDeepSeekReadinessNotice(""); + try { + const readiness = await invoke("save_deepseek_api_key", { + apiKey, + }); + setOnboardingReadiness(readiness); + if ( + readiness.deepseek.source === "stored" && + readiness.deepseek.code !== "key_format_invalid" && + readiness.deepseek.code !== "credential_store_unavailable" + ) { + setDeepSeekReadinessNotice(copy.onboarding.keySaved); + } + return readiness; + } catch { + setDeepSeekReadinessError(copy.onboarding.commandFailed); + return null; + } finally { + setDeepSeekApiKeyDraft(""); + setDeepSeekReadinessPending(false); + } + }; + const retryDeepSeekReadiness = async (): Promise => { + setDeepSeekReadinessPending(true); + setDeepSeekReadinessError(""); + setDeepSeekReadinessNotice(""); try { - const apiKeyCandidates = deepSeekApiKeyCandidates( - sessionDeepSeekApiKey, - fallbackDeepSeekApiKey, + const readiness = await invoke( + "verify_deepseek_api_key", ); - if (!deepSeekCredentialStatus.chat_completion_ready && apiKeyCandidates.length === 0) { - setDeepSeekBalanceError(copy.chatWorkbench.deepSeekKeyRequired); - return; - } + setOnboardingReadiness(readiness); + setDeepSeekReadinessNotice(copy.onboarding.checkFinished); + return readiness; + } catch { + setDeepSeekReadinessError(copy.onboarding.commandFailed); + return null; + } finally { + setDeepSeekApiKeyDraft(""); + setDeepSeekReadinessPending(false); + } + }; - const balance = await invoke( - "get_deepseek_user_balance", - { - apiKeyOverride: apiKeyCandidates[0] ?? null, - fallbackApiKeyOverride: apiKeyCandidates[1] ?? null, - }, + const removeDeepSeekKey = async () => { + setDeepSeekReadinessPending(true); + setDeepSeekReadinessError(""); + setDeepSeekReadinessNotice(""); + try { + const readiness = await invoke( + "remove_deepseek_api_key", ); - setDeepSeekBalance(balance); - } catch (error) { - setDeepSeekBalanceError(String(error) || copy.settingsPanel.balanceFailed); + setOnboardingReadiness(readiness); + setDeepSeekReadinessNotice(copy.onboarding.keyRemoved); + } catch { + setDeepSeekReadinessError(copy.onboarding.commandFailed); } finally { - setDeepSeekBalancePending(false); + setDeepSeekApiKeyDraft(""); + setDeepSeekReadinessPending(false); } }; @@ -2438,8 +2475,8 @@ export function App() { setSetupWorkspaceDir(previousWorkspaceDir); } } - } catch (error) { - setSetupError(String(error) || copy.localSetup.chooseFailed); + } catch { + setSetupError(copy.localSetup.chooseFailed); } }; @@ -2570,7 +2607,7 @@ export function App() { }; useEffect(() => { - if (!hasDesktopRuntime() || localDirectoryState.needs_setup) { + if (!hasDesktopRuntime() || localDirectoryNeedsSetup) { return; } let cancelled = false; @@ -2589,7 +2626,7 @@ export function App() { cancelled = true; window.clearInterval(timer); }; - }, [localDirectoryState.needs_setup]); + }, [localDirectoryNeedsSetup]); useEffect(() => { if (!hasDesktopRuntime() || (!agentChatPending && !hasOpenAgentRuns)) { @@ -2607,24 +2644,19 @@ export function App() { }, [agentChatPending, hasOpenAgentRuns]); useEffect(() => { - if (!hasDesktopRuntime() || localDirectoryState.needs_setup) { + if (!hasDesktopRuntime() || localDirectoryNeedsSetup) { return; } let cancelled = false; const runNextDurableAgentTask = async () => { - const apiKeyCandidates = deepSeekApiKeyCandidates( - sessionDeepSeekApiKey, - fallbackDeepSeekApiKey, - ); if (cancelled || !shouldRunDurableAgentWorker({ desktopRuntime: hasDesktopRuntime(), - setupNeeded: localDirectoryState.needs_setup, + setupNeeded: localDirectoryNeedsSetup, workerBusy: backgroundAgentWorkerBusyRef.current, chatPending: agentChatPendingRef.current, queuedLocalCount: queuedAgentPromptRef.current.length, - credentialReady: - deepSeekCredentialStatus.chat_completion_ready || apiKeyCandidates.length > 0, + credentialReady: deepSeekCredentialStatus.chat_completion_ready, })) { return; } @@ -2676,8 +2708,6 @@ export function App() { thinkingLevel: state.thinking_level, accessMode: state.access_mode, networkSearchSourceModel: state.network_search_source_model || null, - apiKeyOverride: apiKeyCandidates[0] ?? null, - fallbackApiKeyOverride: apiKeyCandidates[1] ?? null, }), ), ); @@ -2733,8 +2763,6 @@ export function App() { thinkingLevel: state.thinking_level, accessMode: state.access_mode, networkSearchSourceModel: state.network_search_source_model || null, - apiKeyOverride: apiKeyCandidates[0] ?? null, - fallbackApiKeyOverride: apiKeyCandidates[1] ?? null, }, ); if (!workerResult || cancelled) { @@ -2862,9 +2890,7 @@ export function App() { }; }, [ deepSeekCredentialStatus.chat_completion_ready, - fallbackDeepSeekApiKey, - localDirectoryState.needs_setup, - sessionDeepSeekApiKey, + localDirectoryNeedsSetup, state.access_mode, state.large_model_provider, state.model_route, @@ -2885,16 +2911,8 @@ export function App() { }; const runMemoryBackgroundMaintenance = async () => { - const apiKeyCandidates = deepSeekApiKeyCandidates( - sessionDeepSeekApiKey, - fallbackDeepSeekApiKey, - ); const summary = await invoke( "run_memory_background_maintenance", - { - apiKeyOverride: apiKeyCandidates[0] ?? null, - fallbackApiKeyOverride: apiKeyCandidates[1] ?? null, - }, ); await Promise.all([refreshMemoryCandidateRecords(), refreshMemoryMaintenanceReviews()]); return summary; @@ -3812,8 +3830,7 @@ export function App() { const runOperationsBriefingWorkflow = async (event: FormEvent) => { event.preventDefault(); - const trimmedPath = - briefingFolderPath.trim() || localDirectoryState.settings?.evidence_dir.trim() || ""; + const trimmedPath = briefingFolderPath.trim(); if (!trimmedPath) { setBriefingError(copy.operationsBriefing.failed); return; @@ -4047,7 +4064,6 @@ export function App() { const sendAgentPrompt = async ( promptValue: string, options: { - apiKeyOverride?: string; skipWorkspaceSetup?: boolean; skipNetworkSearchSetup?: boolean; isGuidanceContinuation?: boolean; @@ -4100,20 +4116,14 @@ export function App() { } const runToken = (agentChatRunTokenRef.current += 1); - const apiKeyCandidates = deepSeekApiKeyCandidates( - options.apiKeyOverride ?? sessionDeepSeekApiKey, - fallbackDeepSeekApiKey, - ); - if (!deepSeekCredentialStatus.chat_completion_ready && apiKeyCandidates.length === 0) { + if (onboardingReadiness.overall !== "ready") { setPendingAgentPrompt(prompt); setDeepSeekApiKeyDraft(""); - setAgentSetupPrompt("deepseek_key"); - return; - } - - if (localDirectoryState.needs_setup && !options.skipWorkspaceSetup) { - setPendingAgentPrompt(prompt); - setAgentSetupPrompt("workspace"); + setAgentSetupPrompt( + onboardingReadiness.next_step === "ready" + ? "doctor" + : onboardingReadiness.next_step, + ); return; } @@ -4253,8 +4263,6 @@ export function App() { thinkingLevel: state.thinking_level, accessMode: state.access_mode, networkSearchSourceModel: state.network_search_source_model || null, - apiKeyOverride: apiKeyCandidates[0] ?? null, - fallbackApiKeyOverride: apiKeyCandidates[1] ?? null, }); void invoke("record_agent_run_step_record", { runId: agentRunId, @@ -4277,8 +4285,6 @@ export function App() { thinkingLevel: state.thinking_level, accessMode: state.access_mode, networkSearchSourceModel: state.network_search_source_model || null, - apiKeyOverride: apiKeyCandidates[0] ?? null, - fallbackApiKeyOverride: apiKeyCandidates[1] ?? null, }, ); if (!workerResult) { @@ -4547,25 +4553,34 @@ export function App() { const continueAgentAfterDeepSeekKey = async (event: FormEvent) => { event.preventDefault(); - const trimmedKey = deepSeekApiKeyDraft.trim(); - if (!trimmedKey) { + let submittedKey = deepSeekApiKeyDraft.trim(); + if (!submittedKey) { setAgentChatError(copy.chatWorkbench.deepSeekKeyRequired); return; } - setSessionDeepSeekApiKey(trimmedKey); + const readiness = await saveDeepSeekKey(submittedKey); + submittedKey = ""; + if (!readiness) { + return; + } + if (readiness.overall !== "ready") { + setAgentSetupPrompt(readiness.next_step === "ready" ? "doctor" : readiness.next_step); + return; + } + const prompt = pendingAgentPrompt; setAgentSetupPrompt(null); - await sendAgentPrompt(pendingAgentPrompt, { - apiKeyOverride: trimmedKey, - attachments: agentAttachments, - }); setPendingAgentPrompt(""); - setDeepSeekApiKeyDraft(""); + await sendAgentPrompt(prompt, { attachments: agentAttachments }); }; const continueAgentAfterWorkspaceSetup = async (event: FormEvent) => { event.preventDefault(); - const saved = await persistLocalDirectorySetup(); - if (!saved) { + const readiness = await persistLocalDirectorySetup(); + if (!readiness) { + return; + } + if (readiness.overall !== "ready") { + setAgentSetupPrompt(readiness.next_step === "ready" ? "doctor" : readiness.next_step); return; } const prompt = pendingAgentPrompt; @@ -4577,6 +4592,38 @@ export function App() { }); }; + const continueAgentAfterDoctor = async (event: FormEvent) => { + event.preventDefault(); + let readiness: OnboardingReadinessProjection | null = null; + if (deepSeekCredentialStatus.chat_completion_ready) { + setDeepSeekReadinessPending(true); + setDeepSeekReadinessError(""); + try { + readiness = await invoke( + "get_onboarding_readiness", + ); + setOnboardingReadiness(readiness); + } catch { + setDeepSeekReadinessError(copy.onboarding.commandFailed); + } finally { + setDeepSeekReadinessPending(false); + } + } else { + readiness = await retryDeepSeekReadiness(); + } + if (!readiness) { + return; + } + if (readiness.overall !== "ready") { + setAgentSetupPrompt(readiness.next_step === "ready" ? "doctor" : readiness.next_step); + return; + } + const prompt = pendingAgentPrompt; + setAgentSetupPrompt(null); + setPendingAgentPrompt(""); + await sendAgentPrompt(prompt, { attachments: agentAttachments }); + }; + const continueAgentAfterNetworkSearchSetup = async (event: FormEvent) => { event.preventDefault(); if (modelToolStrategy.network_search_source_model_required && !state.network_search_source_model) { @@ -4919,7 +4966,7 @@ export function App() { ? "done" : briefingPending ? "current" - : localDirectoryState.needs_setup + : localDirectoryNeedsSetup ? "blocked" : "waiting", }, @@ -5181,15 +5228,25 @@ export function App() { aria-label={copy.settingsPanel.title} data-settings-count={settingsPanelItemCount} > -