diff --git a/crates/ironclaw_gateway/static/js/core/history.js b/crates/ironclaw_gateway/static/js/core/history.js index db762152601..1b578ffd653 100644 --- a/crates/ironclaw_gateway/static/js/core/history.js +++ b/crates/ironclaw_gateway/static/js/core/history.js @@ -39,6 +39,22 @@ function loadHistory(before) { apiFetch(historyUrl).then((data) => { const container = document.getElementById('chat-messages'); + const pending = !isPaginating ? _pendingUserMessages.get(currentThreadId) : null; + let freshPending = []; + let pendingByContent = null; + + if (!isPaginating && pending && pending.length > 0) { + const now = Date.now(); + freshPending = pending.filter(p => now - p.timestamp < PENDING_MSG_TTL_MS); + if (freshPending.length > 0) { + pendingByContent = new Map(); + freshPending.forEach((p) => { + const key = p.content; + if (!pendingByContent.has(key)) pendingByContent.set(key, []); + pendingByContent.get(key).push(p); + }); + } + } if (!isPaginating && currentThreadId && data.channel) { threadChannelHints.set(currentThreadId, data.channel); @@ -49,7 +65,33 @@ function loadHistory(before) { container.innerHTML = ''; for (const turn of data.turns) { if (turn.user_input) { - addMessage('user', turn.user_input); + let renderedPending = false; + const pendingQueue = pendingByContent && pendingByContent.get(turn.user_input); + const nextPending = pendingQueue && pendingQueue.length > 0 ? pendingQueue[0] : null; + if (nextPending) { + let persistedAttachments = []; + if (typeof parseUserMessageContent === 'function') { + persistedAttachments = parseUserMessageContent(turn.user_input).attachments; + } + const hasPendingVisuals = ( + (Array.isArray(nextPending.attachments) && nextPending.attachments.length > 0) + || (Array.isArray(nextPending.images) && nextPending.images.length > 0) + ); + if (hasPendingVisuals && persistedAttachments.length === 0) { + const div = addMessage('user', nextPending.content, { + attachments: Array.isArray(nextPending.attachments) ? nextPending.attachments : [], + copyText: nextPending.copyText || nextPending.content, + }); + if (nextPending.images && nextPending.images.length > 0) { + appendImagesToMessage(div, nextPending.images); + } + renderedPending = true; + } + pendingQueue.shift(); + } + if (!renderedPending) { + addMessage('user', turn.user_input); + } } if (turn.tool_calls && turn.tool_calls.length > 0) { addToolCallsSummary(turn.tool_calls); @@ -75,52 +117,57 @@ function loadHistory(before) { addMessage('assistant', turn.response); } } - // Re-inject pending user messages not yet in DB (#2409) - const pending = _pendingUserMessages.get(currentThreadId); - let freshPending = []; - if (pending && pending.length > 0) { - const now = Date.now(); - freshPending = pending.filter(p => now - p.timestamp < PENDING_MSG_TTL_MS); - if (freshPending.length > 0) { - const dbContentsCounts = new Map(); - data.turns - .map(t => t.user_input) - .filter(Boolean) - .forEach(content => { - dbContentsCounts.set(content, (dbContentsCounts.get(content) || 0) + 1); - }); - for (const p of freshPending) { - const count = dbContentsCounts.get(p.content) || 0; - if (count > 0) { - dbContentsCounts.set(p.content, count - 1); - } else { - const div = addMessage('user', p.content); - if (p.images && p.images.length > 0) { - appendImagesToMessage(div, p.images); - } - } - } - _pendingUserMessages.set(currentThreadId, freshPending); - } else { - _pendingUserMessages.delete(currentThreadId); - } - } - container.scrollTop = container.scrollHeight; - // Show welcome card when history is empty - if (data.turns.length === 0 && !data.in_progress && freshPending.length === 0) { - showWelcomeCard(); - } // Show processing indicator if the last turn is still in-progress var lastTurn = data.turns.length > 0 ? data.turns[data.turns.length - 1] : null; if (data.in_progress) { const sameLastTurn = isSameInProgressTurn(lastTurn, data.in_progress); if (!sameLastTurn && data.in_progress.user_input) { - addMessage('user', data.in_progress.user_input); + const pendingQueue = pendingByContent && pendingByContent.get(data.in_progress.user_input); + const nextPending = pendingQueue && pendingQueue.length > 0 ? pendingQueue[0] : null; + const hasPendingVisuals = nextPending && ( + (Array.isArray(nextPending.attachments) && nextPending.attachments.length > 0) + || (Array.isArray(nextPending.images) && nextPending.images.length > 0) + ); + if (hasPendingVisuals) { + const div = addMessage('user', nextPending.content, { + attachments: Array.isArray(nextPending.attachments) ? nextPending.attachments : [], + copyText: nextPending.copyText || nextPending.content, + }); + if (nextPending.images && nextPending.images.length > 0) { + appendImagesToMessage(div, nextPending.images); + } + pendingQueue.shift(); + } else { + addMessage('user', data.in_progress.user_input); + } } showActivityThinking(ActivityEntry.t('activity.processing', 'Processing...')); } else if (lastTurn && !lastTurn.response && lastTurn.state === 'Processing') { showActivityThinking(ActivityEntry.t('activity.processing', 'Processing...')); } + // Re-inject pending user messages not yet in DB (#2409) + const remainingPending = freshPending.length > 0 && pendingByContent + ? Array.from(pendingByContent.values()).flat() + : freshPending; + if (remainingPending.length > 0) { + for (const p of remainingPending) { + const div = addMessage('user', p.content, { + attachments: Array.isArray(p.attachments) ? p.attachments : [], + copyText: p.copyText || p.content, + }); + if (p.images && p.images.length > 0) { + appendImagesToMessage(div, p.images); + } + } + _pendingUserMessages.set(currentThreadId, freshPending); + } else { + _pendingUserMessages.delete(currentThreadId); + } + container.scrollTop = container.scrollHeight; + // Show welcome card when history is empty + if (data.turns.length === 0 && !data.in_progress && freshPending.length === 0) { + showWelcomeCard(); + } const hintedChannel = currentThreadId ? (data.channel || threadChannelHints.get(currentThreadId) || 'gateway') : 'gateway'; @@ -780,4 +827,3 @@ function updateTabIndicator() { window.addEventListener('resize', updateTabIndicator); // --- Memory (filesystem tree) --- - diff --git a/crates/ironclaw_gateway/static/js/core/init-auth.js b/crates/ironclaw_gateway/static/js/core/init-auth.js index b8146549070..45085efc2df 100644 --- a/crates/ironclaw_gateway/static/js/core/init-auth.js +++ b/crates/ironclaw_gateway/static/js/core/init-auth.js @@ -60,6 +60,7 @@ function initApp() { if (roleEl) roleEl.textContent = profile.role; }).catch(function() {}); checkTeeStatus(); + refreshSlashSkillEntries(); loadThreads(); loadMemoryTree(); loadJobs(); @@ -412,4 +413,3 @@ function updateRestartButtonVisibility() { } // --- SSE --- - diff --git a/crates/ironclaw_gateway/static/js/surfaces/chat.js b/crates/ironclaw_gateway/static/js/surfaces/chat.js index a65f80c7c6b..197d85333f0 100644 --- a/crates/ironclaw_gateway/static/js/surfaces/chat.js +++ b/crates/ironclaw_gateway/static/js/surfaces/chat.js @@ -129,8 +129,20 @@ async function sendMessage() { })); const displayContent = content || (pendingAttachmentsForDisplay.length > 0 ? '(files attached)' : '(images attached)'); + const pendingCopyTextParts = []; + if (displayContent) pendingCopyTextParts.push(displayContent); + pendingAttachmentsForDisplay.forEach((att) => { + const suffix = [att.mime_type, att.size_label].filter(Boolean).join(' • '); + pendingCopyTextParts.push( + suffix + ? `[Attachment] ${att.filename || 'attachment'} (${suffix})` + : `[Attachment] ${att.filename || 'attachment'}` + ); + }); + const pendingCopyText = pendingCopyTextParts.join('\n'); const userMsg = addMessage('user', displayContent, { attachments: pendingAttachmentsForDisplay, + copyText: pendingCopyText, }); if (attachedImageDataUrls.length > 0) { appendImagesToMessage(userMsg, attachedImageDataUrls); @@ -149,7 +161,6 @@ async function sendMessage() { let pendingId = null; const pendingThreadId = currentThreadId; if (currentThreadId) { - const displayContent = content || '(images attached)'; if (!_pendingUserMessages.has(currentThreadId)) { _pendingUserMessages.set(currentThreadId, []); } @@ -157,6 +168,8 @@ async function sendMessage() { _pendingUserMessages.get(currentThreadId).push({ id: pendingId, content: displayContent, + copyText: pendingCopyText, + attachments: pendingAttachmentsForDisplay.map((att) => ({ ...att })), images: attachedImageDataUrls, timestamp: Date.now(), }); @@ -310,8 +323,8 @@ document.getElementById('attach-btn').addEventListener('click', () => { }); document.getElementById('image-file-input').addEventListener('change', (e) => { - handleImageFiles(e.target.files); - e.target.value = ''; + const files = Array.from(e.target.files || []); + handleImageFiles(files); }); document.getElementById('chat-input').addEventListener('paste', (e) => { @@ -441,6 +454,8 @@ function resolveGeneratedImageForRender(threadId, image) { // --- Slash Autocomplete --- +let _slashSkillEntries = []; + function showSlashAutocomplete(matches) { const el = document.getElementById('slash-autocomplete'); if (!el || matches.length === 0) { hideSlashAutocomplete(); return; } @@ -468,6 +483,51 @@ function showSlashAutocomplete(matches) { el.style.display = 'block'; } +function setSlashSkillEntries(skills) { + if (!Array.isArray(skills)) { + _slashSkillEntries = []; + const input = document.getElementById('chat-input'); + if (input && input.value.startsWith('/')) filterSlashCommands(input.value); + return; + } + _slashSkillEntries = skills + .filter((skill) => skill && typeof skill.name === 'string' && skill.name.trim() !== '') + .map((skill) => ({ + cmd: '/' + skill.name.trim(), + desc: (skill.description || '').trim() || 'Skill', + kind: 'skill', + })) + .sort((a, b) => a.cmd.localeCompare(b.cmd)); + const input = document.getElementById('chat-input'); + if (input && input.value.startsWith('/')) filterSlashCommands(input.value); +} + +function getSlashAutocompleteItems() { + const items = SLASH_COMMANDS.map((cmd) => ({ + cmd: cmd.cmd, + desc: cmd.desc, + kind: 'command', + })); + const seen = new Set(items.map((item) => item.cmd.toLowerCase())); + _slashSkillEntries.forEach((item) => { + const key = item.cmd.toLowerCase(); + if (seen.has(key)) return; + seen.add(key); + items.push(item); + }); + return items; +} + +function refreshSlashSkillEntries() { + return apiFetch('/api/skills') + .then(function(data) { + setSlashSkillEntries((data && data.skills) || []); + }) + .catch(function() { + // Preserve the last known skill list on transient fetch failures. + }); +} + function hideSlashAutocomplete() { const el = document.getElementById('slash-autocomplete'); if (el) el.style.display = 'none'; @@ -495,8 +555,9 @@ function filterSlashCommands(value) { if (!value.startsWith('/')) { hideSlashAutocomplete(); return; } // Only show autocomplete when the input is just a slash command prefix (no spaces except /thread new) const lower = value.toLowerCase(); - const matches = SLASH_COMMANDS.filter((c) => c.cmd.startsWith(lower)); - if (matches.length === 0 || (matches.length === 1 && matches[0].cmd === lower.trimEnd())) { + const exactLower = lower.trimEnd(); + const matches = getSlashAutocompleteItems().filter((c) => c.cmd.toLowerCase().startsWith(lower)); + if (matches.length === 0 || (matches.length === 1 && matches[0].cmd.toLowerCase() === exactLower)) { hideSlashAutocomplete(); } else { showSlashAutocomplete(matches); diff --git a/crates/ironclaw_gateway/static/js/surfaces/skills.js b/crates/ironclaw_gateway/static/js/surfaces/skills.js index d1a2635bf91..95d668bf851 100644 --- a/crates/ironclaw_gateway/static/js/surfaces/skills.js +++ b/crates/ironclaw_gateway/static/js/surfaces/skills.js @@ -62,6 +62,7 @@ function loadSkills() { var skillsList = document.getElementById('skills-list'); skillsList.innerHTML = renderCardsSkeleton(3); apiFetch('/api/skills').then(function(data) { + setSlashSkillEntries((data && data.skills) || []); if (!data.skills || data.skills.length === 0) { skillsList.innerHTML = '
' + I18n.t('skills.noInstalled') + '
'; return; @@ -390,4 +391,3 @@ document.getElementById('skill-search-input').addEventListener('keydown', functi }); // --- Tool Permissions --- - diff --git a/src/app.rs b/src/app.rs index f4ae34c7f39..79903957ee5 100644 --- a/src/app.rs +++ b/src/app.rs @@ -474,18 +474,14 @@ impl AppBuilder { // workspace. Even outside authenticated multi-tenant mode, some // channels and test harnesses route non-owner users through // per-user tenant workspaces seeded on demand. - let is_multi_tenant = db.has_any_users().await.unwrap_or(false); + // + // Whether the deployment is multi-tenant is configuration, not a + // property we should infer from the current DB contents. An admin + // may start in multi-tenant mode before creating any tenant users. + let is_multi_tenant = self.config.is_multi_tenant_deployment(); // In multi-tenant mode, enable admin system prompt on the owner // workspace so the dispatcher reads SYSTEM.md from __admin__ scope. - // - // NOTE: `is_multi_tenant` is evaluated once at startup. If the - // server starts with no users (single-user mode) and users are - // added later, the owner workspace frozen in `Arc` will NOT have - // `admin_prompt_enabled`. A server restart is required after the - // first user is created to activate admin prompts on the owner - // workspace. Tenant workspaces created via `WorkspacePool` are - // unaffected — they always call `.with_admin_prompt()`. if is_multi_tenant { ws = ws.with_admin_prompt(); } diff --git a/src/channels/web/features/settings/mod.rs b/src/channels/web/features/settings/mod.rs index 7cd99b8f4ff..1bbb91ac4df 100644 --- a/src/channels/web/features/settings/mod.rs +++ b/src/channels/web/features/settings/mod.rs @@ -335,43 +335,27 @@ async fn reload_llm_after_settings_change(state: &GatewayState) -> ReloadOutcome return ReloadOutcome::Skipped; }; - // Use the gateway owner scope so the admin-scope merge happens the - // same way it did at startup. `re_resolve_llm_with_secrets` also - // hydrates API keys from the secrets store — without that, - // `from_db_with_toml` alone would miss `OPENAI_API_KEY` / - // `NEARAI_SESSION_TOKEN` added alongside the backend switch. - let mut config = match crate::config::Config::from_db_with_toml( - store.as_ref(), + // Re-resolve just the LLM config using the same owner/admin scope layering + // the gateway used at startup. This avoids rebuilding unrelated config + // sections while still hydrating secrets-backed API keys for the new chain. + let llm_config = match crate::config::Config::resolve_llm_with_secrets_strict( + Some(store.as_ref()), &state.owner_id, state.config_toml_path.as_deref(), + state.secrets_store.as_deref(), true, ) .await { Ok(c) => c, Err(e) => { - tracing::error!("LLM hot reload: from_db_with_toml failed: {}", e); + tracing::error!("LLM hot reload: LLM config resolve failed: {}", e); return ReloadOutcome::ConfigLoadFailed(e.to_string()); } }; - if let Some(secrets) = state.secrets_store.as_ref() - && let Err(e) = config - .re_resolve_llm_with_secrets( - Some(store.as_ref()), - &state.owner_id, - state.config_toml_path.as_deref(), - Some(secrets.as_ref()), - true, - ) - .await - { - tracing::error!("LLM hot reload: secret re-hydration failed: {}", e); - return ReloadOutcome::ConfigLoadFailed(e.to_string()); - } - if let Err(e) = reloader - .reload(&config.llm, Arc::clone(session_manager)) + .reload(&llm_config, Arc::clone(session_manager)) .await { tracing::error!("LLM hot reload: provider chain build failed: {}", e); @@ -386,15 +370,15 @@ async fn reload_llm_after_settings_change(state: &GatewayState) -> ReloadOutcome .llm_provider .as_ref() .map(|provider| provider.active_model_name()) - .unwrap_or_else(|| config.llm.active_model_name()); + .unwrap_or_else(|| llm_config.active_model_name()); { let mut active = state.active_config.write().await; - active.llm_backend = config.llm.backend.clone(); + active.llm_backend = llm_config.backend.clone(); active.llm_model = active_model; } tracing::info!( - backend = %config.llm.backend, + backend = %llm_config.backend, "LLM provider chain hot-reloaded from updated settings" ); ReloadOutcome::Swapped @@ -1202,6 +1186,7 @@ mod tests { }; use crate::channels::web::auth::UserIdentity; + use crate::config::helpers::lock_env; #[test] fn test_mask_settings_api_keys_builtin_overrides() { @@ -1279,6 +1264,7 @@ mod tests { sse: Arc::new(crate::channels::web::sse::SseManager::new()), workspace: None, workspace_pool: None, + multi_tenant_mode: false, session_manager: None, log_broadcaster: None, log_level_handle: None, @@ -1646,11 +1632,13 @@ mod tests { /// gets the wrapper missing from state, or stops threading the new model /// into `active_config`. #[tokio::test] + #[allow(clippy::await_holding_lock)] // env guard must span async hot-reload flow async fn settings_set_handler_triggers_llm_provider_hot_reload() { use crate::llm::{LlmConfig, SessionConfig, SessionManager, build_provider_chain}; + let _env_guard = lock_env(); let secrets = test_secrets_store(); - let (db, _tmp) = crate::testing::test_db().await; + let (db, tmp) = crate::testing::test_db().await; // Starting config: NEAR AI backend with "model-start". let mut initial = LlmConfig { @@ -1714,9 +1702,11 @@ mod tests { state.llm_provider = Some(Arc::clone(&primary)); state.llm_reload = Some(Arc::clone(&reload_handle)); state.llm_session_manager = Some(Arc::clone(&session)); - // Owner scope is the user_id that `Config::from_db_with_toml` is - // called with — we set it to admin scope so the settings reload - // reads the same rows we just seeded. + let toml_path = tmp.path().join("empty-config.toml"); + std::fs::write(&toml_path, "").expect("create empty toml"); + state.config_toml_path = Some(toml_path); + // Owner scope is the user_id the reload resolves from — we set it + // to admin scope so it reads the same rows we just seeded. state.owner_id = admin_scope.to_string(); let state = Arc::new(state); @@ -1858,6 +1848,9 @@ mod tests { state.llm_provider = Some(Arc::clone(&primary)); state.llm_reload = Some(Arc::clone(&reload_handle)); state.llm_session_manager = Some(Arc::clone(&session)); + let toml_path = tmp.path().join("empty-config.toml"); + std::fs::write(&toml_path, "").expect("create empty toml"); + state.config_toml_path = Some(toml_path); // Gateway owner is a distinct identity from admin scope, so tests // can tell when a reload was gated out by scope. state.owner_id = "owner".to_string(); @@ -1946,11 +1939,13 @@ mod tests { /// scope. Without this branch, the default "write to my own settings" /// UX would never trigger a reload for the owner. #[tokio::test] + #[allow(clippy::await_holding_lock)] // env guard must span async hot-reload flow async fn settings_set_handler_owner_scope_triggers_reload() { + let _env_guard = lock_env(); let (state, primary, _tmp) = hot_reload_harness().await; - // Seed the owner scope so `Config::from_db_with_toml` resolves to - // the new model when it reads it back via the `owner` user_id. + // Seed the owner scope so the reload resolves to the new model + // when it reads back via the `owner` user_id. state .store .as_ref() @@ -1992,7 +1987,9 @@ mod tests { /// see this broken sibling-config, fail resolution, and roll back the /// caller's write. #[tokio::test] + #[allow(clippy::await_holding_lock)] // env guard must span async hot-reload flow async fn settings_set_handler_rolls_back_on_reload_failure() { + let _env_guard = lock_env(); let (state, primary, _tmp) = hot_reload_harness().await; let before_model = primary.active_model_name(); let admin_scope = crate::tools::permissions::ADMIN_SETTINGS_USER_ID; @@ -2083,7 +2080,9 @@ mod tests { /// override is lost and the provider ends up reporting the admin-scope /// model instead. With the fix, the owner's model survives. #[tokio::test] + #[allow(clippy::await_holding_lock)] // env guard must span async hot-reload flow async fn reload_rebuilds_from_owner_scope_not_effective_scope() { + let _env_guard = lock_env(); let (state, primary, _tmp) = hot_reload_harness().await; let admin_scope = crate::tools::permissions::ADMIN_SETTINGS_USER_ID; let store = state.store.as_ref().expect("store"); // dispatch-exempt: test harness diff --git a/src/channels/web/handlers/memory.rs b/src/channels/web/handlers/memory.rs index 177e2a3b33d..b8cc71c3cd5 100644 --- a/src/channels/web/handlers/memory.rs +++ b/src/channels/web/handlers/memory.rs @@ -16,8 +16,9 @@ use crate::workspace::Workspace; /// Resolve the workspace for the authenticated user. /// -/// Prefers `workspace_pool` (multi-user mode) when available, falling back -/// to the single-user `state.workspace`. +/// Authenticated memory APIs should prefer the per-user workspace pool whenever +/// it is available so user-scoped reads and writes stay isolated even if the +/// deployment is otherwise using single-user bootstrap/static routes. pub(crate) async fn resolve_workspace( state: &GatewayState, user: &UserIdentity, @@ -223,3 +224,161 @@ pub async fn memory_search_handler( Ok(Json(MemorySearchResponse { results: hits })) } + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use std::sync::Arc; + + use axum::Router; + use axum::body::Body; + use axum::http::Request; + use axum::middleware; + use axum::routing::get; + use tower::ServiceExt; + + use crate::channels::web::auth::{MultiAuthState, UserIdentity, auth_middleware}; + use crate::channels::web::platform::state::{ + ActiveConfigSnapshot, GatewayState, PerUserRateLimiter, RateLimiter, WorkspacePool, + }; + use crate::channels::web::sse::SseManager; + use crate::config::{WorkspaceConfig, WorkspaceSearchConfig}; + use crate::db::Database; + use crate::workspace::{EmbeddingCacheConfig, Workspace}; + + use super::memory_read_handler; + + async fn test_db() -> (Arc, tempfile::TempDir) { + use crate::db::libsql::LibSqlBackend; + + let dir = tempfile::tempdir().expect("temp dir"); + let path = dir.path().join("memory-handler-test.db"); + let backend = LibSqlBackend::new_local(&path) + .await + .expect("create libsql backend"); + backend.run_migrations().await.expect("run migrations"); + (Arc::new(backend) as Arc, dir) + } + + fn test_state( + db: Arc, + workspace: Arc, + pool: Arc, + ) -> Arc { + Arc::new(GatewayState { + msg_tx: tokio::sync::RwLock::new(None), + sse: Arc::new(SseManager::new()), + workspace: Some(workspace), + workspace_pool: Some(pool), + multi_tenant_mode: false, + session_manager: None, + log_broadcaster: None, + log_level_handle: None, + extension_manager: None, + tool_registry: None, + store: Some(db), + settings_cache: None, + job_manager: None, + prompt_queue: None, + scheduler: None, + owner_id: "owner".to_string(), + shutdown_tx: tokio::sync::RwLock::new(None), + ws_tracker: None, + llm_provider: None, + llm_reload: None, + llm_session_manager: None, + config_toml_path: None, + skill_registry: None, + skill_catalog: None, + auth_manager: None, + chat_rate_limiter: PerUserRateLimiter::new(30, 60), + oauth_rate_limiter: PerUserRateLimiter::new(20, 60), + webhook_rate_limiter: RateLimiter::new(10, 60), + registry_entries: Vec::new(), + cost_guard: None, + routine_engine: Arc::new(tokio::sync::RwLock::new(None)), + startup_time: std::time::Instant::now(), + active_config: Arc::new(tokio::sync::RwLock::new(ActiveConfigSnapshot::default())), + secrets_store: None, + db_auth: None, + pairing_store: None, + oauth_providers: None, + oauth_state_store: None, + oauth_base_url: None, + oauth_allowed_domains: Vec::new(), + near_nonce_store: None, + near_rpc_url: None, + near_network: None, + oauth_sweep_shutdown: None, + frontend_html_cache: Arc::new(tokio::sync::RwLock::new(None)), + tool_dispatcher: None, + }) + } + + fn read_router(state: Arc) -> Router { + let mut tokens = HashMap::new(); + tokens.insert( + "tok-bob".to_string(), + UserIdentity { + user_id: "bob".to_string(), + role: "admin".to_string(), + workspace_read_scopes: Vec::new(), + }, + ); + let auth = MultiAuthState::multi(tokens); + + Router::new() + .route("/api/memory/read", get(memory_read_handler)) + .layer(middleware::from_fn_with_state(auth.into(), auth_middleware)) + .with_state(state) + } + + #[cfg(feature = "libsql")] + #[tokio::test] + async fn memory_read_prefers_workspace_pool_even_when_multi_tenant_mode_is_false() { + let (db, _dir) = test_db().await; + let owner_workspace = Arc::new(Workspace::new_with_db("owner", Arc::clone(&db))); + owner_workspace + .write("note.md", "owner note") + .await + .expect("write owner note"); + + let pool = Arc::new(WorkspacePool::new( + Arc::clone(&db), + None, + EmbeddingCacheConfig::default(), + WorkspaceSearchConfig::default(), + WorkspaceConfig::default(), + )); + let bob = UserIdentity { + user_id: "bob".to_string(), + role: "admin".to_string(), + workspace_read_scopes: Vec::new(), + }; + let bob_workspace = pool.get_or_create(&bob).await; + bob_workspace + .write("note.md", "bob note") + .await + .expect("write bob note"); + + let app = read_router(test_state(db, owner_workspace, pool)); + let response = app + .oneshot( + Request::builder() + .uri("/api/memory/read?path=note.md") + .header("Authorization", "Bearer tok-bob") + .body(Body::empty()) + .expect("build request"), + ) + .await + .expect("dispatch request"); + + assert_eq!(response.status(), axum::http::StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("read body"); + let payload: serde_json::Value = + serde_json::from_slice(&body).expect("parse memory read response"); + assert_eq!(payload["content"], "bob note"); + } +} diff --git a/src/channels/web/handlers/system_prompt.rs b/src/channels/web/handlers/system_prompt.rs index 93860045217..3f80930109e 100644 --- a/src/channels/web/handlers/system_prompt.rs +++ b/src/channels/web/handlers/system_prompt.rs @@ -18,7 +18,7 @@ pub async fn get_handler( AdminUser(_admin): AdminUser, ) -> Result, (StatusCode, String)> { // Gate behind multi-tenant mode. - if state.workspace_pool.is_none() { + if !state.multi_tenant_mode { return Err(( StatusCode::NOT_FOUND, "System prompt management requires multi-tenant mode".to_string(), @@ -68,7 +68,7 @@ pub async fn put_handler( } // Gate behind multi-tenant mode. - if state.workspace_pool.is_none() { + if !state.multi_tenant_mode { return Err(( StatusCode::NOT_FOUND, "System prompt management requires multi-tenant mode".to_string(), diff --git a/src/channels/web/handlers/tool_policy.rs b/src/channels/web/handlers/tool_policy.rs index 09e3415208e..2b2b89a5dd1 100644 --- a/src/channels/web/handlers/tool_policy.rs +++ b/src/channels/web/handlers/tool_policy.rs @@ -29,8 +29,7 @@ pub async fn tool_policy_get_handler( State(state): State>, AdminUser(_admin): AdminUser, ) -> Result, (StatusCode, String)> { - let pool = state.workspace_pool.as_ref(); // dispatch-exempt: gateway-mode probe, not a state mutation - if pool.is_none() { + if !state.multi_tenant_mode { return Err(( StatusCode::NOT_FOUND, "Admin tool policy is only available in multi-tenant mode".to_string(), @@ -73,8 +72,7 @@ pub async fn tool_policy_put_handler( AdminUser(_admin): AdminUser, Json(policy): Json, ) -> Result, (StatusCode, String)> { - let pool = state.workspace_pool.as_ref(); // dispatch-exempt: gateway-mode probe, not a state mutation - if pool.is_none() { + if !state.multi_tenant_mode { return Err(( StatusCode::NOT_FOUND, "Admin tool policy is only available in multi-tenant mode".to_string(), diff --git a/src/channels/web/handlers/webhooks.rs b/src/channels/web/handlers/webhooks.rs index a0d7e7589b5..fbb0ad23226 100644 --- a/src/channels/web/handlers/webhooks.rs +++ b/src/channels/web/handlers/webhooks.rs @@ -66,9 +66,7 @@ pub async fn webhook_trigger_handler( // In multi-tenant mode, reject unscoped webhooks to prevent cross-user // routine triggering. The per-routine secret provides some protection, // but tenant isolation requires scoping by user_id. - // Use workspace_pool as the multi-tenant indicator — it's only set when - // has_any_users() was true at startup (not just when a DB exists). - if state.workspace_pool.is_some() { + if state.multi_tenant_mode { return Err(( StatusCode::GONE, "Unscoped webhooks disabled in multi-tenant mode. Use /api/webhooks/u/{user_id}/{path} instead.".to_string(), diff --git a/src/channels/web/mod.rs b/src/channels/web/mod.rs index 5f8c38e5ba7..a7369cab165 100644 --- a/src/channels/web/mod.rs +++ b/src/channels/web/mod.rs @@ -57,13 +57,13 @@ use tokio_stream::wrappers::ReceiverStream; use crate::agent::SessionManager; use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate}; -use crate::config::GatewayConfig; +use crate::config::{Config, GatewayConfig}; use crate::db::Database; use crate::error::ChannelError; use crate::extensions::ExtensionManager; use crate::orchestrator::job_manager::ContainerJobManager; use crate::tools::ToolRegistry; -use crate::workspace::Workspace; +use crate::workspace::{EmbeddingCacheConfig, EmbeddingProvider, Workspace}; use ironclaw_skills::catalog::SkillCatalog; use ironclaw_skills::registry::SkillRegistry; @@ -152,6 +152,7 @@ impl GatewayChannel { )), workspace: None, workspace_pool: None, + multi_tenant_mode: false, session_manager: None, log_broadcaster: None, log_level_handle: None, @@ -217,6 +218,7 @@ impl GatewayChannel { )), workspace: self.state.workspace.clone(), workspace_pool: self.state.workspace_pool.clone(), + multi_tenant_mode: self.state.multi_tenant_mode, session_manager: self.state.session_manager.clone(), log_broadcaster: self.state.log_broadcaster.clone(), log_level_handle: self.state.log_level_handle.clone(), @@ -575,6 +577,39 @@ impl GatewayChannel { self } + /// Configure DB-backed workspace access from the resolved runtime config. + /// + /// Startup should decide multi-tenant mode from explicit config, not from + /// current DB contents. This helper keeps the DB-backed workspace pool and + /// the `multi_tenant_mode` flag wired together so production startup and + /// integration tests exercise the same caller path. + pub fn with_db_backing_from_config( + mut self, + config: &Config, + db: Arc, + embeddings: Option>, + ) -> Self { + let emb_cache_config = EmbeddingCacheConfig { + max_entries: config.embeddings.cache_size, + }; + let pool = Arc::new(platform::state::WorkspacePool::new( + db, + embeddings, + emb_cache_config, + config.search.clone(), + config.workspace.clone(), + )); + self = self.with_workspace_pool(pool); + self = self.with_multi_tenant_mode(config.is_multi_tenant_deployment()); + self + } + + /// Mark whether the gateway started in multi-tenant mode. + pub fn with_multi_tenant_mode(mut self, multi_tenant_mode: bool) -> Self { + self.rebuild_state(|s| s.multi_tenant_mode = multi_tenant_mode); + self + } + /// Inject the shared pairing store for the pairing API endpoints. pub fn with_pairing_store(mut self, store: Arc) -> Self { self.rebuild_state(|s| s.pairing_store = Some(store)); diff --git a/src/channels/web/platform/state.rs b/src/channels/web/platform/state.rs index 44aa17cac07..c1daaa62dc6 100644 --- a/src/channels/web/platform/state.rs +++ b/src/channels/web/platform/state.rs @@ -342,8 +342,20 @@ pub struct GatewayState { pub sse: Arc, /// Workspace for memory API (single-user fallback). pub workspace: Option>, - /// Per-user workspace pool for multi-user mode. + /// Optional per-user workspace resolver/pool. + /// + /// This is independent of `multi_tenant_mode`: the runtime may provide a + /// per-user workspace pool even in single-user mode for plumbing or test + /// harnesses. pub workspace_pool: Option>, + /// Whether the gateway started in multi-tenant mode. + /// + /// This is intentionally separate from `workspace_pool.is_some()`: the + /// runtime may still use a per-user workspace resolver in single-user mode, + /// but the unauthenticated bootstrap routes (`/`, `/style.css`) only need + /// to suppress workspace-driven frontend customizations when startup + /// actually determined that multiple tenants exist. + pub multi_tenant_mode: bool, /// Session manager for thread info. pub session_manager: Option>, /// Log broadcaster for the logs SSE endpoint. diff --git a/src/channels/web/platform/static_files.rs b/src/channels/web/platform/static_files.rs index 1925726b5a1..f695d27dee5 100644 --- a/src/channels/web/platform/static_files.rs +++ b/src/channels/web/platform/static_files.rs @@ -171,7 +171,7 @@ async fn compute_frontend_cache_key(workspace: &crate::workspace::Workspace) -> /// every widget manifest / JS / CSS file, which would otherwise fire on every /// page load. /// -/// **Multi-tenant safety.** In multi-user mode (`workspace_pool` set) this +/// **Multi-tenant safety.** In multi-tenant mode (`multi_tenant_mode`) this /// function ALWAYS returns `None`, regardless of whether `state.workspace` is /// also populated. The customization assembly path is fundamentally /// single-tenant: `index_handler` (`GET /`) is the unauthenticated bootstrap @@ -215,7 +215,7 @@ async fn compute_frontend_cache_key(workspace: &crate::workspace::Workspace) -> /// right fix is a workspace version generation counter, not a lock /// around this function. pub(crate) async fn build_frontend_html(state: &GatewayState) -> Option { - if state.workspace_pool.is_some() { + if state.multi_tenant_mode { // Multi-tenant: refuse the assembly path entirely. See the function // doc comment above for the full rationale. The cache write below // is unreachable on this branch, so the cache stays empty and @@ -610,7 +610,7 @@ pub(crate) async fn css_handler( // // **Multi-tenant safety.** This must mirror the same guard // `build_frontend_html` already enforces (see its doc comment): in - // multi-user mode (`workspace_pool.is_some()`) we cannot resolve a + // multi-tenant mode (`multi_tenant_mode`) we cannot resolve a // per-user workspace because `/style.css` is the unauthenticated // bootstrap stylesheet — there is no user identity at request time. // Reading from `state.workspace` here would expose one global @@ -619,7 +619,7 @@ pub(crate) async fn css_handler( // path entirely in multi-tenant mode and serve the embedded base // stylesheet to all users; per-user CSS overrides can ride a future // authenticated `/api/frontend/custom-css` endpoint. - let css: std::borrow::Cow<'static, str> = if state.workspace_pool.is_some() { + let css: std::borrow::Cow<'static, str> = if state.multi_tenant_mode { std::borrow::Cow::Borrowed(assets::STYLE_CSS) } else { match &state.workspace { @@ -1248,6 +1248,7 @@ mod tests { let state_mut = Arc::get_mut(&mut state).expect("test state must be uniquely owned"); state_mut.workspace = Some(global_ws); state_mut.workspace_pool = Some(pool); + state_mut.multi_tenant_mode = true; let app = Router::new() .route("/style.css", get(css_handler)) @@ -1274,7 +1275,7 @@ mod tests { assert!( !body_str.contains("TENANT-LEAK-BAIT"), "custom.css from global workspace leaked into multi-tenant /style.css \ - response — css_handler is missing its workspace_pool guard" + response — css_handler is missing its multi_tenant_mode guard" ); // Contract 2: the response is exactly the embedded base @@ -1401,6 +1402,7 @@ mod tests { let state_mut = Arc::get_mut(&mut state).expect("test state must be uniquely owned"); state_mut.workspace = Some(global_ws); state_mut.workspace_pool = Some(pool); + state_mut.multi_tenant_mode = true; // Contract 1: build_frontend_html refuses to assemble. let html = build_frontend_html(&state).await; diff --git a/src/channels/web/platform/ws.rs b/src/channels/web/platform/ws.rs index bfa2b99854b..8d64ae665eb 100644 --- a/src/channels/web/platform/ws.rs +++ b/src/channels/web/platform/ws.rs @@ -562,6 +562,7 @@ mod tests { sse: Arc::new(SseManager::new()), workspace: None, workspace_pool: None, + multi_tenant_mode: false, session_manager: None, log_broadcaster: None, log_level_handle: None, diff --git a/src/channels/web/test_helpers.rs b/src/channels/web/test_helpers.rs index 23817b777bc..ff7a18d0a9d 100644 --- a/src/channels/web/test_helpers.rs +++ b/src/channels/web/test_helpers.rs @@ -91,6 +91,7 @@ impl TestGatewayBuilder { sse: Arc::new(SseManager::new()), workspace: None, workspace_pool: None, + multi_tenant_mode: false, session_manager: None, log_broadcaster: None, log_level_handle: None, @@ -206,6 +207,7 @@ pub(crate) fn test_gateway_state_with_dependencies( sse: Arc::new(SseManager::new()), workspace: None, workspace_pool: None, + multi_tenant_mode: false, session_manager: None, log_broadcaster: None, log_level_handle: None, @@ -262,6 +264,7 @@ pub(crate) fn test_gateway_state_with_store_and_session_manager( sse: Arc::new(SseManager::new()), workspace: None, workspace_pool: None, + multi_tenant_mode: false, session_manager: Some(session_manager), log_broadcaster: None, log_level_handle: None, diff --git a/src/channels/web/tests/multi_tenant.rs b/src/channels/web/tests/multi_tenant.rs index 5c507d10405..2e809eefeaa 100644 --- a/src/channels/web/tests/multi_tenant.rs +++ b/src/channels/web/tests/multi_tenant.rs @@ -58,6 +58,7 @@ fn build_state( sse: Arc::new(SseManager::new()), workspace: None, workspace_pool: None, + multi_tenant_mode: false, session_manager: None, log_broadcaster: None, log_level_handle: None, @@ -1264,6 +1265,7 @@ mod admin_tool_policy { sse: Arc::new(SseManager::new()), workspace: None, workspace_pool: Some(Arc::new(pool)), + multi_tenant_mode: true, session_manager: None, log_broadcaster: None, log_level_handle: None, diff --git a/src/channels/web/util.rs b/src/channels/web/util.rs index 359a89f6466..5c3ed2cd96e 100644 --- a/src/channels/web/util.rs +++ b/src/channels/web/util.rs @@ -63,6 +63,7 @@ fn web_attachment_ext(mime: &str) -> Option<&'static str> { "audio/aac" => Some("aac"), "audio/flac" => Some("flac"), "audio/webm" => Some("webm"), + "application/octet-stream" => Some("bin"), _ => None, }; @@ -108,6 +109,7 @@ fn is_allowed_attachment_mime(mime: &str) -> bool { | "application/pdf" | "application/json" | "application/xml" + | "application/octet-stream" | "application/rtf" | "text/rtf" | "application/vnd.openxmlformats-officedocument.wordprocessingml.document" @@ -1180,6 +1182,23 @@ mod tests { assert!(err.contains("Unsupported file type")); } + #[test] + fn web_upload_accepts_octet_stream_attachment() { + use base64::Engine; + + let attachments = vec![AttachmentData { + mime_type: "application/octet-stream".to_string(), + filename: Some("mystery.bin".to_string()), + data_base64: base64::engine::general_purpose::STANDARD + .encode([0x00u8, 0x01, 0x02, 0x03]), + }]; + + let incoming = web_attachments_to_incoming(&attachments).expect("octet-stream should pass"); + assert_eq!(incoming[0].mime_type, "application/octet-stream"); + assert_eq!(incoming[0].filename.as_deref(), Some("mystery.bin")); + assert_eq!(incoming[0].kind, crate::channels::AttachmentKind::Document); + } + #[test] fn web_upload_rejects_spoofed_audio_mp4() { use base64::Engine; diff --git a/src/config/mod.rs b/src/config/mod.rs index 731c617e10d..66187b09c5f 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -153,6 +153,15 @@ fn generate_test_master_key() -> secrecy::SecretString { } impl Config { + /// Returns whether this deployment is configured to run in multi-tenant mode. + /// + /// Keep this decision config-driven rather than inferring it from runtime + /// DB contents. A deployment may be explicitly multi-tenant before any + /// non-owner users have been created. + pub fn is_multi_tenant_deployment(&self) -> bool { + self.agent.multi_tenant + } + /// Create a full Config for integration tests without reading env vars. /// /// Requires the `libsql` feature. Sets up: @@ -286,45 +295,8 @@ impl Config { let _ = dotenvy::dotenv(); crate::bootstrap::load_ironclaw_env(); - // Resolution layers (lowest -> highest priority): - // defaults -> deployment profile -> TOML -> admin DB -> per-user DB - let mut settings = Settings::default(); - profile::apply_profile(&mut settings)?; - Self::apply_toml_overlay(&mut settings, toml_path)?; - - // Layer admin-scope defaults between TOML and per-user settings. - // This lets an admin set instance-wide defaults (e.g. temperature, - // model) that members inherit unless they override per-user. - // Skip if the user IS the admin scope to avoid a redundant merge. - let admin_scope = crate::tools::permissions::ADMIN_SETTINGS_USER_ID; - if user_id != admin_scope - && let Ok(mut admin_map) = store.get_all_settings(admin_scope).await - && !admin_map.is_empty() - { - // Defense-in-depth: even though the admin-scope map is written - // by an operator, never let admin-only LLM endpoint settings - // (private/loopback URLs) propagate down to non-operators. - if !is_operator { - crate::config::helpers::strip_admin_only_llm_keys(&mut admin_map); - } - let admin_settings = Settings::from_db_map(&admin_map); - settings.merge_from(&admin_settings); - } - - // Overlay per-user DB settings on top (highest priority). - match store.get_all_settings(user_id).await { - Ok(mut map) => { - if !is_operator { - crate::config::helpers::strip_admin_only_llm_keys(&mut map); - } - let db_settings = Settings::from_db_map(&map); - settings.merge_from(&db_settings); - } - Err(e) => { - tracing::warn!("Failed to load settings from DB, using defaults: {}", e); - } - }; - + let settings = + Self::load_db_backed_settings(store, user_id, toml_path, is_operator, false).await?; Self::build(&settings).await } @@ -417,45 +389,123 @@ impl Config { secrets: Option<&(dyn crate::secrets::SecretsStore + Send + Sync)>, is_operator: bool, ) -> Result<(), ConfigError> { - let mut settings = if let Some(store) = store { - // Resolution layers: profile -> TOML -> admin DB -> per-user DB. - let mut s = Settings::default(); - profile::apply_profile(&mut s)?; - Self::apply_toml_overlay(&mut s, toml_path)?; - let admin_scope = crate::tools::permissions::ADMIN_SETTINGS_USER_ID; - if user_id != admin_scope - && let Ok(mut admin_map) = store.get_all_settings(admin_scope).await - && !admin_map.is_empty() - { - if !is_operator { - crate::config::helpers::strip_admin_only_llm_keys(&mut admin_map); + self.llm = + Self::resolve_llm_with_secrets(store, user_id, toml_path, secrets, is_operator).await?; + Ok(()) + } + + /// Build the settings overlay used for DB-backed config reads. + /// + /// Resolution order is profile -> TOML -> admin DB -> per-user DB. + /// This is shared between full config loads and LLM-only hot reloads so + /// they read the same owner/admin scopes without duplicating merge logic. + async fn load_db_backed_settings( + store: &(dyn crate::db::SettingsStore + Sync), + user_id: &str, + toml_path: Option<&std::path::Path>, + is_operator: bool, + strict_db_reads: bool, + ) -> Result { + let mut settings = Settings::default(); + profile::apply_profile(&mut settings)?; + Self::apply_toml_overlay(&mut settings, toml_path)?; + + let admin_scope = crate::tools::permissions::ADMIN_SETTINGS_USER_ID; + if user_id != admin_scope { + match store.get_all_settings(admin_scope).await { + Ok(mut admin_map) if !admin_map.is_empty() => { + if !is_operator { + crate::config::helpers::strip_admin_only_llm_keys(&mut admin_map); + } + let admin_settings = Settings::from_db_map(&admin_map); + settings.merge_from(&admin_settings); + } + Ok(_) => {} + Err(e) if strict_db_reads => { + return Err(ConfigError::ParseError(format!( + "Failed to load admin-scope settings from DB: {e}" + ))); + } + Err(e) => { + tracing::warn!( + "Failed to load admin-scope settings from DB, using defaults: {e}" + ); } - let admin_settings = Settings::from_db_map(&admin_map); - s.merge_from(&admin_settings); } - if let Ok(mut map) = store.get_all_settings(user_id).await { + } + + match store.get_all_settings(user_id).await { + Ok(mut map) => { if !is_operator { crate::config::helpers::strip_admin_only_llm_keys(&mut map); } let db_settings = Settings::from_db_map(&map); - s.merge_from(&db_settings); + settings.merge_from(&db_settings); } - s + Err(e) if strict_db_reads => { + return Err(ConfigError::ParseError(format!( + "Failed to load settings from DB: {e}" + ))); + } + Err(e) => { + tracing::warn!("Failed to load settings from DB, using defaults: {}", e); + } + } + + Ok(settings) + } + + async fn resolve_llm_with_secrets_inner( + store: Option<&(dyn crate::db::SettingsStore + Sync)>, + user_id: &str, + toml_path: Option<&std::path::Path>, + secrets: Option<&(dyn crate::secrets::SecretsStore + Send + Sync)>, + is_operator: bool, + strict_db_reads: bool, + ) -> Result { + let mut settings = if let Some(store) = store { + Self::load_db_backed_settings(store, user_id, toml_path, is_operator, strict_db_reads) + .await? } else { let mut s = Settings::default(); profile::apply_profile(&mut s)?; + Self::apply_toml_overlay(&mut s, toml_path)?; s }; - // Hydrate API keys from encrypted secrets store into the settings - // struct so that LlmConfig::resolve() sees them without any changes - // to its synchronous resolution logic. if let Some(secrets) = secrets { hydrate_llm_keys_from_secrets(&mut settings, secrets, user_id).await; } - self.llm = LlmConfig::resolve(&settings)?; - Ok(()) + LlmConfig::resolve(&settings) + } + + /// Resolve only the LLM configuration from the current source stack. + /// + /// This is used by hot reload paths that need the exact owner/admin merge + /// semantics from startup without rebuilding unrelated config sections. + pub(crate) async fn resolve_llm_with_secrets( + store: Option<&(dyn crate::db::SettingsStore + Sync)>, + user_id: &str, + toml_path: Option<&std::path::Path>, + secrets: Option<&(dyn crate::secrets::SecretsStore + Send + Sync)>, + is_operator: bool, + ) -> Result { + Self::resolve_llm_with_secrets_inner(store, user_id, toml_path, secrets, is_operator, false) + .await + } + + /// Resolve LLM configuration for hot reload paths that must fail closed on + /// DB read errors so the caller can roll back the triggering settings write. + pub(crate) async fn resolve_llm_with_secrets_strict( + store: Option<&(dyn crate::db::SettingsStore + Sync)>, + user_id: &str, + toml_path: Option<&std::path::Path>, + secrets: Option<&(dyn crate::secrets::SecretsStore + Send + Sync)>, + is_operator: bool, + ) -> Result { + Self::resolve_llm_with_secrets_inner(store, user_id, toml_path, secrets, is_operator, true) + .await } /// Build config from settings (shared by from_env and from_db). @@ -942,12 +992,16 @@ mod tests { rows: tokio::sync::RwLock< std::collections::HashMap>, >, + fail_get_all_settings_for: tokio::sync::RwLock>, } impl FakeSettingsStore { fn new() -> Self { Self { rows: tokio::sync::RwLock::new(std::collections::HashMap::new()), + fail_get_all_settings_for: tokio::sync::RwLock::new( + std::collections::HashSet::new(), + ), } } @@ -957,6 +1011,13 @@ mod tests { .or_default() .insert(key.to_string(), value); } + + async fn fail_get_all_settings_for(&self, user_id: &str) { + self.fail_get_all_settings_for + .write() + .await + .insert(user_id.to_string()); + } } #[async_trait::async_trait] @@ -1016,6 +1077,16 @@ mod tests { user_id: &str, ) -> Result, crate::error::DatabaseError> { + if self + .fail_get_all_settings_for + .read() + .await + .contains(user_id) + { + return Err(crate::error::DatabaseError::Query(format!( + "injected get_all_settings failure for {user_id}" + ))); + } let rows = self.rows.read().await; Ok(rows.get(user_id).cloned().unwrap_or_default()) } @@ -1064,6 +1135,63 @@ mod tests { .expect("create temp toml") } + #[allow(clippy::await_holding_lock)] + #[tokio::test] + async fn re_resolve_llm_without_store_keeps_toml_overlay() { + let _env_guard = crate::config::helpers::lock_env(); + // SAFETY: Under ENV_MUTEX. + unsafe { + std::env::remove_var("LLM_BACKEND"); + std::env::remove_var("NEARAI_MODEL"); + } + + let dir = tempfile::tempdir().expect("create temp dir"); + let toml_path = dir.path().join("config.toml"); + Settings { + llm_backend: Some("nearai".to_string()), + selected_model: Some("toml-selected-model".to_string()), + ..Default::default() + } + .save_toml(&toml_path) + .expect("save config.toml"); + + let mut cfg = config_for_owner("operator-user"); + cfg.re_resolve_llm(None, "operator-user", Some(&toml_path)) + .await + .expect("resolve should succeed without a settings store"); + + assert_eq!( + cfg.llm.backend, "nearai", + "re-resolve without a DB store must keep the TOML-selected backend" + ); + assert_eq!( + cfg.llm.nearai.model, "toml-selected-model", + "re-resolve without a DB store must keep the TOML-selected model" + ); + } + + #[tokio::test] + async fn resolve_llm_with_secrets_strict_fails_on_user_db_read_error() { + let store = FakeSettingsStore::new(); + store.fail_get_all_settings_for("owner-user").await; + + let toml = empty_toml_path(); + let err = Config::resolve_llm_with_secrets_strict( + Some(&store as &(dyn crate::db::SettingsStore + Sync)), + "owner-user", + Some(toml.path()), + None, + true, + ) + .await + .expect_err("strict resolve should fail closed on DB read error"); + + assert!( + err.to_string().contains("Failed to load settings from DB"), + "strict resolve should surface the DB read failure; got {err}" + ); + } + #[tokio::test] async fn re_resolve_llm_strips_admin_only_keys_for_non_operator_user() { use crate::db::SettingsStore; diff --git a/src/main.rs b/src/main.rs index bfddadc150e..5af236ea97f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -833,25 +833,17 @@ async fn async_main() -> anyhow::Result<()> { let mut sse_manager: Option> = None; if enable_non_cli && let Some(ref gw_config) = config.channels.gateway { let mut gw = GatewayChannel::new(gw_config.clone(), config.owner_id.clone()); + gw = gw.with_multi_tenant_mode(config.is_multi_tenant_deployment()); gw = gw.with_llm_provider(Arc::clone(&components.llm)); if let Some(ref ws) = components.workspace { gw = gw.with_workspace(Arc::clone(ws)); } - // Create per-user workspace pool for multi-user mode. if let Some(ref db) = components.db { - let emb_cache_config = ironclaw::workspace::EmbeddingCacheConfig { - max_entries: config.embeddings.cache_size, - }; - let pool = Arc::new( - ironclaw::channels::web::platform::state::WorkspacePool::new( - Arc::clone(db), - components.embeddings.clone(), - emb_cache_config, - config.search.clone(), - config.workspace.clone(), - ), + gw = gw.with_db_backing_from_config( + &config, + Arc::clone(db), + components.embeddings.clone(), ); - gw = gw.with_workspace_pool(pool); } gw = gw.with_session_manager(Arc::clone(&session_manager)); gw = gw.with_llm_session_manager(Arc::clone(&components.session)); diff --git a/src/tools/builtin/skill_tools.rs b/src/tools/builtin/skill_tools.rs index 26b8d38a51b..29160608113 100644 --- a/src/tools/builtin/skill_tools.rs +++ b/src/tools/builtin/skill_tools.rs @@ -2294,30 +2294,8 @@ mod tests { #[test] fn test_extract_skill_from_zip_deflate() { - // Build a real ZIP with flate2 + manual header construction. - use flate2::Compression; - use flate2::write::DeflateEncoder; - use std::io::Write; - let skill_md = b"---\nname: test\n---\n# Test Skill\n"; - let mut encoder = DeflateEncoder::new(Vec::new(), Compression::default()); - encoder.write_all(skill_md).unwrap(); - let compressed = encoder.finish().unwrap(); - - let mut zip = Vec::new(); - // Local file header - zip.extend_from_slice(&[0x50, 0x4B, 0x03, 0x04]); // signature - zip.extend_from_slice(&[0x14, 0x00]); // version needed (2.0) - zip.extend_from_slice(&[0x00, 0x00]); // flags - zip.extend_from_slice(&[0x08, 0x00]); // compression: deflate - zip.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]); // mod time/date - zip.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]); // crc32 (unused) - zip.extend_from_slice(&(compressed.len() as u32).to_le_bytes()); // compressed size - zip.extend_from_slice(&(skill_md.len() as u32).to_le_bytes()); // uncompressed size - zip.extend_from_slice(&8u16.to_le_bytes()); // filename length - zip.extend_from_slice(&0u16.to_le_bytes()); // extra field length - zip.extend_from_slice(b"SKILL.md"); - zip.extend_from_slice(&compressed); + let zip = build_zip_archive(&[("SKILL.md", skill_md)], zip::CompressionMethod::Deflated); let result = super::extract_skill_from_zip(&zip).unwrap(); assert_eq!(result, "---\nname: test\n---\n# Test Skill\n"); @@ -2326,21 +2304,7 @@ mod tests { #[test] fn test_extract_skill_from_zip_store() { let skill_md = b"---\nname: stored\n---\n# Stored\n"; - - let mut zip = Vec::new(); - // Local file header - zip.extend_from_slice(&[0x50, 0x4B, 0x03, 0x04]); - zip.extend_from_slice(&[0x0A, 0x00]); // version needed (1.0) - zip.extend_from_slice(&[0x00, 0x00]); // flags - zip.extend_from_slice(&[0x00, 0x00]); // compression: store - zip.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]); // mod time/date - zip.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]); // crc32 - zip.extend_from_slice(&(skill_md.len() as u32).to_le_bytes()); // compressed = uncompressed - zip.extend_from_slice(&(skill_md.len() as u32).to_le_bytes()); - zip.extend_from_slice(&8u16.to_le_bytes()); // filename length - zip.extend_from_slice(&0u16.to_le_bytes()); // extra field length - zip.extend_from_slice(b"SKILL.md"); - zip.extend_from_slice(skill_md); + let zip = build_zip_archive(&[("SKILL.md", skill_md)], zip::CompressionMethod::Stored); let result = super::extract_skill_from_zip(&zip).unwrap(); assert_eq!(result, "---\nname: stored\n---\n# Stored\n"); @@ -2686,19 +2650,7 @@ mod tests { #[test] fn test_extract_skill_from_zip_missing_skill_md() { - let mut zip = Vec::new(); - zip.extend_from_slice(&[0x50, 0x4B, 0x03, 0x04]); - zip.extend_from_slice(&[0x0A, 0x00]); // version - zip.extend_from_slice(&[0x00, 0x00]); // flags - zip.extend_from_slice(&[0x00, 0x00]); // compression: store - zip.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]); // mod time/date - zip.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]); // crc32 - zip.extend_from_slice(&2u32.to_le_bytes()); // compressed size - zip.extend_from_slice(&2u32.to_le_bytes()); // uncompressed size - zip.extend_from_slice(&10u16.to_le_bytes()); // filename length - zip.extend_from_slice(&0u16.to_le_bytes()); // extra field length - zip.extend_from_slice(b"_meta.json"); - zip.extend_from_slice(b"{}"); + let zip = build_zip_archive(&[("_meta.json", b"{}")], zip::CompressionMethod::Stored); let err = super::extract_skill_from_zip(&zip).unwrap_err(); assert!(err.to_string().contains("does not contain SKILL.md")); @@ -2706,22 +2658,24 @@ mod tests { // ── ZIP extraction security regression tests ──────────────────────── - /// Helper: build a minimal ZIP local file header with Store compression. + fn build_zip_archive( + entries: &[(&str, &[u8])], + compression: zip::CompressionMethod, + ) -> Vec { + use std::io::Write; + + let cursor = std::io::Cursor::new(Vec::new()); + let mut writer = zip::ZipWriter::new(cursor); + let options = zip::write::SimpleFileOptions::default().compression_method(compression); + for (file_name, content) in entries { + writer.start_file(*file_name, options).unwrap(); + writer.write_all(content).unwrap(); + } + writer.finish().unwrap().into_inner() + } + fn build_zip_entry_store(file_name: &str, content: &[u8]) -> Vec { - let mut zip = Vec::new(); - zip.extend_from_slice(&[0x50, 0x4B, 0x03, 0x04]); // signature - zip.extend_from_slice(&[0x0A, 0x00]); // version needed (1.0) - zip.extend_from_slice(&[0x00, 0x00]); // flags - zip.extend_from_slice(&[0x00, 0x00]); // compression: store (0) - zip.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]); // mod time/date - zip.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]); // crc32 - zip.extend_from_slice(&(content.len() as u32).to_le_bytes()); // compressed size - zip.extend_from_slice(&(content.len() as u32).to_le_bytes()); // uncompressed size - zip.extend_from_slice(&(file_name.len() as u16).to_le_bytes()); // filename length - zip.extend_from_slice(&0u16.to_le_bytes()); // extra field length - zip.extend_from_slice(file_name.as_bytes()); - zip.extend_from_slice(content); - zip + build_zip_archive(&[(file_name, content)], zip::CompressionMethod::Stored) } #[test] @@ -2735,9 +2689,10 @@ mod tests { #[test] fn test_zip_extract_ignores_non_skill_entries() { // ZIP with README.md and src/main.rs but no SKILL.md -- should error. - let mut zip = Vec::new(); - zip.extend_from_slice(&build_zip_entry_store("README.md", b"# Readme")); - zip.extend_from_slice(&build_zip_entry_store("src/main.rs", b"fn main() {}")); + let zip = build_zip_archive( + &[("README.md", b"# Readme"), ("src/main.rs", b"fn main() {}")], + zip::CompressionMethod::Stored, + ); let err = super::extract_skill_from_zip(&zip).unwrap_err(); assert!( @@ -2749,55 +2704,40 @@ mod tests { #[test] fn test_zip_extract_path_traversal_rejected() { - // An entry named "../../SKILL.md" must NOT match the exact "SKILL.md" check. + // Parent components are invalid and must be rejected during path normalization. let content = b"---\nname: evil\n---\n# Malicious path traversal\n"; let zip = build_zip_entry_store("../../SKILL.md", content); let err = super::extract_skill_from_zip(&zip).unwrap_err(); assert!( - err.to_string().contains("does not contain SKILL.md"), - "Path traversal entry should not match SKILL.md, got: {}", + err.to_string().contains("unsafe path"), + "Path traversal entry should be rejected during normalization, got: {}", err ); } #[test] - fn test_zip_extract_nested_path_not_matched() { - // An entry named "subdir/SKILL.md" must NOT match the exact "SKILL.md" check. + fn test_zip_extract_nested_single_skill_supported() { + // A ZIP containing a single nested skill directory should still extract SKILL.md. let content = b"---\nname: nested\n---\n# Nested\n"; let zip = build_zip_entry_store("subdir/SKILL.md", content); - let err = super::extract_skill_from_zip(&zip).unwrap_err(); - assert!( - err.to_string().contains("does not contain SKILL.md"), - "Nested path should not match SKILL.md, got: {}", - err - ); + let result = super::extract_skill_from_zip(&zip).unwrap(); + assert_eq!(result, std::str::from_utf8(content).unwrap()); } #[test] fn test_zip_extract_oversized_rejected() { - // Create a ZIP entry whose declared uncompressed_size exceeds MAX_DECOMPRESSED (1 MB). - let oversized_claim: u32 = 2 * 1024 * 1024; // 2 MB - let small_body = b"tiny"; - - let mut zip = Vec::new(); - zip.extend_from_slice(&[0x50, 0x4B, 0x03, 0x04]); // signature - zip.extend_from_slice(&[0x0A, 0x00]); // version needed - zip.extend_from_slice(&[0x00, 0x00]); // flags - zip.extend_from_slice(&[0x00, 0x00]); // compression: store - zip.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]); // mod time/date - zip.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]); // crc32 - zip.extend_from_slice(&(small_body.len() as u32).to_le_bytes()); // compressed size (actual) - zip.extend_from_slice(&oversized_claim.to_le_bytes()); // uncompressed size (forged) - zip.extend_from_slice(&8u16.to_le_bytes()); // filename length - zip.extend_from_slice(&0u16.to_le_bytes()); // extra field length - zip.extend_from_slice(b"SKILL.md"); - zip.extend_from_slice(small_body); + let oversized_body = vec![b'x'; (super::MAX_ZIP_ENTRY_BYTES as usize) + 1]; + let zip = build_zip_archive( + &[("blob.bin", oversized_body.as_slice())], + zip::CompressionMethod::Stored, + ); let err = super::extract_skill_from_zip(&zip).unwrap_err(); assert!( - err.to_string().contains("too large"), + err.to_string() + .contains("ZIP entry too large to decompress safely"), "Oversized entry should be rejected, got: {}", err ); diff --git a/tests/e2e/scenarios/test_pending_user_messages.py b/tests/e2e/scenarios/test_pending_user_messages.py index 5859407f190..dd083ec967f 100644 --- a/tests/e2e/scenarios/test_pending_user_messages.py +++ b/tests/e2e/scenarios/test_pending_user_messages.py @@ -5,8 +5,12 @@ clears the DOM before the agent loop has persisted them. """ +import asyncio + from helpers import ( + AUTH_TOKEN, SEL, + api_get, send_chat_and_wait_for_terminal_message, ) @@ -26,6 +30,27 @@ async def _create_new_thread(page) -> str: return await page.evaluate("() => currentThreadId") +async def _reload_and_switch_to_thread(page, base_url: str, thread_id: str) -> None: + await page.goto(f"{base_url}/?token={AUTH_TOKEN}", timeout=15000) + await page.wait_for_selector(SEL["auth_screen"], state="hidden", timeout=10000) + await _wait_for_connected(page, timeout=10000) + await page.evaluate("(id) => switchThread(id)", thread_id) + await page.wait_for_function("(id) => currentThreadId === id", arg=thread_id, timeout=10000) + + +async def _wait_for_in_progress_turn(base_url: str, thread_id: str, *, timeout: float = 15.0) -> dict: + last_payload = {} + for _ in range(int(timeout * 5)): + response = await api_get(base_url, f"/api/chat/history?thread_id={thread_id}", timeout=15) + response.raise_for_status() + payload = response.json() + last_payload = payload + if payload.get("in_progress"): + return payload + await asyncio.sleep(0.2) + raise AssertionError(f"Timed out waiting for in-progress turn: {last_payload}") + + async def test_user_message_visible_after_send(page): """A sent message should be visible in the chat immediately.""" chat_input = page.locator(SEL["chat_input"]) @@ -128,6 +153,56 @@ async def test_pending_message_survives_sse_reconnect(page): ) +async def test_in_progress_attachment_turn_survives_reload(page, ironclaw_server): + """Reloading during a durable in-progress attachment turn keeps its file card.""" + await _wait_for_connected(page, timeout=5000) + + thread_id = await page.evaluate("() => currentThreadId") + assert thread_id, "expected an active thread before send" + + attachment_input = page.locator(SEL["attachment_input"]) + chat_input = page.locator(SEL["chat_input"]) + + await attachment_input.set_input_files( + files=[ + { + "name": "pending-note.txt", + "mimeType": "text/plain", + "buffer": b"Attachment survives in-progress reload.", + } + ] + ) + + await chat_input.fill("issue 1780 loop forever") + await chat_input.press("Enter") + + await page.wait_for_function( + """() => { + const pending = _pendingUserMessages.get(currentThreadId); + return pending && pending.some((p) => + p.content === 'issue 1780 loop forever' && + Array.isArray(p.attachments) && + p.attachments.some((att) => att.filename === 'pending-note.txt') + ); + }""", + timeout=5000, + ) + + await _wait_for_in_progress_turn(ironclaw_server, thread_id, timeout=15.0) + await _reload_and_switch_to_thread(page, ironclaw_server, thread_id) + + await page.wait_for_function( + """() => { + const users = document.querySelectorAll('#chat-messages .message.user'); + const lastUser = users.length ? users[users.length - 1] : null; + return !!lastUser + && lastUser.querySelectorAll('.message-attachment-file').length >= 1 + && (lastUser.innerText || '').includes('pending-note.txt'); + }""", + timeout=15000, + ) + + async def test_pending_entry_cleared_when_send_fails(page): """If POST /api/chat/send rejects (network error, 5xx), the optimistic pending entry must be removed so a subsequent thread switch / loadHistory diff --git a/tests/gateway_startup_multi_tenant_integration.rs b/tests/gateway_startup_multi_tenant_integration.rs new file mode 100644 index 00000000000..1a58e953a62 --- /dev/null +++ b/tests/gateway_startup_multi_tenant_integration.rs @@ -0,0 +1,99 @@ +//! Startup-level regression test for config-driven gateway multi-tenant mode. +//! +//! Verifies the real gateway construction path derives `multi_tenant_mode` +//! from `Config::is_multi_tenant_deployment()` even when the database is +//! freshly migrated and contains no tenant users yet. + +#[cfg(feature = "libsql")] +mod tests { + use std::path::PathBuf; + use std::sync::Arc; + + use ironclaw::channels::web::GatewayChannel; + use ironclaw::channels::web::auth::MultiAuthState; + use ironclaw::channels::web::platform::router::start_server; + use ironclaw::config::{Config, GatewayConfig}; + use ironclaw::db::Database; + + async fn create_test_db() -> (Arc, tempfile::TempDir) { + use ironclaw::db::libsql::LibSqlBackend; + + let dir = tempfile::tempdir().expect("temp db dir"); + let path = dir.path().join("gateway-startup-test.db"); + let backend = LibSqlBackend::new_local(&path) + .await + .expect("create libsql backend"); + backend.run_migrations().await.expect("run migrations"); + (Arc::new(backend) as Arc, dir) + } + + #[tokio::test] + async fn gateway_startup_honors_config_multi_tenant_mode_with_empty_db() { + let (db, _dir) = create_test_db().await; + let skills_dir = tempfile::tempdir().expect("skills dir"); + let installed_skills_dir = tempfile::tempdir().expect("installed skills dir"); + + let mut config = Config::for_testing( + PathBuf::from("ignored.db"), + skills_dir.path().to_path_buf(), + installed_skills_dir.path().to_path_buf(), + ); + config.owner_id = "startup-owner".to_string(); + config.agent.multi_tenant = true; + config.channels.gateway = Some(GatewayConfig { + host: "127.0.0.1".to_string(), + port: 0, + auth_token: Some("startup-token".to_string()), + max_connections: 16, + broadcast_buffer: 64, + workspace_read_scopes: Vec::new(), + memory_layers: Vec::new(), + oidc: None, + }); + + let gateway_config = config + .channels + .gateway + .clone() + .expect("gateway config missing"); + let gateway = GatewayChannel::new(gateway_config, config.owner_id.clone()) + .with_db_backing_from_config(&config, Arc::clone(&db), None) + .with_store(Arc::clone(&db)); + + let auth = + MultiAuthState::single(gateway.auth_token().to_string(), config.owner_id.clone()); + let addr = start_server( + "127.0.0.1:0".parse().expect("localhost addr"), + gateway.state().clone(), + auth.into(), + ) + .await + .expect("start gateway server"); + + let client = reqwest::Client::new(); + let response = client + .get(format!("http://{addr}/api/admin/tool-policy")) + .bearer_auth(gateway.auth_token()) + .send() + .await + .expect("request admin tool policy"); + + assert_eq!( + response.status(), + reqwest::StatusCode::OK, + "config-driven multi_tenant_mode should expose multi-tenant-only endpoints even before any users exist" + ); + + let body: serde_json::Value = response + .json() + .await + .expect("parse admin tool policy response"); + assert_eq!( + body, + serde_json::json!({ + "disabled_tools": [], + "user_disabled_tools": {} + }) + ); + } +} diff --git a/tests/multi_tenant_integration.rs b/tests/multi_tenant_integration.rs index e8023a53819..c89eba3d9b5 100644 --- a/tests/multi_tenant_integration.rs +++ b/tests/multi_tenant_integration.rs @@ -536,6 +536,7 @@ fn gateway_state_has_multi_tenant_fields() { sse: Arc::new(SseManager::new()), workspace: None, workspace_pool: None, // Multi-tenant: per-user workspace pool + multi_tenant_mode: true, session_manager: None, log_broadcaster: None, log_level_handle: None, @@ -628,6 +629,7 @@ async fn start_owner_scoped_sender_server() -> ( sse: Arc::new(SseManager::new()), workspace: None, workspace_pool: None, + multi_tenant_mode: true, session_manager: None, log_broadcaster: None, log_level_handle: None, @@ -1109,6 +1111,7 @@ async fn start_multi_user_server_with_db() -> ( sse: Arc::new(SseManager::new()), workspace: None, workspace_pool: None, + multi_tenant_mode: true, session_manager: None, log_broadcaster: None, log_level_handle: None, diff --git a/tests/oauth_greeting_integration.rs b/tests/oauth_greeting_integration.rs index 68f8d10ee8d..a9971e3e910 100644 --- a/tests/oauth_greeting_integration.rs +++ b/tests/oauth_greeting_integration.rs @@ -63,6 +63,7 @@ mod tests { sse: Arc::new(SseManager::new()), workspace: None, workspace_pool: None, + multi_tenant_mode: true, session_manager: Some(session_manager), log_broadcaster: None, log_level_handle: None, diff --git a/tests/openai_compat_integration.rs b/tests/openai_compat_integration.rs index fdd806c2648..69f91b0fc32 100644 --- a/tests/openai_compat_integration.rs +++ b/tests/openai_compat_integration.rs @@ -197,6 +197,7 @@ async fn start_test_server_with_provider( sse: Arc::new(SseManager::new()), workspace: None, workspace_pool: None, + multi_tenant_mode: false, session_manager: None, log_broadcaster: None, log_level_handle: None, @@ -719,6 +720,7 @@ async fn test_no_llm_provider_returns_503() { sse: Arc::new(SseManager::new()), workspace: None, workspace_pool: None, + multi_tenant_mode: false, session_manager: None, log_broadcaster: None, log_level_handle: None, diff --git a/tests/support/gateway_workflow_harness.rs b/tests/support/gateway_workflow_harness.rs index 0fd5e31adbd..7224f0434b1 100644 --- a/tests/support/gateway_workflow_harness.rs +++ b/tests/support/gateway_workflow_harness.rs @@ -216,6 +216,7 @@ impl GatewayWorkflowHarness { sse: Arc::new(SseManager::new()), workspace: components.workspace.clone(), workspace_pool: None, + multi_tenant_mode: false, session_manager: Some(Arc::clone(&agent_session_manager)), log_broadcaster: None, log_level_handle: None, diff --git a/tests/ws_gateway_integration.rs b/tests/ws_gateway_integration.rs index 755fb523358..9c89a40d81e 100644 --- a/tests/ws_gateway_integration.rs +++ b/tests/ws_gateway_integration.rs @@ -43,6 +43,7 @@ async fn start_test_server() -> ( sse: Arc::new(SseManager::new()), workspace: None, workspace_pool: None, + multi_tenant_mode: false, session_manager: None, log_broadcaster: None, log_level_handle: None,