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,