From 202665a55cfd1ae3c0dcad56436df3a3c25a672d Mon Sep 17 00:00:00 2001 From: Elliot Braem Date: Mon, 9 Feb 2026 18:08:59 -0600 Subject: [PATCH 01/33] Fixes build, adds missing sse event and correct command (#11) * add missing type * prune * readme * minor * update to .ironclaw --- .env.example | 6 +++--- README.md | 7 ++++--- channels-src/slack/build.sh | 2 +- channels-src/telegram/build.sh | 4 ++-- channels-src/telegram/telegram.wasm | Bin 220467 -> 246930 bytes src/channels/web/types.rs | 1 + 6 files changed, 11 insertions(+), 9 deletions(-) diff --git a/.env.example b/.env.example index 1a80fc02..e3b629ee 100644 --- a/.env.example +++ b/.env.example @@ -1,15 +1,15 @@ # Database Configuration -DATABASE_URL=postgres://ironclaw:password@localhost:5432/ironclaw +DATABASE_URL=postgres://localhost/ironclaw DATABASE_POOL_SIZE=10 # LLM Provider (NEAR AI) # NEAR AI provides a unified interface to all models with user authentication -# Session token is stored in ~/.near-agent/session.json and managed automatically. +# Session token is stored in ~/.ironclaw/session.json and managed automatically. # On first run, the agent will open a browser for OAuth authentication. NEARAI_MODEL=claude-3-5-sonnet-20241022 NEARAI_BASE_URL=https://cloud-api.near.ai NEARAI_AUTH_URL=https://private.near.ai -# NEARAI_SESSION_PATH=~/.near-agent/session.json # optional, default shown +# NEARAI_SESSION_PATH=~/.ironclaw/session.json # optional, default shown # Channel Configuration # CLI is always enabled diff --git a/README.md b/README.md index ce78526a..194b9e15 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ IronClaw is the AI assistant you can actually trust with your personal and profe ### Prerequisites - Rust 1.85+ -- PostgreSQL 15+ with pgvector extension +- PostgreSQL 15+ with [pgvector](https://github.com/pgvector/pgvector) extension - NEAR AI account (authentication handled via setup wizard) ### Build @@ -97,7 +97,7 @@ psql ironclaw -c "CREATE EXTENSION IF NOT EXISTS vector;" Run the setup wizard to configure IronClaw: ```bash -ironclaw setup +ironclaw onboard ``` The wizard handles database connection, NEAR AI authentication (via browser OAuth), @@ -191,7 +191,7 @@ External content passes through multiple security layers: ```bash # First-time setup (configures database, auth, etc.) -ironclaw setup +ironclaw onboard # Start interactive REPL cargo run @@ -210,6 +210,7 @@ cargo fmt cargo clippy --all --benches --tests --examples --all-features # Run tests +createdb ironclaw_test cargo test # Run specific test diff --git a/channels-src/slack/build.sh b/channels-src/slack/build.sh index 5b568c8d..5ab678f9 100755 --- a/channels-src/slack/build.sh +++ b/channels-src/slack/build.sh @@ -30,7 +30,7 @@ if [ -f "$WASM_PATH" ]; then wasm-tools strip slack.wasm -o slack.wasm echo "Built: slack.wasm ($(du -h slack.wasm | cut -f1))" - echo "Copy slack.wasm and slack.capabilities.json to ~/.near-agent/channels/" + echo "Copy slack.wasm and slack.capabilities.json to ~/.ironclaw/channels/" else echo "Error: WASM output not found at $WASM_PATH" exit 1 diff --git a/channels-src/telegram/build.sh b/channels-src/telegram/build.sh index 531ab762..3e4d7808 100755 --- a/channels-src/telegram/build.sh +++ b/channels-src/telegram/build.sh @@ -32,8 +32,8 @@ if [ -f "$WASM_PATH" ]; then echo "Built: telegram.wasm ($(du -h telegram.wasm | cut -f1))" echo "" echo "To install:" - echo " mkdir -p ~/.near-agent/channels" - echo " cp telegram.wasm telegram.capabilities.json ~/.near-agent/channels/" + echo " mkdir -p ~/.ironclaw/channels" + echo " cp telegram.wasm telegram.capabilities.json ~/.ironclaw/channels/" echo "" echo "Then add your bot token to secrets:" echo " # Set TELEGRAM_BOT_TOKEN in your environment or secrets store" diff --git a/channels-src/telegram/telegram.wasm b/channels-src/telegram/telegram.wasm index 14791860fd464018c7e2d9ec1bcb00c9dca95d1a..f739b982535cdeed237a67f965be5dbce43484ad 100644 GIT binary patch literal 246930 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 DcQqMy literal 220467 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
 blocks
+    html = html.replace(/
/g, '
');
+    return html;
   }
   return escapeHtml(text);
 }
 
+function copyCodeBlock(btn) {
+  const pre = btn.parentElement;
+  const code = pre.querySelector('code');
+  const text = code ? code.textContent : pre.textContent;
+  navigator.clipboard.writeText(text).then(() => {
+    btn.textContent = 'Copied!';
+    setTimeout(() => { btn.textContent = 'Copy'; }, 1500);
+  });
+}
+
 function addMessage(role, content) {
   const container = document.getElementById('chat-messages');
   const div = document.createElement('div');
@@ -268,19 +402,340 @@ function showApproval(data) {
   container.scrollTop = container.scrollHeight;
 }
 
-function loadHistory() {
-  apiFetch('/api/chat/history').then((data) => {
-    const container = document.getElementById('chat-messages');
-    container.innerHTML = '';
-    for (const turn of data.turns) {
-      addMessage('user', turn.user_input);
-      if (turn.response) {
-        addMessage('assistant', turn.response);
-      }
-    }
-  }).catch(() => {
-    // No history or no active thread, that's fine
+function showJobCard(data) {
+  const container = document.getElementById('chat-messages');
+  const card = document.createElement('div');
+  card.className = 'job-card';
+
+  const icon = document.createElement('span');
+  icon.className = 'job-card-icon';
+  icon.textContent = '\u2692';
+  card.appendChild(icon);
+
+  const info = document.createElement('div');
+  info.className = 'job-card-info';
+
+  const title = document.createElement('div');
+  title.className = 'job-card-title';
+  title.textContent = data.title || 'Sandbox Job';
+  info.appendChild(title);
+
+  const id = document.createElement('div');
+  id.className = 'job-card-id';
+  id.textContent = (data.job_id || '').substring(0, 8);
+  info.appendChild(id);
+
+  card.appendChild(info);
+
+  const viewBtn = document.createElement('button');
+  viewBtn.className = 'job-card-view';
+  viewBtn.textContent = 'View Job';
+  viewBtn.addEventListener('click', () => {
+    switchTab('jobs');
+    openJobDetail(data.job_id);
   });
+  card.appendChild(viewBtn);
+
+  if (data.browse_url) {
+    const browseBtn = document.createElement('a');
+    browseBtn.className = 'job-card-browse';
+    browseBtn.href = data.browse_url;
+    browseBtn.target = '_blank';
+    browseBtn.textContent = 'Browse';
+    card.appendChild(browseBtn);
+  }
+
+  container.appendChild(card);
+  container.scrollTop = container.scrollHeight;
+}
+
+// --- Auth card ---
+
+function showAuthCard(data) {
+  // Remove any existing card for this extension first
+  removeAuthCard(data.extension_name);
+
+  const container = document.getElementById('chat-messages');
+  const card = document.createElement('div');
+  card.className = 'auth-card';
+  card.setAttribute('data-extension-name', data.extension_name);
+
+  const header = document.createElement('div');
+  header.className = 'auth-header';
+  header.textContent = 'Authentication required for ' + data.extension_name;
+  card.appendChild(header);
+
+  if (data.instructions) {
+    const instr = document.createElement('div');
+    instr.className = 'auth-instructions';
+    instr.textContent = data.instructions;
+    card.appendChild(instr);
+  }
+
+  const links = document.createElement('div');
+  links.className = 'auth-links';
+
+  if (data.auth_url) {
+    const oauthBtn = document.createElement('button');
+    oauthBtn.className = 'auth-oauth';
+    oauthBtn.textContent = 'Authenticate with ' + data.extension_name;
+    oauthBtn.addEventListener('click', () => {
+      window.open(data.auth_url, '_blank', 'width=600,height=700');
+    });
+    links.appendChild(oauthBtn);
+  }
+
+  if (data.setup_url) {
+    const setupLink = document.createElement('a');
+    setupLink.href = data.setup_url;
+    setupLink.target = '_blank';
+    setupLink.textContent = 'Get your token';
+    links.appendChild(setupLink);
+  }
+
+  if (links.children.length > 0) {
+    card.appendChild(links);
+  }
+
+  // Token input
+  const tokenRow = document.createElement('div');
+  tokenRow.className = 'auth-token-input';
+
+  const tokenInput = document.createElement('input');
+  tokenInput.type = 'password';
+  tokenInput.placeholder = 'Paste your API key or token';
+  tokenInput.addEventListener('keydown', (e) => {
+    if (e.key === 'Enter') submitAuthToken(data.extension_name, tokenInput.value);
+  });
+  tokenRow.appendChild(tokenInput);
+  card.appendChild(tokenRow);
+
+  // Error display (hidden initially)
+  const errorEl = document.createElement('div');
+  errorEl.className = 'auth-error';
+  errorEl.style.display = 'none';
+  card.appendChild(errorEl);
+
+  // Action buttons
+  const actions = document.createElement('div');
+  actions.className = 'auth-actions';
+
+  const submitBtn = document.createElement('button');
+  submitBtn.className = 'auth-submit';
+  submitBtn.textContent = 'Submit';
+  submitBtn.addEventListener('click', () => submitAuthToken(data.extension_name, tokenInput.value));
+
+  const cancelBtn = document.createElement('button');
+  cancelBtn.className = 'auth-cancel';
+  cancelBtn.textContent = 'Cancel';
+  cancelBtn.addEventListener('click', () => cancelAuth(data.extension_name));
+
+  actions.appendChild(submitBtn);
+  actions.appendChild(cancelBtn);
+  card.appendChild(actions);
+
+  container.appendChild(card);
+  container.scrollTop = container.scrollHeight;
+  tokenInput.focus();
+}
+
+function removeAuthCard(extensionName) {
+  const card = document.querySelector('.auth-card[data-extension-name="' + extensionName + '"]');
+  if (card) card.remove();
+}
+
+function submitAuthToken(extensionName, tokenValue) {
+  if (!tokenValue || !tokenValue.trim()) return;
+
+  // Disable submit button while in flight
+  const card = document.querySelector('.auth-card[data-extension-name="' + extensionName + '"]');
+  if (card) {
+    const btns = card.querySelectorAll('button');
+    btns.forEach((b) => { b.disabled = true; });
+  }
+
+  apiFetch('/api/chat/auth-token', {
+    method: 'POST',
+    body: { extension_name: extensionName, token: tokenValue.trim() },
+  }).then((result) => {
+    if (result.success) {
+      removeAuthCard(extensionName);
+      addMessage('system', result.message);
+    } else {
+      showAuthCardError(extensionName, result.message);
+    }
+  }).catch((err) => {
+    showAuthCardError(extensionName, 'Failed: ' + err.message);
+  });
+}
+
+function cancelAuth(extensionName) {
+  apiFetch('/api/chat/auth-cancel', {
+    method: 'POST',
+    body: { extension_name: extensionName },
+  }).catch(() => {});
+  removeAuthCard(extensionName);
+  enableChatInput();
+}
+
+function showAuthCardError(extensionName, message) {
+  const card = document.querySelector('.auth-card[data-extension-name="' + extensionName + '"]');
+  if (!card) return;
+  // Re-enable buttons
+  const btns = card.querySelectorAll('button');
+  btns.forEach((b) => { b.disabled = false; });
+  // Show error
+  const errorEl = card.querySelector('.auth-error');
+  if (errorEl) {
+    errorEl.textContent = message;
+    errorEl.style.display = 'block';
+  }
+}
+
+function loadHistory(before) {
+  let historyUrl = '/api/chat/history?limit=50';
+  if (currentThreadId) {
+    historyUrl += '&thread_id=' + encodeURIComponent(currentThreadId);
+  }
+  if (before) {
+    historyUrl += '&before=' + encodeURIComponent(before);
+  }
+
+  const isPaginating = !!before;
+  if (isPaginating) loadingOlder = true;
+
+  apiFetch(historyUrl).then((data) => {
+    const container = document.getElementById('chat-messages');
+
+    if (!isPaginating) {
+      // Fresh load: clear and render
+      container.innerHTML = '';
+      for (const turn of data.turns) {
+        addMessage('user', turn.user_input);
+        if (turn.response) {
+          addMessage('assistant', turn.response);
+        }
+      }
+    } else {
+      // Pagination: prepend older messages
+      const savedHeight = container.scrollHeight;
+      const fragment = document.createDocumentFragment();
+      for (const turn of data.turns) {
+        const userDiv = createMessageElement('user', turn.user_input);
+        fragment.appendChild(userDiv);
+        if (turn.response) {
+          const assistantDiv = createMessageElement('assistant', turn.response);
+          fragment.appendChild(assistantDiv);
+        }
+      }
+      container.insertBefore(fragment, container.firstChild);
+      // Restore scroll position so the user doesn't jump
+      container.scrollTop = container.scrollHeight - savedHeight;
+    }
+
+    hasMore = data.has_more || false;
+    oldestTimestamp = data.oldest_timestamp || null;
+  }).catch(() => {
+    // No history or no active thread
+  }).finally(() => {
+    loadingOlder = false;
+    removeScrollSpinner();
+  });
+}
+
+// Create a message DOM element without appending it (for prepend operations)
+function createMessageElement(role, content) {
+  const div = document.createElement('div');
+  div.className = 'message ' + role;
+  if (role === 'user') {
+    div.textContent = content;
+  } else {
+    div.setAttribute('data-raw', content);
+    div.innerHTML = renderMarkdown(content);
+  }
+  return div;
+}
+
+function removeScrollSpinner() {
+  const spinner = document.getElementById('scroll-load-spinner');
+  if (spinner) spinner.remove();
+}
+
+// --- Threads ---
+
+function loadThreads() {
+  apiFetch('/api/chat/threads').then((data) => {
+    // Pinned assistant thread
+    if (data.assistant_thread) {
+      assistantThreadId = data.assistant_thread.id;
+      const el = document.getElementById('assistant-thread');
+      const isActive = currentThreadId === assistantThreadId;
+      el.className = 'assistant-item' + (isActive ? ' active' : '');
+      const meta = document.getElementById('assistant-meta');
+      const count = data.assistant_thread.turn_count || 0;
+      meta.textContent = count > 0 ? count + ' turns' : '';
+    }
+
+    // Regular threads
+    const list = document.getElementById('thread-list');
+    list.innerHTML = '';
+    const threads = data.threads || [];
+    for (const thread of threads) {
+      const item = document.createElement('div');
+      item.className = 'thread-item' + (thread.id === currentThreadId ? ' active' : '');
+      const label = document.createElement('span');
+      label.className = 'thread-label';
+      label.textContent = thread.title || thread.id.substring(0, 8);
+      label.title = thread.title ? thread.title + ' (' + thread.id + ')' : thread.id;
+      item.appendChild(label);
+      const meta = document.createElement('span');
+      meta.className = 'thread-meta';
+      meta.textContent = (thread.turn_count || 0) + ' turns';
+      item.appendChild(meta);
+      item.addEventListener('click', () => switchThread(thread.id));
+      list.appendChild(item);
+    }
+
+    // Default to assistant thread on first load if no thread selected
+    if (!currentThreadId && assistantThreadId) {
+      switchToAssistant();
+    }
+  }).catch(() => {});
+}
+
+function switchToAssistant() {
+  if (!assistantThreadId) return;
+  currentThreadId = assistantThreadId;
+  hasMore = false;
+  oldestTimestamp = null;
+  loadHistory();
+  loadThreads();
+}
+
+function switchThread(threadId) {
+  currentThreadId = threadId;
+  hasMore = false;
+  oldestTimestamp = null;
+  loadHistory();
+  loadThreads();
+}
+
+function createNewThread() {
+  apiFetch('/api/chat/thread/new', { method: 'POST' }).then((data) => {
+    currentThreadId = data.id || null;
+    document.getElementById('chat-messages').innerHTML = '';
+    setStatus('');
+    loadThreads();
+  }).catch((err) => {
+    showToast('Failed to create thread: ' + err.message, 'error');
+  });
+}
+
+function toggleThreadSidebar() {
+  const sidebar = document.getElementById('thread-sidebar');
+  sidebar.classList.toggle('collapsed');
+  const btn = document.getElementById('thread-toggle-btn');
+  btn.innerHTML = sidebar.classList.contains('collapsed') ? '»' : '«';
 }
 
 // Chat input auto-resize and keyboard handling
@@ -293,6 +748,20 @@ chatInput.addEventListener('keydown', (e) => {
 });
 chatInput.addEventListener('input', () => autoResizeTextarea(chatInput));
 
+// Infinite scroll: load older messages when scrolled near the top
+document.getElementById('chat-messages').addEventListener('scroll', function () {
+  if (this.scrollTop < 100 && hasMore && !loadingOlder) {
+    loadingOlder = true;
+    // Show spinner at top
+    const spinner = document.createElement('div');
+    spinner.id = 'scroll-load-spinner';
+    spinner.className = 'scroll-load-spinner';
+    spinner.innerHTML = '
Loading older messages...'; + this.insertBefore(spinner, this.firstChild); + loadHistory(oldestTimestamp); + } +}); + function autoResizeTextarea(el) { el.style.height = 'auto'; el.style.height = Math.min(el.scrollHeight, 120) + 'px'; @@ -318,12 +787,16 @@ function switchTab(tab) { if (tab === 'memory') loadMemoryTree(); if (tab === 'jobs') loadJobs(); + if (tab === 'routines') loadRoutines(); + if (tab === 'logs') applyLogFilters(); if (tab === 'extensions') loadExtensions(); } // --- Memory (filesystem tree) --- let memorySearchTimeout = null; +let currentMemoryPath = null; +let currentMemoryContent = null; // Tree state: nested nodes persisted across renders // { name, path, is_dir, children: [] | null, expanded: bool, loaded: bool } let memoryTreeState = null; @@ -437,16 +910,61 @@ function toggleExpand(node) { } function readMemoryFile(path) { + currentMemoryPath = path; // Update breadcrumb - document.getElementById('memory-breadcrumb').innerHTML = buildBreadcrumb(path); + document.getElementById('memory-breadcrumb-path').innerHTML = buildBreadcrumb(path); + document.getElementById('memory-edit-btn').style.display = 'inline-block'; + + // Exit edit mode if active + cancelMemoryEdit(); apiFetch('/api/memory/read?path=' + encodeURIComponent(path)).then((data) => { - document.getElementById('memory-viewer').textContent = data.content; + currentMemoryContent = data.content; + const viewer = document.getElementById('memory-viewer'); + // Render markdown if it's a .md file + if (path.endsWith('.md')) { + viewer.innerHTML = '
' + renderMarkdown(data.content) + '
'; + viewer.classList.add('rendered'); + } else { + viewer.textContent = data.content; + viewer.classList.remove('rendered'); + } }).catch((err) => { + currentMemoryContent = null; document.getElementById('memory-viewer').innerHTML = '
Error: ' + escapeHtml(err.message) + '
'; }); } +function startMemoryEdit() { + if (!currentMemoryPath || currentMemoryContent === null) return; + document.getElementById('memory-viewer').style.display = 'none'; + const editor = document.getElementById('memory-editor'); + editor.style.display = 'flex'; + const textarea = document.getElementById('memory-edit-textarea'); + textarea.value = currentMemoryContent; + textarea.focus(); +} + +function cancelMemoryEdit() { + document.getElementById('memory-viewer').style.display = ''; + document.getElementById('memory-editor').style.display = 'none'; +} + +function saveMemoryEdit() { + if (!currentMemoryPath) return; + const content = document.getElementById('memory-edit-textarea').value; + apiFetch('/api/memory/write', { + method: 'POST', + body: { path: currentMemoryPath, content: content }, + }).then(() => { + showToast('Saved ' + currentMemoryPath, 'success'); + cancelMemoryEdit(); + readMemoryFile(currentMemoryPath); + }).catch((err) => { + showToast('Save failed: ' + err.message, 'error'); + }); +} + function buildBreadcrumb(path) { const parts = path.split('/'); let html = '
workspace'; @@ -472,14 +990,35 @@ function searchMemory(query) { for (const result of data.results) { const item = document.createElement('div'); item.className = 'search-result'; + const snippet = snippetAround(result.content, query, 120); item.innerHTML = '
' + escapeHtml(result.path) + '
' - + '
' + escapeHtml(result.content.substring(0, 120)) + '
'; + + '
' + highlightQuery(snippet, query) + '
'; item.addEventListener('click', () => readMemoryFile(result.path)); tree.appendChild(item); } }).catch(() => {}); } +function snippetAround(text, query, len) { + const lower = text.toLowerCase(); + const idx = lower.indexOf(query.toLowerCase()); + if (idx < 0) return text.substring(0, len); + const start = Math.max(0, idx - Math.floor(len / 2)); + const end = Math.min(text.length, start + len); + let s = text.substring(start, end); + if (start > 0) s = '...' + s; + if (end < text.length) s = s + '...'; + return s; +} + +function highlightQuery(text, query) { + if (!query) return escapeHtml(text); + const escaped = escapeHtml(text); + const queryEscaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const re = new RegExp('(' + queryEscaped + ')', 'gi'); + return escaped.replace(re, '$1'); +} + // --- Logs --- const LOG_MAX_ENTRIES = 2000; @@ -574,6 +1113,7 @@ function toggleLogsPause() { } function clearLogs() { + if (!confirm('Clear all logs?')) return; document.getElementById('logs-output').innerHTML = ''; logBuffer = []; } @@ -709,39 +1249,53 @@ function activateExtension(name) { } if (res.auth_url) { - addMessage( - 'system', - 'Opening authentication for **' + name + '**. Complete the flow in the opened tab, then click Activate again.' - ); + showToast('Opening authentication for ' + name, 'info'); window.open(res.auth_url, '_blank'); } else if (res.awaiting_token) { - addMessage( - 'system', - (res.instructions || 'Please provide an API token for **' + name + '**.') + - '\n\nYou can authenticate via chat: type `Authenticate ' + name + '` and follow the instructions.' - ); + showToast(res.instructions || 'Please provide an API token for ' + name, 'info'); } else { - addMessage('system', 'Activate failed: ' + res.message); + showToast('Activate failed: ' + res.message, 'error'); } loadExtensions(); }) - .catch((err) => addMessage('system', 'Activate failed: ' + err.message)); + .catch((err) => showToast('Activate failed: ' + err.message, 'error')); } function removeExtension(name) { + if (!confirm('Remove extension "' + name + '"?')) return; apiFetch('/api/extensions/' + encodeURIComponent(name) + '/remove', { method: 'POST' }) .then((res) => { if (!res.success) { - addMessage('system', 'Remove failed: ' + res.message); + showToast('Remove failed: ' + res.message, 'error'); + } else { + showToast('Removed ' + name, 'success'); } loadExtensions(); }) - .catch((err) => addMessage('system', 'Remove failed: ' + err.message)); + .catch((err) => showToast('Remove failed: ' + err.message, 'error')); } // --- Jobs --- +let currentJobId = null; +let currentJobSubTab = 'overview'; +let jobFilesTreeState = null; + function loadJobs() { + currentJobId = null; + jobFilesTreeState = null; + + // Rebuild DOM if renderJobDetail() destroyed it (it wipes .jobs-container innerHTML). + const container = document.querySelector('.jobs-container'); + if (!document.getElementById('jobs-summary')) { + container.innerHTML = + '
' + + '' + + '' + + '
IDTitleStatusCreatedActions
' + + ''; + } + Promise.all([ apiFetch('/api/jobs/summary'), apiFetch('/api/jobs'), @@ -781,27 +1335,792 @@ function renderJobsList(jobs) { tbody.innerHTML = jobs.map((job) => { const shortId = job.id.substring(0, 8); const stateClass = job.state.replace(' ', '_'); - const cancelBtn = (job.state === 'pending' || job.state === 'in_progress') - ? '' - : ''; - return '' + + let actionBtns = ''; + if (job.state === 'pending' || job.state === 'in_progress') { + actionBtns = ''; + } else if (job.state === 'failed' || job.state === 'interrupted') { + actionBtns = ''; + } + + return '' + '' + shortId + '' + '' + escapeHtml(job.title) + '' + '' + escapeHtml(job.state) + '' + '' + formatDate(job.created_at) + '' - + '' + cancelBtn + '' + + '' + actionBtns + '' + ''; }).join(''); } function cancelJob(jobId) { + if (!confirm('Cancel this job?')) return; apiFetch('/api/jobs/' + jobId + '/cancel', { method: 'POST' }) - .then(() => loadJobs()) + .then(() => { + showToast('Job cancelled', 'success'); + if (currentJobId) openJobDetail(currentJobId); + else loadJobs(); + }) .catch((err) => { - addMessage('system', 'Failed to cancel job: ' + err.message); + showToast('Failed to cancel job: ' + err.message, 'error'); }); } +function restartJob(jobId) { + apiFetch('/api/jobs/' + jobId + '/restart', { method: 'POST' }) + .then((res) => { + showToast('Job restarted as ' + (res.new_job_id || '').substring(0, 8), 'success'); + loadJobs(); + }) + .catch((err) => { + showToast('Failed to restart job: ' + err.message, 'error'); + }); +} + +function openJobDetail(jobId) { + currentJobId = jobId; + currentJobSubTab = 'activity'; + apiFetch('/api/jobs/' + jobId).then((job) => { + renderJobDetail(job); + }).catch((err) => { + addMessage('system', 'Failed to load job: ' + err.message); + closeJobDetail(); + }); +} + +function closeJobDetail() { + currentJobId = null; + jobFilesTreeState = null; + loadJobs(); +} + +function renderJobDetail(job) { + const container = document.querySelector('.jobs-container'); + const stateClass = job.state.replace(' ', '_'); + + container.innerHTML = ''; + + // Header + const header = document.createElement('div'); + header.className = 'job-detail-header'; + + let headerHtml = '' + + '

' + escapeHtml(job.title) + '

' + + '' + escapeHtml(job.state) + ''; + + if (job.state === 'failed' || job.state === 'interrupted') { + headerHtml += ''; + } + if (job.browse_url) { + headerHtml += 'Browse Files'; + } + + header.innerHTML = headerHtml; + container.appendChild(header); + + // Sub-tab bar + const tabs = document.createElement('div'); + tabs.className = 'job-detail-tabs'; + const subtabs = ['overview', 'activity', 'files']; + for (const st of subtabs) { + const btn = document.createElement('button'); + btn.textContent = st.charAt(0).toUpperCase() + st.slice(1); + btn.className = st === currentJobSubTab ? 'active' : ''; + btn.addEventListener('click', () => { + currentJobSubTab = st; + renderJobDetail(job); + }); + tabs.appendChild(btn); + } + container.appendChild(tabs); + + // Content + const content = document.createElement('div'); + content.className = 'job-detail-content'; + container.appendChild(content); + + switch (currentJobSubTab) { + case 'overview': renderJobOverview(content, job); break; + case 'files': renderJobFiles(content, job); break; + case 'activity': renderJobActivity(content, job); break; + } +} + +function metaItem(label, value) { + return '
' + escapeHtml(label) + + '
' + escapeHtml(String(value != null ? value : '-')) + + '
'; +} + +function formatDuration(secs) { + if (secs == null) return '-'; + if (secs < 60) return secs + 's'; + const m = Math.floor(secs / 60); + const s = secs % 60; + if (m < 60) return m + 'm ' + s + 's'; + const h = Math.floor(m / 60); + return h + 'h ' + (m % 60) + 'm'; +} + +function renderJobOverview(container, job) { + // Metadata grid + const grid = document.createElement('div'); + grid.className = 'job-meta-grid'; + grid.innerHTML = metaItem('Job ID', job.id) + + metaItem('State', job.state) + + metaItem('Created', formatDate(job.created_at)) + + metaItem('Started', formatDate(job.started_at)) + + metaItem('Completed', formatDate(job.completed_at)) + + metaItem('Duration', formatDuration(job.elapsed_secs)) + + (job.job_mode ? metaItem('Mode', job.job_mode) : ''); + container.appendChild(grid); + + // Description + if (job.description) { + const descSection = document.createElement('div'); + descSection.className = 'job-description'; + const descHeader = document.createElement('h3'); + descHeader.textContent = 'Description'; + descSection.appendChild(descHeader); + const descBody = document.createElement('div'); + descBody.className = 'job-description-body'; + descBody.innerHTML = renderMarkdown(job.description); + descSection.appendChild(descBody); + container.appendChild(descSection); + } + + // State transitions timeline + if (job.transitions.length > 0) { + const timelineSection = document.createElement('div'); + timelineSection.className = 'job-timeline-section'; + const tlHeader = document.createElement('h3'); + tlHeader.textContent = 'State Transitions'; + timelineSection.appendChild(tlHeader); + + const timeline = document.createElement('div'); + timeline.className = 'timeline'; + for (const t of job.transitions) { + const entry = document.createElement('div'); + entry.className = 'timeline-entry'; + const dot = document.createElement('div'); + dot.className = 'timeline-dot'; + entry.appendChild(dot); + const info = document.createElement('div'); + info.className = 'timeline-info'; + info.innerHTML = '' + escapeHtml(t.from) + '' + + ' → ' + + '' + escapeHtml(t.to) + '' + + '' + formatDate(t.timestamp) + '' + + (t.reason ? '
' + escapeHtml(t.reason) + '
' : ''); + entry.appendChild(info); + timeline.appendChild(entry); + } + timelineSection.appendChild(timeline); + container.appendChild(timelineSection); + } +} + +function renderJobFiles(container, job) { + container.innerHTML = '
' + + '
' + + '
Select a file to view
' + + '
'; + + container._jobId = job ? job.id : null; + + apiFetch('/api/jobs/' + job.id + '/files/list?path=').then((data) => { + jobFilesTreeState = data.entries.map((e) => ({ + name: e.name, + path: e.path, + is_dir: e.is_dir, + children: e.is_dir ? null : undefined, + expanded: false, + loaded: false, + })); + renderJobFilesTree(); + }).catch(() => { + const treeContainer = document.querySelector('.job-files-tree'); + if (treeContainer) { + treeContainer.innerHTML = '
No project files
'; + } + }); +} + +function renderJobFilesTree() { + const treeContainer = document.querySelector('.job-files-tree'); + if (!treeContainer) return; + treeContainer.innerHTML = ''; + if (!jobFilesTreeState || jobFilesTreeState.length === 0) { + treeContainer.innerHTML = '
No files in workspace
'; + return; + } + renderJobFileNodes(jobFilesTreeState, treeContainer, 0); +} + +function renderJobFileNodes(nodes, container, depth) { + for (const node of nodes) { + const row = document.createElement('div'); + row.className = 'tree-row'; + row.style.paddingLeft = (depth * 16 + 8) + 'px'; + + if (node.is_dir) { + const arrow = document.createElement('span'); + arrow.className = 'expand-arrow' + (node.expanded ? ' expanded' : ''); + arrow.textContent = '\u25B6'; + arrow.addEventListener('click', (e) => { + e.stopPropagation(); + toggleJobFileExpand(node); + }); + row.appendChild(arrow); + + const label = document.createElement('span'); + label.className = 'tree-label dir'; + label.textContent = node.name; + label.addEventListener('click', () => toggleJobFileExpand(node)); + row.appendChild(label); + } else { + const spacer = document.createElement('span'); + spacer.className = 'expand-arrow-spacer'; + row.appendChild(spacer); + + const label = document.createElement('span'); + label.className = 'tree-label file'; + label.textContent = node.name; + label.addEventListener('click', () => readJobFile(node.path)); + row.appendChild(label); + } + + container.appendChild(row); + + if (node.is_dir && node.expanded && node.children) { + const childContainer = document.createElement('div'); + childContainer.className = 'tree-children'; + renderJobFileNodes(node.children, childContainer, depth + 1); + container.appendChild(childContainer); + } + } +} + +function getJobId() { + const container = document.querySelector('.job-detail-content'); + return (container && container._jobId) || null; +} + +function toggleJobFileExpand(node) { + if (node.expanded) { + node.expanded = false; + renderJobFilesTree(); + return; + } + if (node.loaded) { + node.expanded = true; + renderJobFilesTree(); + return; + } + const jobId = getJobId(); + apiFetch('/api/jobs/' + jobId + '/files/list?path=' + encodeURIComponent(node.path)).then((data) => { + node.children = data.entries.map((e) => ({ + name: e.name, + path: e.path, + is_dir: e.is_dir, + children: e.is_dir ? null : undefined, + expanded: false, + loaded: false, + })); + node.loaded = true; + node.expanded = true; + renderJobFilesTree(); + }).catch(() => {}); +} + +function readJobFile(path) { + const viewer = document.querySelector('.job-files-viewer'); + if (!viewer) return; + const jobId = getJobId(); + apiFetch('/api/jobs/' + jobId + '/files/read?path=' + encodeURIComponent(path)).then((data) => { + viewer.innerHTML = '
' + escapeHtml(path) + '
' + + '
' + escapeHtml(data.content) + '
'; + }).catch((err) => { + viewer.innerHTML = '
Error: ' + escapeHtml(err.message) + '
'; + }); +} + +// --- Activity tab (unified for all sandbox jobs) --- + +let activityCurrentJobId = null; +// Track how many live SSE events we've already rendered so refreshActivityTab +// only appends new ones (avoids duplicates on each SSE tick). +let activityRenderedLiveIndex = 0; + +function renderJobActivity(container, job) { + activityCurrentJobId = job ? job.id : null; + activityRenderedLiveIndex = 0; + + container.innerHTML = '
' + + '' + + '' + + '
' + + '
' + + '
' + + '' + + '' + + '' + + '
'; + + document.getElementById('activity-type-filter').addEventListener('change', applyActivityFilter); + + const terminal = document.getElementById('activity-terminal'); + const input = document.getElementById('activity-prompt-input'); + const sendBtn = document.getElementById('activity-send-btn'); + const doneBtn = document.getElementById('activity-done-btn'); + + sendBtn.addEventListener('click', () => sendJobPrompt(job.id, false)); + doneBtn.addEventListener('click', () => sendJobPrompt(job.id, true)); + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') sendJobPrompt(job.id, false); + }); + + // Load persisted events from DB, then catch up with any live SSE events + apiFetch('/api/jobs/' + job.id + '/events').then((data) => { + if (data.events && data.events.length > 0) { + for (const evt of data.events) { + appendActivityEvent(terminal, evt.event_type, evt.data); + } + } + appendNewLiveEvents(terminal, job.id); + }).catch(() => { + appendNewLiveEvents(terminal, job.id); + }); +} + +function appendNewLiveEvents(terminal, jobId) { + const live = jobEvents.get(jobId) || []; + for (let i = activityRenderedLiveIndex; i < live.length; i++) { + const evt = live[i]; + appendActivityEvent(terminal, evt.type.replace('job_', ''), evt.data); + } + activityRenderedLiveIndex = live.length; + const autoScroll = document.getElementById('activity-autoscroll'); + if (!autoScroll || autoScroll.checked) { + terminal.scrollTop = terminal.scrollHeight; + } +} + +function applyActivityFilter() { + const filter = document.getElementById('activity-type-filter').value; + const events = document.querySelectorAll('#activity-terminal .activity-event'); + for (const el of events) { + if (filter === 'all') { + el.style.display = ''; + } else { + el.style.display = el.getAttribute('data-event-type') === filter ? '' : 'none'; + } + } +} + +function appendActivityEvent(terminal, eventType, data) { + if (!terminal) return; + const el = document.createElement('div'); + el.className = 'activity-event activity-event-' + eventType; + el.setAttribute('data-event-type', eventType); + + // Respect current filter + const filterEl = document.getElementById('activity-type-filter'); + if (filterEl && filterEl.value !== 'all' && filterEl.value !== eventType) { + el.style.display = 'none'; + } + + switch (eventType) { + case 'message': + el.innerHTML = '' + escapeHtml(data.role || 'assistant') + ' ' + + '' + escapeHtml(data.content || '') + ''; + break; + case 'tool_use': + el.innerHTML = '
' + + ' ' + + escapeHtml(data.tool_name || 'tool') + + '
'
+        + escapeHtml(typeof data.input === 'string' ? data.input : JSON.stringify(data.input, null, 2))
+        + '
'; + break; + case 'tool_result': + el.innerHTML = '
' + + ' ' + + escapeHtml(data.tool_name || 'result') + + '
'
+        + escapeHtml(data.output || '')
+        + '
'; + break; + case 'status': + el.innerHTML = '' + escapeHtml(data.message || '') + ''; + break; + case 'result': + el.className += ' activity-final'; + const success = data.success !== false; + el.innerHTML = '' + + escapeHtml(data.message || data.status || 'done') + ''; + if (data.session_id) { + el.innerHTML += ' session: ' + escapeHtml(data.session_id) + ''; + } + break; + default: + el.innerHTML = '' + escapeHtml(JSON.stringify(data)) + ''; + } + + terminal.appendChild(el); +} + +function refreshActivityTab(jobId) { + if (activityCurrentJobId !== jobId) return; + if (currentJobSubTab !== 'activity') return; + const terminal = document.getElementById('activity-terminal'); + if (!terminal) return; + appendNewLiveEvents(terminal, jobId); +} + +function sendJobPrompt(jobId, done) { + const input = document.getElementById('activity-prompt-input'); + const content = input ? input.value.trim() : ''; + if (!content && !done) return; + + apiFetch('/api/jobs/' + jobId + '/prompt', { + method: 'POST', + body: { content: content || '(done)', done: done }, + }).then(() => { + if (input) input.value = ''; + if (done) { + const bar = document.getElementById('activity-input-bar'); + if (bar) bar.innerHTML = 'Done signal sent'; + } + }).catch((err) => { + const terminal = document.getElementById('activity-terminal'); + if (terminal) { + appendActivityEvent(terminal, 'status', { message: 'Failed to send: ' + err.message }); + } + }); +} + +// --- Routines --- + +let currentRoutineId = null; + +function loadRoutines() { + currentRoutineId = null; + + // Restore list view if detail was open + const detail = document.getElementById('routine-detail'); + if (detail) detail.style.display = 'none'; + const table = document.getElementById('routines-table'); + if (table) table.style.display = ''; + + Promise.all([ + apiFetch('/api/routines/summary'), + apiFetch('/api/routines'), + ]).then(([summary, listData]) => { + renderRoutinesSummary(summary); + renderRoutinesList(listData.routines); + }).catch(() => {}); +} + +function renderRoutinesSummary(s) { + document.getElementById('routines-summary').innerHTML = '' + + summaryCard('Total', s.total, '') + + summaryCard('Enabled', s.enabled, 'active') + + summaryCard('Disabled', s.disabled, '') + + summaryCard('Failing', s.failing, 'failed') + + summaryCard('Runs Today', s.runs_today, 'completed'); +} + +function renderRoutinesList(routines) { + const tbody = document.getElementById('routines-tbody'); + const empty = document.getElementById('routines-empty'); + + if (!routines || routines.length === 0) { + tbody.innerHTML = ''; + empty.style.display = 'block'; + return; + } + + empty.style.display = 'none'; + tbody.innerHTML = routines.map((r) => { + const statusClass = r.status === 'active' ? 'completed' + : r.status === 'failing' ? 'failed' + : 'pending'; + + const toggleLabel = r.enabled ? 'Disable' : 'Enable'; + const toggleClass = r.enabled ? 'btn-cancel' : 'btn-restart'; + + return '' + + '' + escapeHtml(r.name) + '' + + '' + escapeHtml(r.trigger_summary) + '' + + '' + escapeHtml(r.action_type) + '' + + '' + formatRelativeTime(r.last_run_at) + '' + + '' + formatRelativeTime(r.next_fire_at) + '' + + '' + r.run_count + '' + + '' + escapeHtml(r.status) + '' + + '' + + ' ' + + ' ' + + '' + + '' + + ''; + }).join(''); +} + +function openRoutineDetail(id) { + currentRoutineId = id; + apiFetch('/api/routines/' + id).then((routine) => { + renderRoutineDetail(routine); + }).catch((err) => { + showToast('Failed to load routine: ' + err.message, 'error'); + }); +} + +function closeRoutineDetail() { + currentRoutineId = null; + loadRoutines(); +} + +function renderRoutineDetail(routine) { + const table = document.getElementById('routines-table'); + if (table) table.style.display = 'none'; + document.getElementById('routines-empty').style.display = 'none'; + + const detail = document.getElementById('routine-detail'); + detail.style.display = 'block'; + + const statusClass = !routine.enabled ? 'pending' + : routine.consecutive_failures > 0 ? 'failed' + : 'completed'; + const statusLabel = !routine.enabled ? 'disabled' + : routine.consecutive_failures > 0 ? 'failing' + : 'active'; + + let html = '
' + + '' + + '

' + escapeHtml(routine.name) + '

' + + '' + escapeHtml(statusLabel) + '' + + '
'; + + // Metadata grid + html += '
' + + metaItem('Routine ID', routine.id) + + metaItem('Enabled', routine.enabled ? 'Yes' : 'No') + + metaItem('Run Count', routine.run_count) + + metaItem('Failures', routine.consecutive_failures) + + metaItem('Last Run', formatDate(routine.last_run_at)) + + metaItem('Next Fire', formatDate(routine.next_fire_at)) + + metaItem('Created', formatDate(routine.created_at)) + + '
'; + + // Description + if (routine.description) { + html += '

Description

' + + '
' + escapeHtml(routine.description) + '
'; + } + + // Trigger config + html += '

Trigger

' + + '
' + escapeHtml(JSON.stringify(routine.trigger, null, 2)) + '
'; + + // Action config + html += '

Action

' + + '
' + escapeHtml(JSON.stringify(routine.action, null, 2)) + '
'; + + // Recent runs + if (routine.recent_runs && routine.recent_runs.length > 0) { + html += '

Recent Runs

' + + '' + + '' + + ''; + for (const run of routine.recent_runs) { + const runStatusClass = run.status === 'Ok' ? 'completed' + : run.status === 'Failed' ? 'failed' + : run.status === 'Attention' ? 'stuck' + : 'in_progress'; + html += '' + + '' + + '' + + '' + + '' + + '' + + '' + + ''; + } + html += '
TriggerStartedCompletedStatusSummaryTokens
' + escapeHtml(run.trigger_type) + '' + formatDate(run.started_at) + '' + formatDate(run.completed_at) + '' + escapeHtml(run.status) + '' + escapeHtml(run.result_summary || '-') + '' + (run.tokens_used != null ? run.tokens_used : '-') + '
'; + } + + detail.innerHTML = html; +} + +function triggerRoutine(id) { + apiFetch('/api/routines/' + id + '/trigger', { method: 'POST' }) + .then(() => showToast('Routine triggered', 'success')) + .catch((err) => showToast('Trigger failed: ' + err.message, 'error')); +} + +function toggleRoutine(id) { + apiFetch('/api/routines/' + id + '/toggle', { method: 'POST' }) + .then((res) => { + showToast('Routine ' + (res.status || 'toggled'), 'success'); + if (currentRoutineId) openRoutineDetail(currentRoutineId); + else loadRoutines(); + }) + .catch((err) => showToast('Toggle failed: ' + err.message, 'error')); +} + +function deleteRoutine(id, name) { + if (!confirm('Delete routine "' + name + '"?')) return; + apiFetch('/api/routines/' + id, { method: 'DELETE' }) + .then(() => { + showToast('Routine deleted', 'success'); + if (currentRoutineId === id) closeRoutineDetail(); + else loadRoutines(); + }) + .catch((err) => showToast('Delete failed: ' + err.message, 'error')); +} + +function formatRelativeTime(isoString) { + if (!isoString) return '-'; + const d = new Date(isoString); + const now = Date.now(); + const diffMs = now - d.getTime(); + const absDiff = Math.abs(diffMs); + const future = diffMs < 0; + + if (absDiff < 60000) return future ? 'in <1m' : '<1m ago'; + if (absDiff < 3600000) { + const m = Math.floor(absDiff / 60000); + return future ? 'in ' + m + 'm' : m + 'm ago'; + } + if (absDiff < 86400000) { + const h = Math.floor(absDiff / 3600000); + return future ? 'in ' + h + 'h' : h + 'h ago'; + } + const days = Math.floor(absDiff / 86400000); + return future ? 'in ' + days + 'd' : days + 'd ago'; +} + +// --- Gateway status widget --- + +let gatewayStatusInterval = null; + +function startGatewayStatusPolling() { + fetchGatewayStatus(); + gatewayStatusInterval = setInterval(fetchGatewayStatus, 30000); +} + +function fetchGatewayStatus() { + apiFetch('/api/gateway/status').then((data) => { + const popover = document.getElementById('gateway-popover'); + popover.innerHTML = '
SSE clients' + (data.sse_clients || 0) + '
' + + '
Log clients' + (data.log_clients || 0) + '
' + + '
Uptime' + formatDuration(data.uptime_secs) + '
'; + }).catch(() => {}); +} + +// Show/hide popover on hover +document.getElementById('gateway-status-trigger').addEventListener('mouseenter', () => { + document.getElementById('gateway-popover').classList.add('visible'); +}); +document.getElementById('gateway-status-trigger').addEventListener('mouseleave', () => { + document.getElementById('gateway-popover').classList.remove('visible'); +}); + +// --- Extension install --- + +function installExtension() { + const name = document.getElementById('ext-install-name').value.trim(); + if (!name) { + showToast('Extension name is required', 'error'); + return; + } + const url = document.getElementById('ext-install-url').value.trim(); + const kind = document.getElementById('ext-install-kind').value; + + apiFetch('/api/extensions/install', { + method: 'POST', + body: { name, url: url || undefined, kind }, + }).then((res) => { + if (res.success) { + showToast('Installed ' + name, 'success'); + document.getElementById('ext-install-name').value = ''; + document.getElementById('ext-install-url').value = ''; + loadExtensions(); + } else { + showToast('Install failed: ' + (res.message || 'unknown error'), 'error'); + } + }).catch((err) => { + showToast('Install failed: ' + err.message, 'error'); + }); +} + +// --- Keyboard shortcuts --- + +document.addEventListener('keydown', (e) => { + const mod = e.metaKey || e.ctrlKey; + const tag = (e.target.tagName || '').toLowerCase(); + const inInput = tag === 'input' || tag === 'textarea'; + + // Mod+1-6: switch tabs + if (mod && e.key >= '1' && e.key <= '6') { + e.preventDefault(); + const tabs = ['chat', 'memory', 'jobs', 'routines', 'logs', 'extensions']; + const idx = parseInt(e.key) - 1; + if (tabs[idx]) switchTab(tabs[idx]); + return; + } + + // Mod+K: focus chat input or memory search + if (mod && e.key === 'k') { + e.preventDefault(); + if (currentTab === 'memory') { + document.getElementById('memory-search').focus(); + } else { + document.getElementById('chat-input').focus(); + } + return; + } + + // Mod+N: new thread + if (mod && e.key === 'n' && currentTab === 'chat') { + e.preventDefault(); + createNewThread(); + return; + } + + // Escape: close job detail or blur input + if (e.key === 'Escape') { + if (currentJobId) { + closeJobDetail(); + } else if (inInput) { + e.target.blur(); + } + return; + } +}); + +// --- Toasts --- + +function showToast(message, type) { + const container = document.getElementById('toasts'); + const toast = document.createElement('div'); + toast.className = 'toast toast-' + (type || 'info'); + toast.textContent = message; + container.appendChild(toast); + // Trigger slide-in + requestAnimationFrame(() => toast.classList.add('visible')); + setTimeout(() => { + toast.classList.remove('visible'); + toast.addEventListener('transitionend', () => toast.remove()); + }, 4000); +} + // --- Utilities --- function escapeHtml(str) { diff --git a/src/channels/web/static/index.html b/src/channels/web/static/index.html index 8ae5be51..bf6c227c 100644 --- a/src/channels/web/static/index.html +++ b/src/channels/web/static/index.html @@ -10,12 +10,19 @@
-

IronClaw

-
- - + -
@@ -26,16 +33,33 @@ +
-
+
Connected +
+
+
+ Threads + + +
+
+ Assistant + +
+
+ Conversations +
+
+
@@ -56,10 +80,20 @@
-
workspace /
+
+ workspace / + +
Select a file to view its contents
+
@@ -73,6 +107,7 @@ ID Title + Source Status Created Actions @@ -104,9 +139,48 @@ + +
+
+
+ + + + + + + + + + + + + + +
NameTriggerActionLast RunNext RunRunsStatusActions
+ + +
+
+
+
+

Install Extension

+
+ + + + +
+

Installed Extensions

@@ -130,6 +204,7 @@
+
diff --git a/src/channels/web/static/style.css b/src/channels/web/static/style.css index bb30ccb2..d28edc2b 100644 --- a/src/channels/web/static/style.css +++ b/src/channels/web/static/style.css @@ -39,28 +39,57 @@ body { align-items: center; justify-content: center; height: 100vh; - flex-direction: column; - gap: 16px; } -#auth-screen h1 { - font-size: 24px; - font-weight: 600; +.auth-card-login { + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 12px; + padding: 40px 36px 32px; + width: 100%; + max-width: 400px; + display: flex; + flex-direction: column; + gap: 24px; + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3); +} + +.auth-brand { + text-align: center; +} + +.auth-brand h1 { + font-size: 28px; + font-weight: 700; + color: var(--text); + margin-bottom: 4px; +} + +.auth-tagline { + font-size: 14px; + color: var(--text-secondary); } #auth-screen .auth-form { display: flex; + flex-direction: column; gap: 8px; } +#auth-screen .auth-form label { + font-size: 13px; + font-weight: 500; + color: var(--text-secondary); +} + #auth-screen input { - padding: 8px 12px; - background: var(--bg-secondary); + padding: 10px 12px; + background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 14px; - width: 300px; + width: 100%; } #auth-screen input:focus { @@ -69,13 +98,15 @@ body { } #auth-screen button { - padding: 8px 16px; + padding: 10px 16px; background: var(--accent); color: #fff; border: none; border-radius: var(--radius); cursor: pointer; font-size: 14px; + font-weight: 500; + margin-top: 4px; } #auth-screen button:hover { @@ -86,6 +117,14 @@ body { color: var(--danger); font-size: 13px; min-height: 20px; + text-align: center; +} + +.auth-hint { + font-size: 12px; + color: var(--text-secondary); + text-align: center; + line-height: 1.4; } /* Main App */ @@ -135,6 +174,8 @@ body { gap: 8px; font-size: 12px; color: var(--text-secondary); + position: relative; + cursor: pointer; } .tab-bar .status .dot { @@ -283,6 +324,25 @@ body { to { transform: rotate(360deg); } } +.scroll-load-spinner { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 8px; + color: var(--text-secondary); + font-size: 12px; +} + +.scroll-load-spinner .spinner { + width: 12px; + height: 12px; + border: 2px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.6s linear infinite; +} + /* Approval card (inline in chat) */ .approval-card { align-self: flex-start; @@ -391,6 +451,109 @@ body { font-style: italic; } +/* Auth card (inline in chat) */ +.auth-card { + align-self: flex-start; + max-width: 80%; + background: var(--bg-secondary); + border: 1px solid var(--accent); + border-radius: var(--radius); + padding: 12px 16px; + margin: 8px 0; + display: flex; + flex-direction: column; + gap: 8px; +} + +.auth-card .auth-header { + font-weight: 600; + color: var(--accent); + font-size: 13px; +} + +.auth-card .auth-instructions { + font-size: 13px; + color: var(--text); + line-height: 1.4; +} + +.auth-card .auth-links { + display: flex; + gap: 8px; + align-items: center; +} + +.auth-card .auth-links a { + color: var(--accent); + font-size: 13px; + text-decoration: underline; +} + +.auth-card .auth-token-input { + display: flex; + gap: 8px; + align-items: center; +} + +.auth-card .auth-token-input input { + flex: 1; + padding: 6px 10px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg); + color: var(--text); + font-size: 13px; + font-family: monospace; +} + +.auth-card .auth-token-input input:focus { + outline: none; + border-color: var(--accent); +} + +.auth-card .auth-actions { + display: flex; + gap: 8px; + align-items: center; +} + +.auth-card .auth-actions button { + padding: 6px 14px; + border: 1px solid var(--border); + border-radius: var(--radius); + cursor: pointer; + font-size: 13px; + background: var(--bg-secondary); + color: var(--text); +} + +.auth-card .auth-actions button:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.auth-card .auth-actions button.auth-submit { + background: var(--accent); + border-color: var(--accent); + color: #fff; +} + +.auth-card .auth-actions button.auth-cancel { + background: var(--bg-secondary); + border-color: var(--border); +} + +.auth-card .auth-actions button.auth-oauth { + background: var(--success); + border-color: var(--success); + color: #fff; +} + +.auth-card .auth-error { + color: var(--danger); + font-size: 12px; +} + /* Chat input */ .chat-input { display: flex; @@ -583,6 +746,9 @@ body { color: var(--text-secondary); border-bottom: 1px solid var(--border); background: var(--bg-secondary); + display: flex; + align-items: center; + gap: 8px; } .memory-breadcrumb a { @@ -718,6 +884,9 @@ body { .badge.failed { background: rgba(248, 81, 73, 0.15); color: var(--danger); } .badge.stuck { background: rgba(210, 153, 34, 0.15); color: var(--warning); } .badge.cancelled { background: var(--bg-tertiary); color: var(--text-secondary); } +.badge.interrupted { background: rgba(210, 153, 34, 0.15); color: var(--warning); } +.badge.source-sandbox { background: rgba(136, 132, 216, 0.15); color: #b4b0e8; } +.badge.source-direct { background: var(--bg-tertiary); color: var(--text-secondary); } .btn-cancel { padding: 4px 10px; @@ -733,12 +902,587 @@ body { background: rgba(248, 81, 73, 0.15); } +.btn-restart { + padding: 4px 10px; + background: none; + border: 1px solid var(--accent); + border-radius: var(--radius); + color: var(--accent); + cursor: pointer; + font-size: 12px; +} + +.btn-restart:hover { + background: rgba(88, 166, 255, 0.15); +} + +.btn-browse { + padding: 4px 10px; + background: none; + border: 1px solid var(--success); + border-radius: var(--radius); + color: var(--success); + cursor: pointer; + font-size: 12px; + text-decoration: none; +} + +.btn-browse:hover { + background: rgba(63, 185, 80, 0.15); +} + +/* Job started card in chat */ +.job-card { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + margin: 8px 0; + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: var(--radius); + border-left: 3px solid var(--accent); +} + +.job-card-icon { + font-size: 20px; +} + +.job-card-info { + flex: 1; +} + +.job-card-title { + font-weight: 600; + font-size: 14px; +} + +.job-card-id { + font-size: 12px; + color: var(--text-secondary); + font-family: monospace; +} + +.job-card-view, .job-card-browse { + padding: 4px 12px; + border-radius: var(--radius); + font-size: 12px; + cursor: pointer; + text-decoration: none; +} + +.job-card-view { + background: none; + border: 1px solid var(--accent); + color: var(--accent); +} + +.job-card-view:hover { + background: rgba(88, 166, 255, 0.15); +} + +.job-card-browse { + background: none; + border: 1px solid var(--success); + color: var(--success); +} + +.job-card-browse:hover { + background: rgba(63, 185, 80, 0.15); +} + +/* Clickable job rows */ +.job-row { + cursor: pointer; +} + +/* Job Detail View */ +.job-detail-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 16px; +} + +.job-detail-header h2 { + font-size: 18px; + font-weight: 600; + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.btn-back { + padding: 6px 12px; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text); + cursor: pointer; + font-size: 13px; + flex-shrink: 0; +} + +.btn-back:hover { + background: var(--bg-tertiary); +} + +.job-detail-tabs { + display: flex; + gap: 0; + border-bottom: 1px solid var(--border); + margin-bottom: 16px; +} + +.job-detail-tabs button { + padding: 8px 16px; + background: none; + border: none; + border-bottom: 2px solid transparent; + color: var(--text-secondary); + cursor: pointer; + font-size: 13px; +} + +.job-detail-tabs button:hover { + color: var(--text); +} + +.job-detail-tabs button.active { + color: var(--accent); + border-bottom-color: var(--accent); +} + +.job-detail-content { + flex: 1; + overflow-y: auto; +} + +/* Metadata grid */ +.job-meta-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 12px; + margin-bottom: 20px; +} + +.meta-item { + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 10px 12px; +} + +.meta-label { + font-size: 11px; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 4px; +} + +.meta-value { + font-size: 14px; + color: var(--text); + word-break: break-all; +} + +/* Job description */ +.job-description { + margin-bottom: 20px; +} + +.job-description h3 { + font-size: 14px; + font-weight: 600; + margin-bottom: 8px; + color: var(--text); +} + +.job-description-body { + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 12px 16px; + font-size: 14px; + line-height: 1.6; +} + +/* State transitions timeline */ +.job-timeline-section { + margin-bottom: 20px; +} + +.job-timeline-section h3 { + font-size: 14px; + font-weight: 600; + margin-bottom: 12px; + color: var(--text); +} + +.timeline { + position: relative; + padding-left: 20px; + border-left: 2px solid var(--border); +} + +.timeline-entry { + position: relative; + padding: 8px 0 8px 16px; +} + +.timeline-dot { + position: absolute; + left: -27px; + top: 14px; + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--accent); + border: 2px solid var(--bg); +} + +.timeline-info { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + font-size: 13px; +} + +.timeline-time { + color: var(--text-secondary); + font-size: 12px; + margin-left: 8px; +} + +.timeline-reason { + width: 100%; + font-size: 12px; + color: var(--text-secondary); + margin-top: 2px; +} + +/* Action cards */ +.action-card { + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius); + margin-bottom: 8px; + border-left: 3px solid var(--success); +} + +.action-card.failure { + border-left-color: var(--danger); +} + +.action-header { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + cursor: pointer; + font-size: 13px; +} + +.action-header:hover { + background: var(--bg-tertiary); +} + +.action-tool { + font-weight: 600; + color: var(--text); + font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; +} + +.action-seq { + color: var(--text-secondary); + font-size: 11px; +} + +.action-duration { + color: var(--text-secondary); + font-size: 12px; +} + +.action-time { + color: var(--text-secondary); + font-size: 12px; + margin-left: auto; +} + +.action-toggle { + color: var(--text-secondary); + font-size: 10px; + flex-shrink: 0; +} + +.action-detail { + padding: 0 12px 12px; +} + +.action-section { + margin-top: 8px; +} + +.action-section strong { + font-size: 12px; + color: var(--text-secondary); + display: block; + margin-bottom: 4px; +} + +.action-json { + background: var(--code-bg); + padding: 8px 12px; + border-radius: var(--radius); + font-size: 12px; + font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; + line-height: 1.4; + overflow-x: auto; + color: var(--text-secondary); + margin: 0; + white-space: pre-wrap; + word-break: break-all; + max-height: 300px; + overflow-y: auto; +} + +.action-error { + background: rgba(248, 81, 73, 0.1); + padding: 8px 12px; + border-radius: var(--radius); + font-size: 12px; + font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; + line-height: 1.4; + color: var(--danger); + margin: 0; + white-space: pre-wrap; + word-break: break-all; +} + +/* Conversation messages */ +.conv-message { + padding: 10px 14px; + border-radius: var(--radius); + margin-bottom: 8px; + font-size: 14px; + line-height: 1.5; +} + +.conv-role { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 4px; +} + +.conv-body { + word-wrap: break-word; +} + +.conv-system { + background: var(--bg-tertiary); + border: 1px solid var(--border); +} + +.conv-system .conv-role { color: var(--text-secondary); } +.conv-system .conv-body { color: var(--text-secondary); font-size: 13px; } + +.conv-user { + background: rgba(88, 166, 255, 0.08); + border: 1px solid rgba(88, 166, 255, 0.2); +} + +.conv-user .conv-role { color: var(--accent); } + +.conv-assistant { + background: var(--bg-secondary); + border: 1px solid var(--border); +} + +.conv-assistant .conv-role { color: var(--success); } + +.conv-tool { + background: var(--bg-secondary); + border: 1px solid var(--border); + font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; + font-size: 13px; +} + +.conv-tool .conv-role { color: var(--warning); } +.conv-tool .conv-body { white-space: pre-wrap; word-break: break-all; max-height: 200px; overflow-y: auto; } + +.conv-tc-id { + font-size: 11px; + color: var(--text-secondary); + margin-bottom: 4px; + font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; +} + +.conv-tool-calls { + margin-top: 8px; + border-top: 1px solid var(--border); + padding-top: 8px; +} + +.conv-tc-entry { + margin-bottom: 6px; +} + +.conv-tc-name { + font-size: 12px; + font-weight: 600; + color: var(--accent); + font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; +} + +.conv-tc-args { + background: var(--code-bg); + padding: 6px 10px; + border-radius: var(--radius); + font-size: 11px; + font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; + line-height: 1.4; + margin: 4px 0 0; + color: var(--text-secondary); + white-space: pre-wrap; + word-break: break-all; + max-height: 150px; + overflow-y: auto; +} + +/* Job files browser */ +.job-files { + display: flex; + height: calc(100vh - 280px); + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; +} + +.job-files-sidebar { + width: 240px; + border-right: 1px solid var(--border); + background: var(--bg-secondary); + overflow-y: auto; +} + +.job-files-tree { + padding: 8px 0; +} + +.job-files-viewer { + flex: 1; + overflow: auto; + padding: 12px 16px; +} + +.job-files-path { + font-size: 12px; + color: var(--accent); + margin-bottom: 8px; + font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; +} + +.job-files-content { + font-size: 13px; + font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-all; + color: var(--text); + margin: 0; +} + .empty-state { text-align: center; padding: 40px; color: var(--text-secondary); } +/* Routines Tab */ +.routines-container { + flex: 1; + overflow-y: auto; + padding: 16px; +} + +.routines-summary { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 12px; + margin-bottom: 20px; +} + +.routines-table { + width: 100%; + border-collapse: collapse; +} + +.routines-table th, +.routines-table td { + padding: 10px 12px; + text-align: left; + border-bottom: 1px solid var(--border); + font-size: 13px; +} + +.routines-table th { + color: var(--text-secondary); + font-weight: 500; + text-transform: uppercase; + font-size: 11px; + letter-spacing: 0.5px; +} + +.routines-table tr:hover td { + background: var(--bg-secondary); +} + +.routine-row { + cursor: pointer; +} + +.routine-detail { + padding: 16px 0; +} + +.badge.enabled { background: rgba(63, 185, 80, 0.15); color: var(--success); } +.badge.disabled { background: var(--bg-tertiary); color: var(--text-secondary); } +.badge.failing { background: rgba(248, 81, 73, 0.15); color: var(--danger); } + +.btn-trigger { + padding: 4px 10px; + background: none; + border: 1px solid var(--accent); + border-radius: var(--radius); + color: var(--accent); + cursor: pointer; + font-size: 12px; +} + +.btn-trigger:hover { + background: rgba(88, 166, 255, 0.15); +} + +.btn-toggle { + padding: 4px 10px; + background: none; + border: 1px solid var(--warning); + border-radius: var(--radius); + color: var(--warning); + cursor: pointer; + font-size: 12px; +} + +.btn-toggle:hover { + background: rgba(210, 153, 34, 0.15); +} + /* Logs Tab */ .logs-container { flex: 1; @@ -1050,3 +1794,710 @@ body { .tools-table tr:hover td { background: var(--bg-secondary); } + +/* --- Activity tab (unified sandbox job events) --- */ + +.activity-terminal { + flex: 1; + overflow-y: auto; + padding: 12px; + font-family: monospace; + font-size: 13px; + line-height: 1.6; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 6px; + margin-bottom: 8px; + max-height: calc(100vh - 320px); +} + +.activity-event { + padding: 4px 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.04); +} + +.activity-event-message .activity-role { + color: var(--accent); + font-weight: 600; + margin-right: 8px; +} + +.activity-event-message .activity-content { + white-space: pre-wrap; + word-break: break-word; +} + +.activity-event-status .activity-status { + color: var(--text-secondary); + font-style: italic; +} + +.activity-event-result.activity-final { + padding: 8px 0; + font-weight: 600; +} + +.activity-result-status { + color: var(--success); +} + +.activity-result-status[data-success="false"] { + color: var(--danger); +} + +.activity-session-id { + color: var(--text-secondary); + font-size: 11px; + font-weight: 400; +} + +.activity-tool-block { + margin: 4px 0; + border: 1px solid var(--border); + border-radius: 4px; + overflow: hidden; +} + +.activity-tool-block summary { + padding: 6px 10px; + cursor: pointer; + background: var(--bg-secondary); + font-size: 12px; + color: var(--text-secondary); +} + +.activity-tool-block summary:hover { + color: var(--text); +} + +.activity-tool-icon { + margin-right: 4px; +} + +.activity-tool-result .activity-tool-icon { + color: var(--success); +} + +.activity-tool-input, +.activity-tool-output { + padding: 8px 10px; + margin: 0; + font-size: 12px; + overflow-x: auto; + max-height: 200px; + overflow-y: auto; + background: var(--bg); +} + +.activity-input-bar { + display: flex; + gap: 8px; + padding: 8px 0; +} + +.activity-input-bar input { + flex: 1; + padding: 8px 12px; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text); + font-size: 13px; +} + +.activity-input-bar input:focus { + outline: none; + border-color: var(--accent); +} + +.activity-input-bar button { + padding: 8px 16px; + background: var(--accent); + color: #fff; + border: none; + border-radius: 6px; + cursor: pointer; + font-size: 13px; +} + +.activity-input-bar button:hover { + background: var(--accent-hover); +} + +#activity-done-btn { + background: var(--bg-secondary); + border: 1px solid var(--border); + color: var(--text-secondary); +} + +#activity-done-btn:hover { + color: var(--text); + border-color: var(--text-secondary); + background: var(--bg-secondary); +} + +/* --- Copy button on code blocks --- */ + +.code-block-wrapper { + position: relative; +} + +.copy-btn { + position: absolute; + top: 6px; + right: 6px; + padding: 2px 8px; + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text-secondary); + font-size: 11px; + cursor: pointer; + opacity: 0; + transition: opacity 0.15s; +} + +.code-block-wrapper:hover .copy-btn { + opacity: 1; +} + +.copy-btn:hover { + color: var(--text); + background: var(--border); +} + +/* --- Toast notifications --- */ + +#toasts { + position: fixed; + top: 16px; + right: 16px; + z-index: 10000; + display: flex; + flex-direction: column; + gap: 8px; + pointer-events: none; +} + +.toast { + padding: 10px 16px; + border-radius: var(--radius); + font-size: 13px; + color: #fff; + pointer-events: auto; + transform: translateX(120%); + transition: transform 0.25s ease; + max-width: 360px; + word-break: break-word; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); +} + +.toast.visible { + transform: translateX(0); +} + +.toast-info { + background: var(--accent); +} + +.toast-success { + background: var(--success); +} + +.toast-error { + background: var(--danger); +} + +/* --- Memory search highlighting --- */ + +mark { + background: rgba(88, 166, 255, 0.3); + color: inherit; + border-radius: 2px; + padding: 0 1px; +} + +/* --- Thread sidebar --- */ + +#tab-chat { + flex-direction: row; +} + +.thread-sidebar { + width: 200px; + background: var(--bg-secondary); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + flex-shrink: 0; + transition: width 0.2s ease; + overflow: hidden; +} + +.thread-sidebar.collapsed { + width: 36px; +} + +.thread-sidebar.collapsed .thread-sidebar-header span, +.thread-sidebar.collapsed .thread-new-btn, +.thread-sidebar.collapsed .thread-list, +.thread-sidebar.collapsed .assistant-item, +.thread-sidebar.collapsed .threads-section-header { + display: none; +} + +.thread-sidebar-header { + display: flex; + align-items: center; + padding: 10px 12px; + border-bottom: 1px solid var(--border); + font-size: 13px; + font-weight: 600; + gap: 8px; +} + +.thread-sidebar-header span { + flex: 1; +} + +.thread-new-btn { + background: none; + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--accent); + cursor: pointer; + font-size: 16px; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + line-height: 1; +} + +.thread-new-btn:hover { + background: rgba(88, 166, 255, 0.15); +} + +.assistant-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 12px; + cursor: pointer; + font-size: 13px; + font-weight: 600; + color: var(--text); + border-bottom: 1px solid var(--border); + background: var(--bg-secondary); +} + +.assistant-item:hover { + background: var(--bg-tertiary); +} + +.assistant-item.active { + background: rgba(88, 166, 255, 0.08); + color: var(--accent); + border-left: 2px solid var(--accent); +} + +.assistant-label { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.assistant-meta { + font-size: 11px; + font-weight: 400; + color: var(--text-secondary); +} + +.threads-section-header { + padding: 8px 12px 4px; + font-size: 11px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-secondary); +} + +.thread-toggle-btn { + background: none; + border: none; + color: var(--text-secondary); + cursor: pointer; + font-size: 14px; + padding: 2px; +} + +.thread-toggle-btn:hover { + color: var(--text); +} + +.thread-list { + flex: 1; + overflow-y: auto; +} + +.thread-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + cursor: pointer; + font-size: 13px; + color: var(--text-secondary); + border-bottom: 1px solid rgba(255, 255, 255, 0.03); +} + +.thread-item:hover { + background: var(--bg-tertiary); + color: var(--text); +} + +.thread-item.active { + background: var(--bg-tertiary); + color: var(--accent); + border-left: 2px solid var(--accent); +} + +.thread-label { + font-family: monospace; + font-size: 12px; +} + +.thread-meta { + font-size: 11px; + color: var(--text-secondary); +} + +/* --- Memory editing --- */ + +#memory-breadcrumb-path { + flex: 1; +} + +.memory-edit-btn { + padding: 3px 10px; + background: none; + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text-secondary); + cursor: pointer; + font-size: 12px; + flex-shrink: 0; +} + +.memory-edit-btn:hover { + color: var(--accent); + border-color: var(--accent); +} + +.memory-editor { + flex: 1; + display: flex; + flex-direction: column; + gap: 8px; + padding: 12px; + overflow: hidden; +} + +.memory-editor textarea { + flex: 1; + padding: 12px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text); + font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; + font-size: 13px; + line-height: 1.5; + resize: none; +} + +.memory-editor textarea:focus { + outline: none; + border-color: var(--accent); +} + +.memory-editor-actions { + display: flex; + gap: 8px; +} + +.btn-save { + padding: 6px 16px; + background: var(--accent); + color: #fff; + border: none; + border-radius: var(--radius); + cursor: pointer; + font-size: 13px; +} + +.btn-save:hover { + background: var(--accent-hover); +} + +.btn-cancel-edit { + padding: 6px 16px; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text); + cursor: pointer; + font-size: 13px; +} + +.btn-cancel-edit:hover { + background: var(--bg-tertiary); +} + +/* Memory rendered markdown */ +.memory-viewer.rendered { + white-space: normal; + font-family: inherit; +} + +.memory-rendered { + font-size: 14px; + line-height: 1.6; +} + +.memory-rendered h1, .memory-rendered h2, .memory-rendered h3 { + margin: 12px 0 6px 0; +} + +.memory-rendered p { margin: 0 0 8px 0; } +.memory-rendered p:last-child { margin-bottom: 0; } +.memory-rendered ul, .memory-rendered ol { margin: 4px 0; padding-left: 20px; } +.memory-rendered li { margin: 2px 0; } +.memory-rendered code { + background: var(--code-bg); + padding: 1px 4px; + border-radius: 3px; + font-size: 13px; +} +.memory-rendered pre { + background: var(--code-bg); + padding: 8px 12px; + border-radius: var(--radius); + overflow-x: auto; + margin: 6px 0; +} +.memory-rendered pre code { background: none; padding: 0; } +.memory-rendered a { color: var(--accent); } +.memory-rendered blockquote { + margin: 6px 0; + padding: 4px 12px; + border-left: 3px solid var(--border); + color: var(--text-secondary); +} + +/* --- Gateway status popover --- */ + +.gateway-popover { + display: none; + position: absolute; + top: 100%; + right: 0; + margin-top: 8px; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 12px; + min-width: 180px; + box-shadow: var(--shadow); + z-index: 100; +} + +.gateway-popover.visible { + display: block; +} + +.gw-stat { + display: flex; + justify-content: space-between; + font-size: 12px; + padding: 3px 0; + color: var(--text-secondary); +} + +.gw-stat span:last-child { + color: var(--text); + font-weight: 500; +} + +/* --- Extension install form --- */ + +.ext-install-form { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; +} + +.ext-install-form input { + padding: 6px 10px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text); + font-size: 13px; +} + +.ext-install-form input:focus { + outline: none; + border-color: var(--accent); +} + +.ext-install-form select { + padding: 6px 10px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text); + font-size: 13px; +} + +.ext-install-form button { + padding: 6px 16px; + background: var(--accent); + color: #fff; + border: none; + border-radius: var(--radius); + cursor: pointer; + font-size: 13px; +} + +.ext-install-form button:hover { + background: var(--accent-hover); +} + +/* --- Activity toolbar --- */ + +.activity-toolbar { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 0; +} + +.activity-toolbar select { + padding: 5px 8px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text); + font-size: 12px; +} + +.activity-toolbar select:focus { + outline: none; + border-color: var(--accent); +} + +/* --- Mobile responsive --- */ + +@media (max-width: 768px) { + /* Tab bar: horizontal scroll */ + .tab-bar { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + padding: 0 8px; + } + + .tab-bar button { + padding: 8px 12px; + font-size: 13px; + white-space: nowrap; + } + + /* Chat messages: wider */ + .message { + max-width: 95%; + } + + /* Thread sidebar: hidden behind toggle */ + .thread-sidebar { + width: 36px; + } + + .thread-sidebar .thread-sidebar-header span, + .thread-sidebar .thread-new-btn, + .thread-sidebar .thread-list, + .thread-sidebar .assistant-item, + .thread-sidebar .threads-section-header { + display: none; + } + + .thread-sidebar.expanded-mobile { + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 200px; + z-index: 50; + } + + .thread-sidebar.expanded-mobile .thread-sidebar-header span, + .thread-sidebar.expanded-mobile .thread-new-btn, + .thread-sidebar.expanded-mobile .thread-list, + .thread-sidebar.expanded-mobile .assistant-item, + .thread-sidebar.expanded-mobile .threads-section-header { + display: flex; + } + + /* Memory: vertical stack */ + .memory-container { + flex-direction: column; + } + + .memory-sidebar { + width: 100%; + max-height: 200px; + border-right: none; + border-bottom: 1px solid var(--border); + } + + /* Job detail sub-tabs: wrap */ + .job-detail-tabs { + flex-wrap: wrap; + } + + .job-detail-header { + flex-wrap: wrap; + } + + .job-detail-header h2 { + min-width: 100%; + order: -1; + } + + /* Job files: vertical */ + .job-files { + flex-direction: column; + height: auto; + } + + .job-files-sidebar { + width: 100%; + max-height: 180px; + border-right: none; + border-bottom: 1px solid var(--border); + } + + /* Extension install form */ + .ext-install-form { + flex-direction: column; + align-items: stretch; + } + + .ext-install-form input, + .ext-install-form select { + width: 100%; + } +} diff --git a/src/channels/web/types.rs b/src/channels/web/types.rs index e476a0f6..c43e86f9 100644 --- a/src/channels/web/types.rs +++ b/src/channels/web/types.rs @@ -24,10 +24,17 @@ pub struct ThreadInfo { pub turn_count: usize, pub created_at: String, pub updated_at: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub thread_type: Option, } #[derive(Debug, Serialize)] pub struct ThreadListResponse { + /// The pinned assistant thread (always present after first load). + pub assistant_thread: Option, + /// Regular conversation threads. pub threads: Vec, pub active_thread: Option, } @@ -54,6 +61,12 @@ pub struct ToolCallInfo { pub struct HistoryResponse { pub thread_id: Uuid, pub turns: Vec, + /// Whether there are older messages available. + #[serde(default)] + pub has_more: bool, + /// Cursor for the next page (ISO8601 timestamp of the oldest message returned). + #[serde(skip_serializing_if = "Option::is_none")] + pub oldest_timestamp: Option, } // --- Approval --- @@ -63,6 +76,8 @@ pub struct ApprovalRequest { pub request_id: String, /// "approve", "always", or "deny" pub action: String, + /// Thread that owns the pending approval (so the agent loop finds the right session). + pub thread_id: Option, } // --- SSE Event Types --- @@ -73,17 +88,49 @@ pub enum SseEvent { #[serde(rename = "response")] Response { content: String, thread_id: String }, #[serde(rename = "thinking")] - Thinking { message: String }, + Thinking { + message: String, + #[serde(skip_serializing_if = "Option::is_none")] + thread_id: Option, + }, #[serde(rename = "tool_started")] - ToolStarted { name: String }, + ToolStarted { + name: String, + #[serde(skip_serializing_if = "Option::is_none")] + thread_id: Option, + }, #[serde(rename = "tool_completed")] - ToolCompleted { name: String, success: bool }, + ToolCompleted { + name: String, + success: bool, + #[serde(skip_serializing_if = "Option::is_none")] + thread_id: Option, + }, #[serde(rename = "tool_result")] - ToolResult { name: String, preview: String }, + ToolResult { + name: String, + preview: String, + #[serde(skip_serializing_if = "Option::is_none")] + thread_id: Option, + }, #[serde(rename = "stream_chunk")] - StreamChunk { content: String }, + StreamChunk { + content: String, + #[serde(skip_serializing_if = "Option::is_none")] + thread_id: Option, + }, #[serde(rename = "status")] - Status { message: String }, + Status { + message: String, + #[serde(skip_serializing_if = "Option::is_none")] + thread_id: Option, + }, + #[serde(rename = "job_started")] + JobStarted { + job_id: String, + title: String, + browse_url: String, + }, #[serde(rename = "approval_needed")] ApprovalNeeded { request_id: String, @@ -91,10 +138,59 @@ pub enum SseEvent { description: String, parameters: String, }, + #[serde(rename = "auth_required")] + AuthRequired { + extension_name: String, + #[serde(skip_serializing_if = "Option::is_none")] + instructions: Option, + #[serde(skip_serializing_if = "Option::is_none")] + auth_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + setup_url: Option, + }, + #[serde(rename = "auth_completed")] + AuthCompleted { + extension_name: String, + success: bool, + message: String, + }, #[serde(rename = "error")] - Error { message: String }, + Error { + message: String, + #[serde(skip_serializing_if = "Option::is_none")] + thread_id: Option, + }, #[serde(rename = "heartbeat")] Heartbeat, + + // Sandbox job streaming events (worker + Claude Code bridge) + #[serde(rename = "job_message")] + JobMessage { + job_id: String, + role: String, + content: String, + }, + #[serde(rename = "job_tool_use")] + JobToolUse { + job_id: String, + tool_name: String, + input: serde_json::Value, + }, + #[serde(rename = "job_tool_result")] + JobToolResult { + job_id: String, + tool_name: String, + output: String, + }, + #[serde(rename = "job_status")] + JobStatus { job_id: String, message: String }, + #[serde(rename = "job_result")] + JobResult { + job_id: String, + status: String, + #[serde(skip_serializing_if = "Option::is_none")] + session_id: Option, + }, } // --- Memory --- @@ -188,6 +284,54 @@ pub struct JobSummaryResponse { pub stuck: usize, } +#[derive(Debug, Serialize)] +pub struct JobDetailResponse { + pub id: Uuid, + pub title: String, + pub description: String, + pub state: String, + pub user_id: String, + pub created_at: String, + pub started_at: Option, + pub completed_at: Option, + pub elapsed_secs: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub project_dir: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub browse_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub job_mode: Option, + pub transitions: Vec, +} + +// --- Project Files --- + +#[derive(Debug, Serialize)] +pub struct ProjectFileEntry { + pub name: String, + pub path: String, + pub is_dir: bool, +} + +#[derive(Debug, Serialize)] +pub struct ProjectFilesResponse { + pub entries: Vec, +} + +#[derive(Debug, Serialize)] +pub struct ProjectFileReadResponse { + pub path: String, + pub content: String, +} + +#[derive(Debug, Serialize)] +pub struct TransitionInfo { + pub from: String, + pub to: String, + pub timestamp: String, + pub reason: Option, +} + // --- Extensions --- #[derive(Debug, Serialize)] @@ -262,6 +406,21 @@ impl ActionResponse { } } +// --- Auth Token --- + +/// Request to submit an auth token for an extension (dedicated endpoint). +#[derive(Debug, Deserialize)] +pub struct AuthTokenRequest { + pub extension_name: String, + pub token: String, +} + +/// Request to cancel an in-progress auth flow. +#[derive(Debug, Deserialize)] +pub struct AuthCancelRequest { + pub extension_name: String, +} + // --- WebSocket --- /// Message sent by a WebSocket client to the server. @@ -280,7 +439,18 @@ pub enum WsClientMessage { request_id: String, /// "approve", "always", or "deny" action: String, + /// Thread that owns the pending approval. + thread_id: Option, }, + /// Submit an auth token for an extension (bypasses message pipeline). + #[serde(rename = "auth_token")] + AuthToken { + extension_name: String, + token: String, + }, + /// Cancel an in-progress auth flow. + #[serde(rename = "auth_cancel")] + AuthCancel { extension_name: String }, /// Client heartbeat ping. #[serde(rename = "ping")] Ping, @@ -317,9 +487,17 @@ impl WsServerMessage { SseEvent::ToolResult { .. } => "tool_result", SseEvent::StreamChunk { .. } => "stream_chunk", SseEvent::Status { .. } => "status", + SseEvent::JobStarted { .. } => "job_started", SseEvent::ApprovalNeeded { .. } => "approval_needed", + SseEvent::AuthRequired { .. } => "auth_required", + SseEvent::AuthCompleted { .. } => "auth_completed", SseEvent::Error { .. } => "error", SseEvent::Heartbeat => "heartbeat", + SseEvent::JobMessage { .. } => "job_message", + SseEvent::JobToolUse { .. } => "job_tool_use", + SseEvent::JobToolResult { .. } => "job_tool_result", + SseEvent::JobStatus { .. } => "job_status", + SseEvent::JobResult { .. } => "job_result", }; let data = serde_json::to_value(event).unwrap_or(serde_json::Value::Null); WsServerMessage::Event { @@ -329,6 +507,96 @@ impl WsServerMessage { } } +// --- Routines --- + +#[derive(Debug, Serialize)] +pub struct RoutineInfo { + pub id: Uuid, + pub name: String, + pub description: String, + pub enabled: bool, + pub trigger_type: String, + pub trigger_summary: String, + pub action_type: String, + pub last_run_at: Option, + pub next_fire_at: Option, + pub run_count: u64, + pub consecutive_failures: u32, + pub status: String, +} + +#[derive(Debug, Serialize)] +pub struct RoutineListResponse { + pub routines: Vec, +} + +#[derive(Debug, Serialize)] +pub struct RoutineSummaryResponse { + pub total: u64, + pub enabled: u64, + pub disabled: u64, + pub failing: u64, + pub runs_today: u64, +} + +#[derive(Debug, Serialize)] +pub struct RoutineDetailResponse { + pub id: Uuid, + pub name: String, + pub description: String, + pub enabled: bool, + pub trigger: serde_json::Value, + pub action: serde_json::Value, + pub guardrails: serde_json::Value, + pub notify: serde_json::Value, + pub last_run_at: Option, + pub next_fire_at: Option, + pub run_count: u64, + pub consecutive_failures: u32, + pub created_at: String, + pub recent_runs: Vec, +} + +#[derive(Debug, Serialize)] +pub struct RoutineRunInfo { + pub id: Uuid, + pub trigger_type: String, + pub started_at: String, + pub completed_at: Option, + pub status: String, + pub result_summary: Option, + pub tokens_used: Option, +} + +// --- Settings --- + +#[derive(Debug, Serialize)] +pub struct SettingResponse { + pub key: String, + pub value: serde_json::Value, + pub updated_at: String, +} + +#[derive(Debug, Serialize)] +pub struct SettingsListResponse { + pub settings: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct SettingWriteRequest { + pub value: serde_json::Value, +} + +#[derive(Debug, Deserialize)] +pub struct SettingsImportRequest { + pub settings: std::collections::HashMap, +} + +#[derive(Debug, Serialize)] +pub struct SettingsExportResponse { + pub settings: std::collections::HashMap, +} + // --- Health --- #[derive(Debug, Serialize)] @@ -371,12 +639,36 @@ mod tests { #[test] fn test_ws_client_approval_parse() { - let json = r#"{"type":"approval","request_id":"abc-123","action":"approve"}"#; + let json = + r#"{"type":"approval","request_id":"abc-123","action":"approve","thread_id":"t1"}"#; let msg: WsClientMessage = serde_json::from_str(json).unwrap(); match msg { - WsClientMessage::Approval { request_id, action } => { + WsClientMessage::Approval { + request_id, + action, + thread_id, + } => { assert_eq!(request_id, "abc-123"); assert_eq!(action, "approve"); + assert_eq!(thread_id.as_deref(), Some("t1")); + } + _ => panic!("Expected Approval variant"), + } + } + + #[test] + fn test_ws_client_approval_parse_no_thread() { + let json = r#"{"type":"approval","request_id":"abc-123","action":"deny"}"#; + let msg: WsClientMessage = serde_json::from_str(json).unwrap(); + match msg { + WsClientMessage::Approval { + request_id, + action, + thread_id, + } => { + assert_eq!(request_id, "abc-123"); + assert_eq!(action, "deny"); + assert!(thread_id.is_none()); } _ => panic!("Expected Approval variant"), } @@ -437,6 +729,7 @@ mod tests { fn test_ws_server_from_sse_thinking() { let sse = SseEvent::Thinking { message: "reasoning...".to_string(), + thread_id: None, }; let ws = WsServerMessage::from_sse_event(&sse); match ws { @@ -477,4 +770,115 @@ mod tests { _ => panic!("Expected Event variant"), } } + + // ---- Auth type tests ---- + + #[test] + fn test_ws_client_auth_token_parse() { + let json = r#"{"type":"auth_token","extension_name":"notion","token":"sk-123"}"#; + let msg: WsClientMessage = serde_json::from_str(json).unwrap(); + match msg { + WsClientMessage::AuthToken { + extension_name, + token, + } => { + assert_eq!(extension_name, "notion"); + assert_eq!(token, "sk-123"); + } + _ => panic!("Expected AuthToken variant"), + } + } + + #[test] + fn test_ws_client_auth_cancel_parse() { + let json = r#"{"type":"auth_cancel","extension_name":"notion"}"#; + let msg: WsClientMessage = serde_json::from_str(json).unwrap(); + match msg { + WsClientMessage::AuthCancel { extension_name } => { + assert_eq!(extension_name, "notion"); + } + _ => panic!("Expected AuthCancel variant"), + } + } + + #[test] + fn test_sse_auth_required_serialize() { + let event = SseEvent::AuthRequired { + extension_name: "notion".to_string(), + instructions: Some("Get your token from...".to_string()), + auth_url: None, + setup_url: Some("https://notion.so/integrations".to_string()), + }; + let json = serde_json::to_string(&event).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed["type"], "auth_required"); + assert_eq!(parsed["extension_name"], "notion"); + assert_eq!(parsed["instructions"], "Get your token from..."); + assert!(parsed.get("auth_url").is_none()); + assert_eq!(parsed["setup_url"], "https://notion.so/integrations"); + } + + #[test] + fn test_sse_auth_completed_serialize() { + let event = SseEvent::AuthCompleted { + extension_name: "notion".to_string(), + success: true, + message: "notion authenticated (3 tools loaded)".to_string(), + }; + let json = serde_json::to_string(&event).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed["type"], "auth_completed"); + assert_eq!(parsed["extension_name"], "notion"); + assert_eq!(parsed["success"], true); + } + + #[test] + fn test_ws_server_from_sse_auth_required() { + let sse = SseEvent::AuthRequired { + extension_name: "openai".to_string(), + instructions: Some("Enter API key".to_string()), + auth_url: None, + setup_url: None, + }; + let ws = WsServerMessage::from_sse_event(&sse); + match ws { + WsServerMessage::Event { event_type, data } => { + assert_eq!(event_type, "auth_required"); + assert_eq!(data["extension_name"], "openai"); + } + _ => panic!("Expected Event variant"), + } + } + + #[test] + fn test_ws_server_from_sse_auth_completed() { + let sse = SseEvent::AuthCompleted { + extension_name: "slack".to_string(), + success: false, + message: "Invalid token".to_string(), + }; + let ws = WsServerMessage::from_sse_event(&sse); + match ws { + WsServerMessage::Event { event_type, data } => { + assert_eq!(event_type, "auth_completed"); + assert_eq!(data["success"], false); + } + _ => panic!("Expected Event variant"), + } + } + + #[test] + fn test_auth_token_request_deserialize() { + let json = r#"{"extension_name":"telegram","token":"bot12345"}"#; + let req: AuthTokenRequest = serde_json::from_str(json).unwrap(); + assert_eq!(req.extension_name, "telegram"); + assert_eq!(req.token, "bot12345"); + } + + #[test] + fn test_auth_cancel_request_deserialize() { + let json = r#"{"extension_name":"telegram"}"#; + let req: AuthCancelRequest = serde_json::from_str(json).unwrap(); + assert_eq!(req.extension_name, "telegram"); + } } diff --git a/src/channels/web/ws.rs b/src/channels/web/ws.rs index 64755e3c..8778f39d 100644 --- a/src/channels/web/ws.rs +++ b/src/channels/web/ws.rs @@ -170,7 +170,11 @@ async fn handle_client_message( .await; } } - WsClientMessage::Approval { request_id, action } => { + WsClientMessage::Approval { + request_id, + action, + thread_id, + } => { let (approved, always) = match action.as_str() { "approve" => (true, false), "always" => (true, true), @@ -214,12 +218,71 @@ async fn handle_client_message( } }; - let msg = IncomingMessage::new("gateway", user_id, content); + let mut msg = IncomingMessage::new("gateway", user_id, content); + if let Some(ref tid) = thread_id { + msg = msg.with_thread(tid); + } let tx_guard = state.msg_tx.read().await; if let Some(ref tx) = *tx_guard { let _ = tx.send(msg).await; } } + WsClientMessage::AuthToken { + extension_name, + token, + } => { + if let Some(ref ext_mgr) = state.extension_manager { + match ext_mgr.auth(&extension_name, Some(&token)).await { + Ok(result) if result.status == "authenticated" => { + let msg = match ext_mgr.activate(&extension_name).await { + Ok(r) => format!( + "{} authenticated ({} tools loaded)", + extension_name, + r.tools_loaded.len() + ), + Err(e) => format!( + "{} authenticated but activation failed: {}", + extension_name, e + ), + }; + crate::channels::web::server::clear_auth_mode(state).await; + state + .sse + .broadcast(crate::channels::web::types::SseEvent::AuthCompleted { + extension_name, + success: true, + message: msg, + }); + } + Ok(result) => { + state + .sse + .broadcast(crate::channels::web::types::SseEvent::AuthRequired { + extension_name, + instructions: result.instructions, + auth_url: result.auth_url, + setup_url: result.setup_url, + }); + } + Err(e) => { + let _ = direct_tx + .send(WsServerMessage::Error { + message: format!("Auth failed: {}", e), + }) + .await; + } + } + } else { + let _ = direct_tx + .send(WsServerMessage::Error { + message: "Extension manager not available".to_string(), + }) + .await; + } + } + WsClientMessage::AuthCancel { .. } => { + crate::channels::web::server::clear_auth_mode(state).await; + } WsClientMessage::Ping => { let _ = direct_tx.send(WsServerMessage::Pong).await; } @@ -328,6 +391,7 @@ mod tests { WsClientMessage::Approval { request_id: request_id.to_string(), action: "approve".to_string(), + thread_id: Some("thread-42".to_string()), }, &state, "user1", @@ -338,6 +402,8 @@ mod tests { let incoming = agent_rx.recv().await.unwrap(); // The content should be a serialized ExecApproval assert!(incoming.content.contains("ExecApproval")); + // Thread should be forwarded onto the IncomingMessage. + assert_eq!(incoming.thread_id.as_deref(), Some("thread-42")); } #[tokio::test] @@ -349,6 +415,7 @@ mod tests { WsClientMessage::Approval { request_id: Uuid::new_v4().to_string(), action: "maybe".to_string(), + thread_id: None, }, &state, "user1", @@ -374,6 +441,7 @@ mod tests { WsClientMessage::Approval { request_id: "not-a-uuid".to_string(), action: "approve".to_string(), + thread_id: None, }, &state, "user1", @@ -398,11 +466,13 @@ mod tests { msg_tx: tokio::sync::RwLock::new(msg_tx), sse: SseManager::new(), workspace: None, - context_manager: None, session_manager: None, log_broadcaster: None, extension_manager: None, tool_registry: None, + store: None, + job_manager: None, + prompt_queue: None, user_id: "test".to_string(), shutdown_tx: tokio::sync::RwLock::new(None), ws_tracker: Some(Arc::new(WsConnectionTracker::new())), diff --git a/src/cli/config.rs b/src/cli/config.rs index 080cccf1..d248fcaf 100644 --- a/src/cli/config.rs +++ b/src/cli/config.rs @@ -1,6 +1,7 @@ //! Configuration management CLI commands. //! //! Commands for viewing and modifying settings. +//! Settings are stored in PostgreSQL (env > DB > default). use clap::Subcommand; @@ -36,41 +37,80 @@ pub enum ConfigCommand { path: String, }, - /// Show the settings file path + /// Show the settings storage info Path, } /// Run a config command. -pub fn run_config_command(cmd: ConfigCommand) -> anyhow::Result<()> { +/// +/// Connects to the database to read/write settings. Falls back to disk +/// if the database is not available. +pub async fn run_config_command(cmd: ConfigCommand) -> anyhow::Result<()> { + let _ = dotenvy::dotenv(); + + // Try to connect to the DB for settings access + let store = match connect_store().await { + Ok(s) => Some(s), + Err(e) => { + eprintln!( + "Warning: Could not connect to database ({}), using disk fallback", + e + ); + None + } + }; + match cmd { - ConfigCommand::List { filter } => list_settings(filter), - ConfigCommand::Get { path } => get_setting(&path), - ConfigCommand::Set { path, value } => set_setting(&path, &value), - ConfigCommand::Reset { path } => reset_setting(&path), - ConfigCommand::Path => show_path(), + ConfigCommand::List { filter } => list_settings(store.as_ref(), filter).await, + ConfigCommand::Get { path } => get_setting(store.as_ref(), &path).await, + ConfigCommand::Set { path, value } => set_setting(store.as_ref(), &path, &value).await, + ConfigCommand::Reset { path } => reset_setting(store.as_ref(), &path).await, + ConfigCommand::Path => show_path(store.is_some()), } } +/// Bootstrap a DB connection for config commands. +async fn connect_store() -> anyhow::Result { + let config = crate::config::Config::from_env().map_err(|e| anyhow::anyhow!("{}", e))?; + let store = crate::history::Store::new(&config.database).await?; + store.run_migrations().await?; + Ok(store) +} + +const DEFAULT_USER_ID: &str = "default"; + +/// Load settings: DB if available, else disk. +async fn load_settings(store: Option<&crate::history::Store>) -> Settings { + if let Some(store) = store { + match store.get_all_settings(DEFAULT_USER_ID).await { + Ok(map) if !map.is_empty() => return Settings::from_db_map(&map), + _ => {} + } + } + Settings::load() +} + /// List all settings. -fn list_settings(filter: Option) -> anyhow::Result<()> { - let settings = Settings::load(); +async fn list_settings( + store: Option<&crate::history::Store>, + filter: Option, +) -> anyhow::Result<()> { + let settings = load_settings(store).await; let all = settings.list(); - // Find the longest key for alignment let max_key_len = all.iter().map(|(k, _)| k.len()).max().unwrap_or(0); - println!("Settings:"); + let source = if store.is_some() { "database" } else { "disk" }; + println!("Settings (source: {}):", source); println!(); for (key, value) in all { - // Skip if filter is set and doesn't match if let Some(ref f) = filter { if !key.starts_with(f) { continue; } } - // Truncate long values for display let display_value = if value.len() > 60 { format!("{}...", &value[..57]) } else { @@ -84,8 +124,8 @@ fn list_settings(filter: Option) -> anyhow::Result<()> { } /// Get a specific setting. -fn get_setting(path: &str) -> anyhow::Result<()> { - let settings = Settings::load(); +async fn get_setting(store: Option<&crate::history::Store>, path: &str) -> anyhow::Result<()> { + let settings = load_settings(store).await; match settings.get(path) { Some(value) => { @@ -99,67 +139,92 @@ fn get_setting(path: &str) -> anyhow::Result<()> { } /// Set a setting value. -fn set_setting(path: &str, value: &str) -> anyhow::Result<()> { - let mut settings = Settings::load(); +async fn set_setting( + store: Option<&crate::history::Store>, + path: &str, + value: &str, +) -> anyhow::Result<()> { + let mut settings = load_settings(store).await; - // Try to set the value settings .set(path, value) .map_err(|e| anyhow::anyhow!("{}", e))?; - // Save to disk - settings.save()?; + // Save to DB if available, otherwise disk + if let Some(store) = store { + let json_value = match serde_json::from_str::(value) { + Ok(v) => v, + Err(_) => serde_json::Value::String(value.to_string()), + }; + store + .set_setting(DEFAULT_USER_ID, path, &json_value) + .await + .map_err(|e| anyhow::anyhow!("Failed to save to database: {}", e))?; + } else { + settings.save()?; + } println!("Set {} = {}", path, value); Ok(()) } /// Reset a setting to default. -fn reset_setting(path: &str) -> anyhow::Result<()> { - let mut settings = Settings::load(); - - // Get the default value for display +async fn reset_setting(store: Option<&crate::history::Store>, path: &str) -> anyhow::Result<()> { let default = Settings::default(); let default_value = default .get(path) .ok_or_else(|| anyhow::anyhow!("Unknown setting: {}", path))?; - // Reset it - settings.reset(path).map_err(|e| anyhow::anyhow!("{}", e))?; - - // Save to disk - settings.save()?; + // Delete from DB (falling back to default) or reset on disk + if let Some(store) = store { + store + .delete_setting(DEFAULT_USER_ID, path) + .await + .map_err(|e| anyhow::anyhow!("Failed to delete setting from database: {}", e))?; + } else { + let mut settings = Settings::load(); + settings.reset(path).map_err(|e| anyhow::anyhow!("{}", e))?; + settings.save()?; + } println!("Reset {} to default: {}", path, default_value); Ok(()) } -/// Show the settings file path. -fn show_path() -> anyhow::Result<()> { - let path = Settings::default_path(); - println!("{}", path.display()); - - if path.exists() { - let metadata = std::fs::metadata(&path)?; - println!(" Size: {} bytes", metadata.len()); - if let Ok(modified) = metadata.modified() { - use std::time::SystemTime; - let duration = SystemTime::now() - .duration_since(modified) - .unwrap_or_default(); - let secs = duration.as_secs(); - if secs < 60 { - println!(" Modified: {} seconds ago", secs); - } else if secs < 3600 { - println!(" Modified: {} minutes ago", secs / 60); - } else if secs < 86400 { - println!(" Modified: {} hours ago", secs / 3600); - } else { - println!(" Modified: {} days ago", secs / 86400); - } - } +/// Show the settings storage info. +fn show_path(has_db: bool) -> anyhow::Result<()> { + if has_db { + println!("Settings stored in: PostgreSQL (settings table)"); + println!( + "Bootstrap config: {}", + crate::bootstrap::BootstrapConfig::default_path().display() + ); } else { - println!(" (does not exist, using defaults)"); + let path = Settings::default_path(); + println!("Settings stored in: {} (disk fallback)", path.display()); + + if path.exists() { + let metadata = std::fs::metadata(&path)?; + println!(" Size: {} bytes", metadata.len()); + if let Ok(modified) = metadata.modified() { + use std::time::SystemTime; + let duration = SystemTime::now() + .duration_since(modified) + .unwrap_or_default(); + let secs = duration.as_secs(); + if secs < 60 { + println!(" Modified: {} seconds ago", secs); + } else if secs < 3600 { + println!(" Modified: {} minutes ago", secs / 60); + } else if secs < 86400 { + println!(" Modified: {} hours ago", secs / 3600); + } else { + println!(" Modified: {} days ago", secs / 86400); + } + } + } else { + println!(" (does not exist, using defaults)"); + } } Ok(()) diff --git a/src/cli/mcp.rs b/src/cli/mcp.rs index db0c65be..311aedc1 100644 --- a/src/cli/mcp.rs +++ b/src/cli/mcp.rs @@ -13,9 +13,7 @@ use crate::secrets::{PostgresSecretsStore, SecretsCrypto, SecretsStore}; use crate::tools::mcp::{ McpClient, McpServerConfig, McpSessionManager, OAuthConfig, auth::{authorize_mcp_server, is_authenticated}, - config::{ - add_mcp_server, get_mcp_server, load_mcp_servers, remove_mcp_server, save_mcp_servers, - }, + config::{self, McpServersFile}, }; #[derive(Subcommand, Debug, Clone)] @@ -173,8 +171,11 @@ async fn add_server( // Validate config.validate()?; - // Save - add_mcp_server(config).await?; + // Save (DB if available, else disk) + let store = connect_store().await; + let mut servers = load_servers(store.as_ref()).await?; + servers.upsert(config); + save_servers(store.as_ref(), &servers).await?; println!(); println!(" ✓ Added MCP server '{}'", name); @@ -192,7 +193,12 @@ async fn add_server( /// Remove an MCP server. async fn remove_server(name: String) -> anyhow::Result<()> { - remove_mcp_server(&name).await?; + let store = connect_store().await; + let mut servers = load_servers(store.as_ref()).await?; + if !servers.remove(&name) { + anyhow::bail!("Server '{}' not found", name); + } + save_servers(store.as_ref(), &servers).await?; println!(); println!(" ✓ Removed MCP server '{}'", name); @@ -203,7 +209,8 @@ async fn remove_server(name: String) -> anyhow::Result<()> { /// List configured MCP servers. async fn list_servers(verbose: bool) -> anyhow::Result<()> { - let servers = load_mcp_servers().await?; + let store = connect_store().await; + let servers = load_servers(store.as_ref()).await?; if servers.servers.is_empty() { println!(); @@ -261,7 +268,12 @@ async fn list_servers(verbose: bool) -> anyhow::Result<()> { /// Authenticate with an MCP server. async fn auth_server(name: String, user_id: String) -> anyhow::Result<()> { // Get server config - let server = get_mcp_server(&name).await?; + let store = connect_store().await; + let servers = load_servers(store.as_ref()).await?; + let server = servers + .get(&name) + .cloned() + .ok_or_else(|| anyhow::anyhow!("Server '{}' not found", name))?; // Initialize secrets store let secrets = get_secrets_store().await?; @@ -329,7 +341,12 @@ async fn auth_server(name: String, user_id: String) -> anyhow::Result<()> { /// Test connection to an MCP server. async fn test_server(name: String, user_id: String) -> anyhow::Result<()> { // Get server config - let server = get_mcp_server(&name).await?; + let store = connect_store().await; + let servers = load_servers(store.as_ref()).await?; + let server = servers + .get(&name) + .cloned() + .ok_or_else(|| anyhow::anyhow!("Server '{}' not found", name))?; println!(); println!(" Testing connection to '{}'...", name); @@ -420,7 +437,8 @@ async fn test_server(name: String, user_id: String) -> anyhow::Result<()> { /// Toggle server enabled/disabled state. async fn toggle_server(name: String, enable: bool, disable: bool) -> anyhow::Result<()> { - let mut servers = load_mcp_servers().await?; + let store = connect_store().await; + let mut servers = load_servers(store.as_ref()).await?; let server = servers .get_mut(&name) @@ -435,7 +453,7 @@ async fn toggle_server(name: String, enable: bool, disable: bool) -> anyhow::Res }; server.enabled = new_state; - save_mcp_servers(&servers).await?; + save_servers(store.as_ref(), &servers).await?; let status = if new_state { "enabled" } else { "disabled" }; println!(); @@ -445,6 +463,37 @@ async fn toggle_server(name: String, enable: bool, disable: bool) -> anyhow::Res Ok(()) } +const DEFAULT_USER_ID: &str = "default"; + +/// Try to connect to the database store for DB-backed config. +async fn connect_store() -> Option { + let config = Config::from_env().ok()?; + let store = Store::new(&config.database).await.ok()?; + store.run_migrations().await.ok()?; + Some(store) +} + +/// Load MCP servers (DB if available, else disk). +async fn load_servers(store: Option<&Store>) -> Result { + if let Some(store) = store { + config::load_mcp_servers_from_db(store, DEFAULT_USER_ID).await + } else { + config::load_mcp_servers().await + } +} + +/// Save MCP servers (DB if available, else disk). +async fn save_servers( + store: Option<&Store>, + servers: &McpServersFile, +) -> Result<(), config::ConfigError> { + if let Some(store) = store { + config::save_mcp_servers_to_db(store, DEFAULT_USER_ID, servers).await + } else { + config::save_mcp_servers(servers).await + } +} + /// Initialize and return the secrets store. async fn get_secrets_store() -> anyhow::Result> { let config = Config::from_env()?; diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 005a22c4..a8a0de3c 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -88,6 +88,42 @@ pub enum Command { /// Show system health and diagnostics Status, + + /// Run as a sandboxed worker inside a Docker container (internal use). + /// This is invoked automatically by the orchestrator, not by users directly. + Worker { + /// Job ID to execute. + #[arg(long)] + job_id: uuid::Uuid, + + /// URL of the orchestrator's internal API. + #[arg(long, default_value = "http://host.docker.internal:50051")] + orchestrator_url: String, + + /// Maximum iterations before stopping. + #[arg(long, default_value = "50")] + max_iterations: u32, + }, + + /// Run as a Claude Code bridge inside a Docker container (internal use). + /// Spawns the `claude` CLI and streams output back to the orchestrator. + ClaudeBridge { + /// Job ID to execute. + #[arg(long)] + job_id: uuid::Uuid, + + /// URL of the orchestrator's internal API. + #[arg(long, default_value = "http://host.docker.internal:50051")] + orchestrator_url: String, + + /// Maximum agentic turns for Claude Code. + #[arg(long, default_value = "50")] + max_turns: u32, + + /// Claude model to use (e.g. "sonnet", "opus"). + #[arg(long, default_value = "sonnet")] + model: String, + }, } impl Cli { diff --git a/src/config.rs b/src/config.rs index caa1d386..fd60caa4 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,4 +1,9 @@ //! Configuration for IronClaw. +//! +//! Settings are loaded with priority: env var > database > default. +//! The database replaces the old `settings.json` file for all settings +//! except the 4 bootstrap fields (database_url, pool_size, secrets key +//! source, onboard_completed) which live in `~/.ironclaw/bootstrap.json`. use std::path::PathBuf; use std::time::Duration; @@ -6,6 +11,7 @@ use std::time::Duration; use secrecy::{ExposeSecret, SecretString}; use crate::error::ConfigError; +use crate::settings::Settings; /// Main configuration for the agent. #[derive(Debug, Clone)] @@ -21,28 +27,67 @@ pub struct Config { pub secrets: SecretsConfig, pub builder: BuilderModeConfig, pub heartbeat: HeartbeatConfig, + pub routines: RoutineConfig, pub sandbox: SandboxModeConfig, + pub claude_code: ClaudeCodeConfig, } impl Config { - /// Load configuration from environment variables. - pub fn from_env() -> Result { - // Load .env file if present (ignore errors if not found) + /// Load configuration from environment variables and the database. + /// + /// Priority: env var > DB settings > default. + /// This is the primary way to load config after DB is connected. + pub async fn from_db( + store: &crate::history::Store, + user_id: &str, + bootstrap: &crate::bootstrap::BootstrapConfig, + ) -> Result { let _ = dotenvy::dotenv(); + // Load all settings from DB into a Settings struct + let db_settings = match store.get_all_settings(user_id).await { + Ok(map) => Settings::from_db_map(&map), + Err(e) => { + tracing::warn!("Failed to load settings from DB, using defaults: {}", e); + Settings::default() + } + }; + + Self::build(bootstrap, &db_settings) + } + + /// Load configuration from environment variables only (no database). + /// + /// Used during early startup before the database is connected, + /// and by CLI commands that don't have DB access. + /// Falls back to legacy `settings.json` on disk if present. + pub fn from_env() -> Result { + let _ = dotenvy::dotenv(); + let bootstrap = crate::bootstrap::BootstrapConfig::load(); + let settings = Settings::load(); + Self::build(&bootstrap, &settings) + } + + /// Build config from bootstrap + settings (shared by from_env and from_db). + fn build( + bootstrap: &crate::bootstrap::BootstrapConfig, + settings: &Settings, + ) -> Result { Ok(Self { - database: DatabaseConfig::from_env()?, - llm: LlmConfig::from_env()?, - embeddings: EmbeddingsConfig::from_env()?, - tunnel: TunnelConfig::from_env()?, - channels: ChannelsConfig::from_env()?, - agent: AgentConfig::from_env()?, - safety: SafetyConfig::from_env()?, - wasm: WasmConfig::from_env()?, - secrets: SecretsConfig::from_env()?, - builder: BuilderModeConfig::from_env()?, - heartbeat: HeartbeatConfig::from_env()?, - sandbox: SandboxModeConfig::from_env()?, + database: DatabaseConfig::resolve(bootstrap)?, + llm: LlmConfig::resolve(settings)?, + embeddings: EmbeddingsConfig::resolve(settings)?, + tunnel: TunnelConfig::resolve(settings)?, + channels: ChannelsConfig::resolve(settings)?, + agent: AgentConfig::resolve(settings)?, + safety: SafetyConfig::resolve()?, + wasm: WasmConfig::resolve()?, + secrets: SecretsConfig::resolve(bootstrap)?, + builder: BuilderModeConfig::resolve()?, + heartbeat: HeartbeatConfig::resolve(settings)?, + routines: RoutineConfig::resolve()?, + sandbox: SandboxModeConfig::resolve()?, + claude_code: ClaudeCodeConfig::resolve()?, }) } } @@ -51,48 +96,17 @@ impl Config { /// /// Used by channels and tools that need public webhook endpoints. /// The tunnel URL is shared across all channels (Telegram, Slack, etc.). -/// -/// # Security Notes -/// -/// **Webhook endpoints** (e.g., `/webhook/telegram`) should NOT use tunnel-level -/// authentication because webhook providers (Telegram, Slack, GitHub) need -/// unauthenticated access to POST updates. Security for webhooks comes from: -/// - Webhook signature verification (provider-specific secrets) -/// - IP allowlisting (if supported by provider) -/// -/// **Non-webhook endpoints** (admin APIs, health checks) CAN be protected using -/// tunnel provider features: -/// - ngrok: Basic Auth, OAuth, IP restrictions -/// - Cloudflare: Access policies, mTLS -/// -/// These protections are configured in the tunnel provider, not here. -/// -/// # Supported Providers -/// -/// - **ngrok**: `ngrok http 8080` -> `https://abc123.ngrok.io` -/// - **Cloudflare Tunnel**: `cloudflared tunnel --url http://localhost:8080` -/// - **localtunnel**: `lt --port 8080` -/// - Any service that provides a public HTTPS URL to localhost #[derive(Debug, Clone, Default)] pub struct TunnelConfig { /// Public URL from tunnel provider (e.g., "https://abc123.ngrok.io"). - /// - /// When set, channels that support webhooks will register their endpoints - /// with this base URL instead of using polling. pub public_url: Option, } impl TunnelConfig { - fn from_env() -> Result { - // Priority: env var > settings file - let public_url = optional_env("TUNNEL_URL")?.or_else(|| { - crate::settings::Settings::load() - .tunnel - .public_url - .filter(|s| !s.is_empty()) - }); + fn resolve(settings: &Settings) -> Result { + let public_url = optional_env("TUNNEL_URL")? + .or_else(|| settings.tunnel.public_url.clone().filter(|s| !s.is_empty())); - // Validate URL format if provided if let Some(ref url) = public_url { if !url.starts_with("https://") { return Err(ConfigError::InvalidValue { @@ -111,8 +125,6 @@ impl TunnelConfig { } /// Get the webhook URL for a given path. - /// - /// Returns `None` if no tunnel is configured. pub fn webhook_url(&self, path: &str) -> Option { self.public_url.as_ref().map(|base| { let base = base.trim_end_matches('/'); @@ -130,18 +142,14 @@ pub struct DatabaseConfig { } impl DatabaseConfig { - fn from_env() -> Result { - let settings = crate::settings::Settings::load(); - - // Priority: env var > settings > error (required) + fn resolve(bootstrap: &crate::bootstrap::BootstrapConfig) -> Result { let url = optional_env("DATABASE_URL")? - .or(settings.database_url.clone()) + .or_else(|| bootstrap.database_url.clone()) .ok_or_else(|| ConfigError::MissingRequired { key: "database_url".to_string(), hint: "Run 'ironclaw onboard' or set DATABASE_URL environment variable".to_string(), })?; - // Priority: env var > settings > default let pool_size = optional_env("DATABASE_POOL_SIZE")? .map(|s| s.parse()) .transpose() @@ -149,7 +157,7 @@ impl DatabaseConfig { key: "DATABASE_POOL_SIZE".to_string(), message: format!("must be a positive integer: {e}"), })? - .or(settings.database_pool_size) + .or(bootstrap.database_pool_size) .unwrap_or(10); Ok(Self { @@ -215,17 +223,15 @@ pub struct NearAiConfig { } impl LlmConfig { - fn from_env() -> Result { + fn resolve(settings: &Settings) -> Result { let api_key = optional_env("NEARAI_API_KEY")?.map(SecretString::from); - // Determine API mode: explicit setting, or infer from API key presence let api_mode = if let Some(mode_str) = optional_env("NEARAI_API_MODE")? { mode_str.parse().map_err(|e| ConfigError::InvalidValue { key: "NEARAI_API_MODE".to_string(), message: e, })? } else if api_key.is_some() { - // If API key is provided, default to chat_completions mode NearAiApiMode::ChatCompletions } else { NearAiApiMode::Responses @@ -233,10 +239,8 @@ impl LlmConfig { Ok(Self { nearai: NearAiConfig { - // Load model from saved settings first, then env, then default - model: crate::settings::Settings::load() - .selected_model - .or_else(|| optional_env("NEARAI_MODEL").ok().flatten()) + model: optional_env("NEARAI_MODEL")? + .or_else(|| settings.selected_model.clone()) .unwrap_or_else(|| { "fireworks::accounts/fireworks/models/llama4-maverick-instruct-basic" .to_string() @@ -265,8 +269,6 @@ pub struct EmbeddingsConfig { /// OpenAI API key (for OpenAI provider). pub openai_api_key: Option, /// Model to use for embeddings. - /// For OpenAI: "text-embedding-3-small", "text-embedding-3-large", "text-embedding-ada-002" - /// For NEAR AI: Uses the configured session for auth. pub model: String, } @@ -282,18 +284,15 @@ impl Default for EmbeddingsConfig { } impl EmbeddingsConfig { - fn from_env() -> Result { - let settings = crate::settings::Settings::load(); + fn resolve(settings: &Settings) -> Result { let openai_api_key = optional_env("OPENAI_API_KEY")?.map(SecretString::from); - // Priority: env var > settings > default let provider = optional_env("EMBEDDING_PROVIDER")? .unwrap_or_else(|| settings.embeddings.provider.clone()); let model = optional_env("EMBEDDING_MODEL")?.unwrap_or_else(|| settings.embeddings.model.clone()); - // Priority: env var > settings > auto-detect from API key let enabled = optional_env("EMBEDDING_ENABLED")? .map(|s| s.parse()) .transpose() @@ -301,10 +300,7 @@ impl EmbeddingsConfig { key: "EMBEDDING_ENABLED".to_string(), message: format!("must be 'true' or 'false': {e}"), })? - .unwrap_or_else(|| { - // Check settings, or auto-enable if API key present - settings.embeddings.enabled || openai_api_key.is_some() - }); + .unwrap_or_else(|| settings.embeddings.enabled || openai_api_key.is_some()); Ok(Self { enabled, @@ -338,6 +334,8 @@ pub struct ChannelsConfig { pub wasm_channels_dir: std::path::PathBuf, /// Whether WASM channels are enabled. pub wasm_channels_enabled: bool, + /// Telegram owner user ID. When set, the bot only responds to this user. + pub telegram_owner_id: Option, } #[derive(Debug, Clone)] @@ -364,7 +362,7 @@ pub struct GatewayConfig { } impl ChannelsConfig { - fn from_env() -> Result { + fn resolve(settings: &Settings) -> Result { let http = if optional_env("HTTP_PORT")?.is_some() || optional_env("HTTP_HOST")?.is_some() { Some(HttpConfig { host: optional_env("HTTP_HOST")?.unwrap_or_else(|| "0.0.0.0".to_string()), @@ -385,7 +383,7 @@ impl ChannelsConfig { let gateway = if optional_env("GATEWAY_ENABLED")? .map(|s| s.to_lowercase() == "true" || s == "1") - .unwrap_or(false) + .unwrap_or(true) { Some(GatewayConfig { host: optional_env("GATEWAY_HOST")?.unwrap_or_else(|| "127.0.0.1".to_string()), @@ -425,6 +423,14 @@ impl ChannelsConfig { message: format!("must be 'true' or 'false': {e}"), })? .unwrap_or(true), + telegram_owner_id: optional_env("TELEGRAM_OWNER_ID")? + .map(|s| s.parse()) + .transpose() + .map_err(|e| ConfigError::InvalidValue { + key: "TELEGRAM_OWNER_ID".to_string(), + message: format!("must be an integer: {e}"), + })? + .or(settings.channels.telegram_owner_id), }) } } @@ -450,14 +456,13 @@ pub struct AgentConfig { pub use_planning: bool, /// Session idle timeout. Sessions inactive longer than this are pruned. pub session_idle_timeout: Duration, + /// Allow chat to use filesystem/shell tools directly (bypass sandbox). + pub allow_local_tools: bool, } impl AgentConfig { - fn from_env() -> Result { - let settings = crate::settings::Settings::load(); - + fn resolve(settings: &Settings) -> Result { Ok(Self { - // Priority: env var > settings > default name: optional_env("AGENT_NAME")?.unwrap_or_else(|| settings.agent.name.clone()), max_parallel_jobs: optional_env("AGENT_MAX_PARALLEL_JOBS")? .map(|s| s.parse()) @@ -523,6 +528,14 @@ impl AgentConfig { })? .unwrap_or(settings.agent.session_idle_timeout_secs), ), + allow_local_tools: optional_env("ALLOW_LOCAL_TOOLS")? + .map(|s| s.parse()) + .transpose() + .map_err(|e| ConfigError::InvalidValue { + key: "ALLOW_LOCAL_TOOLS".to_string(), + message: format!("must be 'true' or 'false': {e}"), + })? + .unwrap_or(false), }) } } @@ -535,7 +548,7 @@ pub struct SafetyConfig { } impl SafetyConfig { - fn from_env() -> Result { + fn resolve() -> Result { Ok(Self { max_output_length: parse_optional_env("SAFETY_MAX_OUTPUT_LENGTH", 100_000)?, injection_check_enabled: optional_env("SAFETY_INJECTION_CHECK_ENABLED")? @@ -573,7 +586,6 @@ pub struct WasmConfig { #[derive(Clone, Default)] pub struct SecretsConfig { /// Master key for encrypting secrets. - /// Source determined by KeySource in settings. pub master_key: Option, /// Whether secrets management is enabled. pub enabled: bool, @@ -592,38 +604,28 @@ impl std::fmt::Debug for SecretsConfig { } impl SecretsConfig { - fn from_env() -> Result { + fn resolve(bootstrap: &crate::bootstrap::BootstrapConfig) -> Result { use crate::settings::KeySource; - let settings = crate::settings::Settings::load(); - - // Priority: env var > keychain (based on settings) > disabled let (master_key, source) = if let Some(env_key) = optional_env("SECRETS_MASTER_KEY")? { - // Env var takes priority (for CI/Docker) (Some(SecretString::from(env_key)), KeySource::Env) } else { - match settings.secrets_master_key_source { - KeySource::Keychain => { - // Try to load from OS keychain - match crate::secrets::keychain::get_master_key() { - Ok(key_bytes) => { - let key_hex: String = - key_bytes.iter().map(|b| format!("{:02x}", b)).collect(); - (Some(SecretString::from(key_hex)), KeySource::Keychain) - } - Err(_) => { - // Keychain configured but key not found - // This might happen if keychain was cleared - tracing::warn!( - "Secrets configured for keychain but key not found. \ - Run 'ironclaw onboard' to reconfigure." - ); - (None, KeySource::None) - } + match bootstrap.secrets_master_key_source { + KeySource::Keychain => match crate::secrets::keychain::get_master_key() { + Ok(key_bytes) => { + let key_hex: String = + key_bytes.iter().map(|b| format!("{:02x}", b)).collect(); + (Some(SecretString::from(key_hex)), KeySource::Keychain) } - } + Err(_) => { + tracing::warn!( + "Secrets configured for keychain but key not found. \ + Run 'ironclaw onboard' to reconfigure." + ); + (None, KeySource::None) + } + }, KeySource::Env => { - // Settings say env, but no env var found tracing::warn!( "Secrets configured for env var but SECRETS_MASTER_KEY not set." ); @@ -635,7 +637,6 @@ impl SecretsConfig { let enabled = master_key.is_some(); - // Validate master key length if provided if let Some(ref key) = master_key { if key.expose_secret().len() < 32 { return Err(ConfigError::InvalidValue { @@ -681,7 +682,7 @@ fn default_tools_dir() -> PathBuf { } impl WasmConfig { - fn from_env() -> Result { + fn resolve() -> Result { Ok(Self { enabled: optional_env("WASM_ENABLED")? .map(|s| s.parse()) @@ -752,7 +753,7 @@ pub struct BuilderModeConfig { impl Default for BuilderModeConfig { fn default() -> Self { Self { - enabled: true, // Builder enabled by default + enabled: true, build_dir: None, max_iterations: 20, timeout_secs: 600, @@ -762,7 +763,7 @@ impl Default for BuilderModeConfig { } impl BuilderModeConfig { - fn from_env() -> Result { + fn resolve() -> Result { Ok(Self { enabled: optional_env("BUILDER_ENABLED")? .map(|s| s.parse()) @@ -771,7 +772,7 @@ impl BuilderModeConfig { key: "BUILDER_ENABLED".to_string(), message: format!("must be 'true' or 'false': {e}"), })? - .unwrap_or(true), // Builder enabled by default + .unwrap_or(true), build_dir: optional_env("BUILDER_DIR")?.map(PathBuf::from), max_iterations: parse_optional_env("BUILDER_MAX_ITERATIONS", 20)?, timeout_secs: parse_optional_env("BUILDER_TIMEOUT_SECS", 600)?, @@ -826,11 +827,8 @@ impl Default for HeartbeatConfig { } impl HeartbeatConfig { - fn from_env() -> Result { - let settings = crate::settings::Settings::load(); - + fn resolve(settings: &Settings) -> Result { Ok(Self { - // Priority: env var > settings > default enabled: optional_env("HEARTBEAT_ENABLED")? .map(|s| s.parse()) .transpose() @@ -848,9 +846,55 @@ impl HeartbeatConfig { })? .unwrap_or(settings.heartbeat.interval_secs), notify_channel: optional_env("HEARTBEAT_NOTIFY_CHANNEL")? - .or(settings.heartbeat.notify_channel.clone()), + .or_else(|| settings.heartbeat.notify_channel.clone()), notify_user: optional_env("HEARTBEAT_NOTIFY_USER")? - .or(settings.heartbeat.notify_user.clone()), + .or_else(|| settings.heartbeat.notify_user.clone()), + }) + } +} + +/// Routines configuration. +#[derive(Debug, Clone)] +pub struct RoutineConfig { + /// Whether the routines system is enabled. + pub enabled: bool, + /// How often (seconds) to poll for cron routines that need firing. + pub cron_check_interval_secs: u64, + /// Max routines executing concurrently across all users. + pub max_concurrent_routines: usize, + /// Default cooldown between fires (seconds). + pub default_cooldown_secs: u64, + /// Max output tokens for lightweight routine LLM calls. + pub max_lightweight_tokens: u32, +} + +impl Default for RoutineConfig { + fn default() -> Self { + Self { + enabled: true, + cron_check_interval_secs: 15, + max_concurrent_routines: 10, + default_cooldown_secs: 300, + max_lightweight_tokens: 4096, + } + } +} + +impl RoutineConfig { + fn resolve() -> Result { + Ok(Self { + enabled: optional_env("ROUTINES_ENABLED")? + .map(|s| s.parse()) + .transpose() + .map_err(|e| ConfigError::InvalidValue { + key: "ROUTINES_ENABLED".to_string(), + message: format!("must be 'true' or 'false': {e}"), + })? + .unwrap_or(true), + cron_check_interval_secs: parse_optional_env("ROUTINES_CRON_INTERVAL", 15)?, + max_concurrent_routines: parse_optional_env("ROUTINES_MAX_CONCURRENT", 10)?, + default_cooldown_secs: parse_optional_env("ROUTINES_DEFAULT_COOLDOWN", 300)?, + max_lightweight_tokens: parse_optional_env("ROUTINES_MAX_TOKENS", 4096)?, }) } } @@ -879,7 +923,7 @@ pub struct SandboxModeConfig { impl Default for SandboxModeConfig { fn default() -> Self { Self { - enabled: true, // Enabled by default + enabled: true, policy: "readonly".to_string(), timeout_secs: 120, memory_limit_mb: 2048, @@ -892,7 +936,7 @@ impl Default for SandboxModeConfig { } impl SandboxModeConfig { - fn from_env() -> Result { + fn resolve() -> Result { let extra_domains = optional_env("SANDBOX_EXTRA_DOMAINS")? .map(|s| s.split(',').map(|d| d.trim().to_string()).collect()) .unwrap_or_default(); @@ -948,6 +992,60 @@ impl SandboxModeConfig { } } +/// Claude Code sandbox configuration. +#[derive(Debug, Clone)] +pub struct ClaudeCodeConfig { + /// Whether Claude Code sandbox mode is available. + pub enabled: bool, + /// Host directory containing Claude auth session (mounted read-only). + pub config_dir: std::path::PathBuf, + /// Claude model to use (e.g. "sonnet", "opus"). + pub model: String, + /// Maximum agentic turns before stopping. + pub max_turns: u32, + /// Memory limit in MB for Claude Code containers (heavier than workers). + pub memory_limit_mb: u64, +} + +impl Default for ClaudeCodeConfig { + fn default() -> Self { + Self { + enabled: false, + config_dir: dirs::home_dir() + .unwrap_or_else(|| std::path::PathBuf::from(".")) + .join(".claude"), + model: "sonnet".to_string(), + max_turns: 50, + memory_limit_mb: 4096, + } + } +} + +impl ClaudeCodeConfig { + fn resolve() -> Result { + let defaults = Self::default(); + Ok(Self { + enabled: optional_env("CLAUDE_CODE_ENABLED")? + .map(|s| s.parse()) + .transpose() + .map_err(|e| ConfigError::InvalidValue { + key: "CLAUDE_CODE_ENABLED".to_string(), + message: format!("must be 'true' or 'false': {e}"), + })? + .unwrap_or(defaults.enabled), + config_dir: optional_env("CLAUDE_CONFIG_DIR")? + .map(std::path::PathBuf::from) + .unwrap_or(defaults.config_dir), + model: optional_env("CLAUDE_CODE_MODEL")?.unwrap_or(defaults.model), + max_turns: parse_optional_env("CLAUDE_CODE_MAX_TURNS", defaults.max_turns)?, + memory_limit_mb: parse_optional_env( + "CLAUDE_CODE_MEMORY_LIMIT_MB", + defaults.memory_limit_mb, + )?, + }) + } +} + // Helper functions fn optional_env(key: &str) -> Result, ConfigError> { diff --git a/src/error.rs b/src/error.rs index 10da699f..189589d0 100644 --- a/src/error.rs +++ b/src/error.rs @@ -39,6 +39,12 @@ pub enum Error { #[error("Workspace error: {0}")] Workspace(#[from] WorkspaceError), + + #[error("Orchestrator error: {0}")] + Orchestrator(#[from] OrchestratorError), + + #[error("Worker error: {0}")] + Worker(#[from] WorkerError), } /// Configuration-related errors. @@ -308,5 +314,52 @@ pub enum WorkspaceError { HeartbeatError { reason: String }, } +/// Orchestrator errors (internal API, container management). +#[derive(Debug, thiserror::Error)] +pub enum OrchestratorError { + #[error("Container creation failed for job {job_id}: {reason}")] + ContainerCreationFailed { job_id: Uuid, reason: String }, + + #[error("Container not found for job {job_id}")] + ContainerNotFound { job_id: Uuid }, + + #[error("Container for job {job_id} is in unexpected state: {state}")] + InvalidContainerState { job_id: Uuid, state: String }, + + #[error("Worker authentication failed: {reason}")] + AuthFailed { reason: String }, + + #[error("Internal API error: {reason}")] + ApiError { reason: String }, + + #[error("Docker error: {reason}")] + Docker { reason: String }, + + #[error("Job {job_id} timed out in container")] + ContainerTimeout { job_id: Uuid }, +} + +/// Worker errors (container-side execution). +#[derive(Debug, thiserror::Error)] +pub enum WorkerError { + #[error("Failed to connect to orchestrator at {url}: {reason}")] + ConnectionFailed { url: String, reason: String }, + + #[error("LLM proxy request failed: {reason}")] + LlmProxyFailed { reason: String }, + + #[error("Secret resolution failed for {secret_name}: {reason}")] + SecretResolveFailed { secret_name: String, reason: String }, + + #[error("Orchestrator returned error for job {job_id}: {reason}")] + OrchestratorRejected { job_id: Uuid, reason: String }, + + #[error("Worker execution failed: {reason}")] + ExecutionFailed { reason: String }, + + #[error("Missing worker token (IRONCLAW_WORKER_TOKEN not set)")] + MissingToken, +} + /// Result type alias for the agent. pub type Result = std::result::Result; diff --git a/src/extensions/manager.rs b/src/extensions/manager.rs index f79d7d95..e39d1980 100644 --- a/src/extensions/manager.rs +++ b/src/extensions/manager.rs @@ -23,9 +23,7 @@ use crate::tools::mcp::auth::{ PkceChallenge, authorize_mcp_server, build_authorization_url, discover_full_oauth_metadata, find_available_port, is_authenticated, register_client, }; -use crate::tools::mcp::config::{ - McpServerConfig, add_mcp_server, get_mcp_server, load_mcp_servers, remove_mcp_server, -}; +use crate::tools::mcp::config::McpServerConfig; use crate::tools::mcp::session::McpSessionManager; use crate::tools::wasm::{WasmToolLoader, WasmToolRuntime, discover_tools}; @@ -58,6 +56,8 @@ pub struct ExtensionManager { /// Tunnel URL for remote OAuth callbacks (used in future iterations). _tunnel_url: Option, user_id: String, + /// Optional database store for DB-backed MCP config. + store: Option>, } impl ExtensionManager { @@ -71,6 +71,7 @@ impl ExtensionManager { wasm_channels_dir: PathBuf, tunnel_url: Option, user_id: String, + store: Option>, ) -> Self { Self { registry: ExtensionRegistry::new(), @@ -85,6 +86,7 @@ impl ExtensionManager { pending_auth: RwLock::new(HashMap::new()), _tunnel_url: tunnel_url, user_id, + store, } } @@ -191,7 +193,7 @@ impl ExtensionManager { // List MCP servers if kind_filter.is_none() || kind_filter == Some(ExtensionKind::McpServer) { - match load_mcp_servers().await { + match self.load_mcp_servers().await { Ok(servers) => { for server in &servers.servers { let authenticated = @@ -304,7 +306,7 @@ impl ExtensionManager { self.mcp_clients.write().await.remove(name); // Remove from config - remove_mcp_server(name) + self.remove_mcp_server(name) .await .map_err(|e| ExtensionError::Config(e.to_string()))?; @@ -342,6 +344,54 @@ impl ExtensionManager { } } + // ── MCP config helpers (DB with disk fallback) ───────────────────── + + async fn load_mcp_servers( + &self, + ) -> Result + { + if let Some(ref store) = self.store { + crate::tools::mcp::config::load_mcp_servers_from_db(store, &self.user_id).await + } else { + crate::tools::mcp::config::load_mcp_servers().await + } + } + + async fn get_mcp_server( + &self, + name: &str, + ) -> Result { + let servers = self.load_mcp_servers().await?; + servers.get(name).cloned().ok_or_else(|| { + crate::tools::mcp::config::ConfigError::ServerNotFound { + name: name.to_string(), + } + }) + } + + async fn add_mcp_server( + &self, + config: McpServerConfig, + ) -> Result<(), crate::tools::mcp::config::ConfigError> { + config.validate()?; + if let Some(ref store) = self.store { + crate::tools::mcp::config::add_mcp_server_db(store, &self.user_id, config).await + } else { + crate::tools::mcp::config::add_mcp_server(config).await + } + } + + async fn remove_mcp_server( + &self, + name: &str, + ) -> Result<(), crate::tools::mcp::config::ConfigError> { + if let Some(ref store) = self.store { + crate::tools::mcp::config::remove_mcp_server_db(store, &self.user_id, name).await + } else { + crate::tools::mcp::config::remove_mcp_server(name).await + } + } + // ── Private helpers ────────────────────────────────────────────────── async fn install_from_entry( @@ -381,7 +431,7 @@ impl ExtensionManager { url: &str, ) -> Result { // Check if already installed - if get_mcp_server(name).await.is_ok() { + if self.get_mcp_server(name).await.is_ok() { return Err(ExtensionError::AlreadyInstalled(name.to_string())); } @@ -390,7 +440,7 @@ impl ExtensionManager { .validate() .map_err(|e| ExtensionError::InvalidUrl(e.to_string()))?; - add_mcp_server(config) + self.add_mcp_server(config) .await .map_err(|e| ExtensionError::Config(e.to_string()))?; @@ -465,7 +515,8 @@ impl ExtensionManager { name: &str, token: Option<&str>, ) -> Result { - let server = get_mcp_server(name) + let server = self + .get_mcp_server(name) .await .map_err(|e| ExtensionError::NotInstalled(e.to_string()))?; @@ -784,7 +835,8 @@ impl ExtensionManager { } } - let server = get_mcp_server(name) + let server = self + .get_mcp_server(name) .await .map_err(|e| ExtensionError::NotInstalled(e.to_string()))?; @@ -893,7 +945,7 @@ impl ExtensionManager { /// Determine what kind of installed extension this is. async fn determine_installed_kind(&self, name: &str) -> Result { // Check MCP servers first - if get_mcp_server(name).await.is_ok() { + if self.get_mcp_server(name).await.is_ok() { return Ok(ExtensionKind::McpServer); } diff --git a/src/history/mod.rs b/src/history/mod.rs index e8254479..a9e5df35 100644 --- a/src/history/mod.rs +++ b/src/history/mod.rs @@ -9,4 +9,7 @@ mod analytics; mod store; pub use analytics::{JobStats, ToolStats}; -pub use store::{LlmCallRecord, Store}; +pub use store::{ + ConversationMessage, ConversationSummary, JobEventRecord, LlmCallRecord, SandboxJobRecord, + SandboxJobSummary, Store, +}; diff --git a/src/history/store.rs b/src/history/store.rs index 0850701d..c5f8d8bd 100644 --- a/src/history/store.rs +++ b/src/history/store.rs @@ -1,5 +1,6 @@ //! PostgreSQL store for persisting agent data. +use chrono::{DateTime, Utc}; use deadpool_postgres::{Config, Pool, Runtime}; use rust_decimal::Decimal; use tokio_postgres::NoTls; @@ -47,11 +48,16 @@ impl Store { Ok(Self { pool }) } - /// Run database migrations. + /// Run database migrations (embedded via refinery). pub async fn run_migrations(&self) -> Result<(), DatabaseError> { - // For now, we assume migrations are run externally via refinery or similar - // In production, you'd integrate refinery here - tracing::info!("Database migrations should be run via: refinery migrate -c refinery.toml"); + use refinery::embed_migrations; + embed_migrations!("migrations"); + + let mut client = self.pool.get().await?; + migrations::runner() + .run_async(&mut **client) + .await + .map_err(|e| DatabaseError::Migration(e.to_string()))?; Ok(()) } @@ -176,7 +182,7 @@ impl Store { let row = conn .query_opt( r#" - SELECT id, conversation_id, title, description, category, status, + SELECT id, conversation_id, title, description, category, status, user_id, budget_amount, budget_token, bid_amount, estimated_cost, estimated_time_secs, actual_cost, repair_attempts, created_at, started_at, completed_at FROM agent_jobs WHERE id = $1 @@ -194,7 +200,7 @@ impl Store { Ok(Some(JobContext { job_id: row.get("id"), state, - user_id: "default".to_string(), // Not stored in DB yet + user_id: row.get::<_, String>("user_id"), conversation_id: row.get("conversation_id"), title: row.get("title"), description: row.get("description"), @@ -429,6 +435,946 @@ impl Store { } } +// ==================== Sandbox Jobs ==================== + +/// Record for a sandbox container job, persisted in the `agent_jobs` table +/// with `source = 'sandbox'`. +#[derive(Debug, Clone)] +pub struct SandboxJobRecord { + pub id: Uuid, + pub task: String, + pub status: String, + pub user_id: String, + pub project_dir: String, + pub success: Option, + pub failure_reason: Option, + pub created_at: DateTime, + pub started_at: Option>, + pub completed_at: Option>, +} + +/// Summary of sandbox job counts grouped by status. +#[derive(Debug, Clone, Default)] +pub struct SandboxJobSummary { + pub total: usize, + pub creating: usize, + pub running: usize, + pub completed: usize, + pub failed: usize, + pub interrupted: usize, +} + +impl Store { + /// Insert a new sandbox job into `agent_jobs`. + pub async fn save_sandbox_job(&self, job: &SandboxJobRecord) -> Result<(), DatabaseError> { + let conn = self.conn().await?; + conn.execute( + r#" + INSERT INTO agent_jobs ( + id, title, description, status, source, user_id, project_dir, + success, failure_reason, created_at, started_at, completed_at + ) VALUES ($1, $2, '', $3, 'sandbox', $4, $5, $6, $7, $8, $9, $10) + ON CONFLICT (id) DO UPDATE SET + status = EXCLUDED.status, + success = EXCLUDED.success, + failure_reason = EXCLUDED.failure_reason, + started_at = EXCLUDED.started_at, + completed_at = EXCLUDED.completed_at + "#, + &[ + &job.id, + &job.task, + &job.status, + &job.user_id, + &job.project_dir, + &job.success, + &job.failure_reason, + &job.created_at, + &job.started_at, + &job.completed_at, + ], + ) + .await?; + Ok(()) + } + + /// Get a sandbox job by ID. + pub async fn get_sandbox_job( + &self, + id: Uuid, + ) -> Result, DatabaseError> { + let conn = self.conn().await?; + let row = conn + .query_opt( + r#" + SELECT id, title, status, user_id, project_dir, + success, failure_reason, created_at, started_at, completed_at + FROM agent_jobs WHERE id = $1 AND source = 'sandbox' + "#, + &[&id], + ) + .await?; + + Ok(row.map(|r| SandboxJobRecord { + id: r.get("id"), + task: r.get("title"), + status: r.get("status"), + user_id: r.get("user_id"), + project_dir: r + .get::<_, Option>("project_dir") + .unwrap_or_default(), + success: r.get("success"), + failure_reason: r.get("failure_reason"), + created_at: r.get("created_at"), + started_at: r.get("started_at"), + completed_at: r.get("completed_at"), + })) + } + + /// List all sandbox jobs, most recent first. + pub async fn list_sandbox_jobs(&self) -> Result, DatabaseError> { + let conn = self.conn().await?; + let rows = conn + .query( + r#" + SELECT id, title, status, user_id, project_dir, + success, failure_reason, created_at, started_at, completed_at + FROM agent_jobs WHERE source = 'sandbox' + ORDER BY created_at DESC + "#, + &[], + ) + .await?; + + Ok(rows + .iter() + .map(|r| SandboxJobRecord { + id: r.get("id"), + task: r.get("title"), + status: r.get("status"), + user_id: r.get("user_id"), + project_dir: r + .get::<_, Option>("project_dir") + .unwrap_or_default(), + success: r.get("success"), + failure_reason: r.get("failure_reason"), + created_at: r.get("created_at"), + started_at: r.get("started_at"), + completed_at: r.get("completed_at"), + }) + .collect()) + } + + /// Update sandbox job status and optional timestamps/result. + pub async fn update_sandbox_job_status( + &self, + id: Uuid, + status: &str, + success: Option, + message: Option<&str>, + started_at: Option>, + completed_at: Option>, + ) -> Result<(), DatabaseError> { + let conn = self.conn().await?; + conn.execute( + r#" + UPDATE agent_jobs SET + status = $2, + success = COALESCE($3, success), + failure_reason = COALESCE($4, failure_reason), + started_at = COALESCE($5, started_at), + completed_at = COALESCE($6, completed_at) + WHERE id = $1 AND source = 'sandbox' + "#, + &[&id, &status, &success, &message, &started_at, &completed_at], + ) + .await?; + Ok(()) + } + + /// Mark any sandbox jobs left in "running" or "creating" as "interrupted". + /// + /// Called on startup to handle jobs that were running when the process died. + pub async fn cleanup_stale_sandbox_jobs(&self) -> Result { + let conn = self.conn().await?; + let count = conn + .execute( + r#" + UPDATE agent_jobs SET + status = 'interrupted', + failure_reason = 'Process restarted', + completed_at = NOW() + WHERE source = 'sandbox' AND status IN ('running', 'creating') + "#, + &[], + ) + .await?; + if count > 0 { + tracing::info!("Marked {} stale sandbox jobs as interrupted", count); + } + Ok(count) + } + + /// Get a summary of sandbox job counts by status. + pub async fn sandbox_job_summary(&self) -> Result { + let conn = self.conn().await?; + let rows = conn + .query( + "SELECT status, COUNT(*) as cnt FROM agent_jobs WHERE source = 'sandbox' GROUP BY status", + &[], + ) + .await?; + + let mut summary = SandboxJobSummary::default(); + for row in &rows { + let status: String = row.get("status"); + let count: i64 = row.get("cnt"); + let c = count as usize; + summary.total += c; + match status.as_str() { + "creating" => summary.creating += c, + "running" => summary.running += c, + "completed" => summary.completed += c, + "failed" => summary.failed += c, + "interrupted" => summary.interrupted += c, + _ => {} + } + } + Ok(summary) + } +} + +// ==================== Job Events ==================== + +/// A persisted job streaming event (from worker or Claude Code bridge). +#[derive(Debug, Clone)] +pub struct JobEventRecord { + pub id: i64, + pub job_id: Uuid, + pub event_type: String, + pub data: serde_json::Value, + pub created_at: DateTime, +} + +impl Store { + /// Persist a job event (fire-and-forget from orchestrator handler). + pub async fn save_job_event( + &self, + job_id: Uuid, + event_type: &str, + data: &serde_json::Value, + ) -> Result<(), DatabaseError> { + let conn = self.conn().await?; + conn.execute( + r#" + INSERT INTO job_events (job_id, event_type, data) + VALUES ($1, $2, $3) + "#, + &[&job_id, &event_type, data], + ) + .await?; + Ok(()) + } + + /// Load all job events for a job, ordered by id. + pub async fn list_job_events( + &self, + job_id: Uuid, + ) -> Result, DatabaseError> { + let conn = self.conn().await?; + let rows = conn + .query( + r#" + SELECT id, job_id, event_type, data, created_at + FROM job_events + WHERE job_id = $1 + ORDER BY id ASC + "#, + &[&job_id], + ) + .await?; + Ok(rows + .iter() + .map(|r| JobEventRecord { + id: r.get("id"), + job_id: r.get("job_id"), + event_type: r.get("event_type"), + data: r.get("data"), + created_at: r.get("created_at"), + }) + .collect()) + } + + /// Update the job_mode column for a sandbox job. + pub async fn update_sandbox_job_mode(&self, id: Uuid, mode: &str) -> Result<(), DatabaseError> { + let conn = self.conn().await?; + conn.execute( + "UPDATE agent_jobs SET job_mode = $2 WHERE id = $1", + &[&id, &mode], + ) + .await?; + Ok(()) + } + + /// Get the job_mode for a sandbox job. + pub async fn get_sandbox_job_mode(&self, id: Uuid) -> Result, DatabaseError> { + let conn = self.conn().await?; + let row = conn + .query_opt("SELECT job_mode FROM agent_jobs WHERE id = $1", &[&id]) + .await?; + Ok(row.map(|r| r.get("job_mode"))) + } +} + +// ==================== Routines ==================== + +use crate::agent::routine::{ + NotifyConfig, Routine, RoutineAction, RoutineGuardrails, RoutineRun, RunStatus, Trigger, +}; + +impl Store { + /// Create a new routine. + pub async fn create_routine(&self, routine: &Routine) -> Result<(), DatabaseError> { + let conn = self.conn().await?; + let trigger_type = routine.trigger.type_tag(); + let trigger_config = routine.trigger.to_config_json(); + let action_type = routine.action.type_tag(); + let action_config = routine.action.to_config_json(); + let cooldown_secs = routine.guardrails.cooldown.as_secs() as i32; + let max_concurrent = routine.guardrails.max_concurrent as i32; + let dedup_window_secs = routine.guardrails.dedup_window.map(|d| d.as_secs() as i32); + + conn.execute( + r#" + INSERT INTO routines ( + id, name, description, user_id, enabled, + trigger_type, trigger_config, action_type, action_config, + cooldown_secs, max_concurrent, dedup_window_secs, + notify_channel, notify_user, notify_on_success, notify_on_failure, notify_on_attention, + state, next_fire_at, created_at, updated_at + ) VALUES ( + $1, $2, $3, $4, $5, + $6, $7, $8, $9, + $10, $11, $12, + $13, $14, $15, $16, $17, + $18, $19, $20, $21 + ) + "#, + &[ + &routine.id, + &routine.name, + &routine.description, + &routine.user_id, + &routine.enabled, + &trigger_type, + &trigger_config, + &action_type, + &action_config, + &cooldown_secs, + &max_concurrent, + &dedup_window_secs, + &routine.notify.channel, + &routine.notify.user, + &routine.notify.on_success, + &routine.notify.on_failure, + &routine.notify.on_attention, + &routine.state, + &routine.next_fire_at, + &routine.created_at, + &routine.updated_at, + ], + ) + .await?; + + Ok(()) + } + + /// Get a routine by ID. + pub async fn get_routine(&self, id: Uuid) -> Result, DatabaseError> { + let conn = self.conn().await?; + let row = conn + .query_opt("SELECT * FROM routines WHERE id = $1", &[&id]) + .await?; + row.map(|r| row_to_routine(&r)).transpose() + } + + /// Get a routine by user_id and name. + pub async fn get_routine_by_name( + &self, + user_id: &str, + name: &str, + ) -> Result, DatabaseError> { + let conn = self.conn().await?; + let row = conn + .query_opt( + "SELECT * FROM routines WHERE user_id = $1 AND name = $2", + &[&user_id, &name], + ) + .await?; + row.map(|r| row_to_routine(&r)).transpose() + } + + /// List routines for a user. + pub async fn list_routines(&self, user_id: &str) -> Result, DatabaseError> { + let conn = self.conn().await?; + let rows = conn + .query( + "SELECT * FROM routines WHERE user_id = $1 ORDER BY name", + &[&user_id], + ) + .await?; + rows.iter().map(row_to_routine).collect() + } + + /// List all enabled routines with event triggers (for event matching). + pub async fn list_event_routines(&self) -> Result, DatabaseError> { + let conn = self.conn().await?; + let rows = conn + .query( + "SELECT * FROM routines WHERE enabled AND trigger_type = 'event'", + &[], + ) + .await?; + rows.iter().map(row_to_routine).collect() + } + + /// List all enabled cron routines whose next_fire_at <= now. + pub async fn list_due_cron_routines(&self) -> Result, DatabaseError> { + let conn = self.conn().await?; + let now = Utc::now(); + let rows = conn + .query( + r#" + SELECT * FROM routines + WHERE enabled + AND trigger_type = 'cron' + AND next_fire_at IS NOT NULL + AND next_fire_at <= $1 + "#, + &[&now], + ) + .await?; + rows.iter().map(row_to_routine).collect() + } + + /// Update a routine (full replacement of mutable fields). + pub async fn update_routine(&self, routine: &Routine) -> Result<(), DatabaseError> { + let conn = self.conn().await?; + let trigger_type = routine.trigger.type_tag(); + let trigger_config = routine.trigger.to_config_json(); + let action_type = routine.action.type_tag(); + let action_config = routine.action.to_config_json(); + let cooldown_secs = routine.guardrails.cooldown.as_secs() as i32; + let max_concurrent = routine.guardrails.max_concurrent as i32; + let dedup_window_secs = routine.guardrails.dedup_window.map(|d| d.as_secs() as i32); + + conn.execute( + r#" + UPDATE routines SET + name = $2, description = $3, enabled = $4, + trigger_type = $5, trigger_config = $6, + action_type = $7, action_config = $8, + cooldown_secs = $9, max_concurrent = $10, dedup_window_secs = $11, + notify_channel = $12, notify_user = $13, + notify_on_success = $14, notify_on_failure = $15, notify_on_attention = $16, + state = $17, next_fire_at = $18, + updated_at = now() + WHERE id = $1 + "#, + &[ + &routine.id, + &routine.name, + &routine.description, + &routine.enabled, + &trigger_type, + &trigger_config, + &action_type, + &action_config, + &cooldown_secs, + &max_concurrent, + &dedup_window_secs, + &routine.notify.channel, + &routine.notify.user, + &routine.notify.on_success, + &routine.notify.on_failure, + &routine.notify.on_attention, + &routine.state, + &routine.next_fire_at, + ], + ) + .await?; + Ok(()) + } + + /// Update runtime state after a routine fires. + pub async fn update_routine_runtime( + &self, + id: Uuid, + last_run_at: DateTime, + next_fire_at: Option>, + run_count: u64, + consecutive_failures: u32, + state: &serde_json::Value, + ) -> Result<(), DatabaseError> { + let conn = self.conn().await?; + conn.execute( + r#" + UPDATE routines SET + last_run_at = $2, next_fire_at = $3, + run_count = $4, consecutive_failures = $5, + state = $6, updated_at = now() + WHERE id = $1 + "#, + &[ + &id, + &last_run_at, + &next_fire_at, + &(run_count as i64), + &(consecutive_failures as i32), + state, + ], + ) + .await?; + Ok(()) + } + + /// Delete a routine. + pub async fn delete_routine(&self, id: Uuid) -> Result { + let conn = self.conn().await?; + let count = conn + .execute("DELETE FROM routines WHERE id = $1", &[&id]) + .await?; + Ok(count > 0) + } + + // ==================== Routine Runs ==================== + + /// Record a routine run starting. + pub async fn create_routine_run(&self, run: &RoutineRun) -> Result<(), DatabaseError> { + let conn = self.conn().await?; + let status = run.status.to_string(); + conn.execute( + r#" + INSERT INTO routine_runs ( + id, routine_id, trigger_type, trigger_detail, + started_at, status, job_id + ) VALUES ($1, $2, $3, $4, $5, $6, $7) + "#, + &[ + &run.id, + &run.routine_id, + &run.trigger_type, + &run.trigger_detail, + &run.started_at, + &status, + &run.job_id, + ], + ) + .await?; + Ok(()) + } + + /// Complete a routine run. + pub async fn complete_routine_run( + &self, + id: Uuid, + status: RunStatus, + result_summary: Option<&str>, + tokens_used: Option, + ) -> Result<(), DatabaseError> { + let conn = self.conn().await?; + let status_str = status.to_string(); + let now = Utc::now(); + conn.execute( + r#" + UPDATE routine_runs SET + completed_at = $2, status = $3, + result_summary = $4, tokens_used = $5 + WHERE id = $1 + "#, + &[&id, &now, &status_str, &result_summary, &tokens_used], + ) + .await?; + Ok(()) + } + + /// List recent runs for a routine. + pub async fn list_routine_runs( + &self, + routine_id: Uuid, + limit: i64, + ) -> Result, DatabaseError> { + let conn = self.conn().await?; + let rows = conn + .query( + r#" + SELECT * FROM routine_runs + WHERE routine_id = $1 + ORDER BY started_at DESC + LIMIT $2 + "#, + &[&routine_id, &limit], + ) + .await?; + rows.iter().map(row_to_routine_run).collect() + } + + /// Count currently running runs for a routine. + pub async fn count_running_routine_runs(&self, routine_id: Uuid) -> Result { + let conn = self.conn().await?; + let row = conn + .query_one( + "SELECT COUNT(*) as cnt FROM routine_runs WHERE routine_id = $1 AND status = 'running'", + &[&routine_id], + ) + .await?; + Ok(row.get("cnt")) + } +} + +fn row_to_routine(row: &tokio_postgres::Row) -> Result { + let trigger_type: String = row.get("trigger_type"); + let trigger_config: serde_json::Value = row.get("trigger_config"); + let action_type: String = row.get("action_type"); + let action_config: serde_json::Value = row.get("action_config"); + let cooldown_secs: i32 = row.get("cooldown_secs"); + let max_concurrent: i32 = row.get("max_concurrent"); + let dedup_window_secs: Option = row.get("dedup_window_secs"); + + let trigger = + Trigger::from_db(&trigger_type, trigger_config).map_err(DatabaseError::Serialization)?; + let action = RoutineAction::from_db(&action_type, action_config) + .map_err(DatabaseError::Serialization)?; + + Ok(Routine { + id: row.get("id"), + name: row.get("name"), + description: row.get("description"), + user_id: row.get("user_id"), + enabled: row.get("enabled"), + trigger, + action, + guardrails: RoutineGuardrails { + cooldown: std::time::Duration::from_secs(cooldown_secs as u64), + max_concurrent: max_concurrent as u32, + dedup_window: dedup_window_secs.map(|s| std::time::Duration::from_secs(s as u64)), + }, + notify: NotifyConfig { + channel: row.get("notify_channel"), + user: row.get("notify_user"), + on_attention: row.get("notify_on_attention"), + on_failure: row.get("notify_on_failure"), + on_success: row.get("notify_on_success"), + }, + last_run_at: row.get("last_run_at"), + next_fire_at: row.get("next_fire_at"), + run_count: row.get::<_, i64>("run_count") as u64, + consecutive_failures: row.get::<_, i32>("consecutive_failures") as u32, + state: row.get("state"), + created_at: row.get("created_at"), + updated_at: row.get("updated_at"), + }) +} + +fn row_to_routine_run(row: &tokio_postgres::Row) -> Result { + let status_str: String = row.get("status"); + let status: RunStatus = status_str + .parse() + .map_err(|e: String| DatabaseError::Serialization(e))?; + + Ok(RoutineRun { + id: row.get("id"), + routine_id: row.get("routine_id"), + trigger_type: row.get("trigger_type"), + trigger_detail: row.get("trigger_detail"), + started_at: row.get("started_at"), + completed_at: row.get("completed_at"), + status, + result_summary: row.get("result_summary"), + tokens_used: row.get("tokens_used"), + job_id: row.get("job_id"), + created_at: row.get("created_at"), + }) +} + +// ==================== Conversation Persistence ==================== + +/// Summary of a conversation for the thread list. +#[derive(Debug, Clone)] +pub struct ConversationSummary { + pub id: Uuid, + /// First user message, truncated to 100 chars. + pub title: Option, + pub message_count: i64, + pub started_at: DateTime, + pub last_activity: DateTime, + /// Thread type extracted from metadata (e.g. "assistant", "thread"). + pub thread_type: Option, +} + +/// A single message in a conversation. +#[derive(Debug, Clone)] +pub struct ConversationMessage { + pub id: Uuid, + pub role: String, + pub content: String, + pub created_at: DateTime, +} + +impl Store { + /// Ensure a conversation row exists for a given UUID. + /// + /// Idempotent: inserts on first call, bumps `last_activity` on subsequent calls. + pub async fn ensure_conversation( + &self, + id: Uuid, + channel: &str, + user_id: &str, + thread_id: Option<&str>, + ) -> Result<(), DatabaseError> { + let conn = self.conn().await?; + conn.execute( + r#" + INSERT INTO conversations (id, channel, user_id, thread_id) + VALUES ($1, $2, $3, $4) + ON CONFLICT (id) DO UPDATE SET last_activity = NOW() + "#, + &[&id, &channel, &user_id, &thread_id], + ) + .await?; + Ok(()) + } + + /// List conversations with a title derived from the first user message. + pub async fn list_conversations_with_preview( + &self, + user_id: &str, + channel: &str, + limit: i64, + ) -> Result, DatabaseError> { + let conn = self.conn().await?; + let rows = conn + .query( + r#" + SELECT + c.id, + c.started_at, + c.last_activity, + c.metadata, + (SELECT COUNT(*) FROM conversation_messages m WHERE m.conversation_id = c.id) AS message_count, + (SELECT LEFT(m2.content, 100) + FROM conversation_messages m2 + WHERE m2.conversation_id = c.id AND m2.role = 'user' + ORDER BY m2.created_at ASC + LIMIT 1 + ) AS title + FROM conversations c + WHERE c.user_id = $1 AND c.channel = $2 + ORDER BY c.last_activity DESC + LIMIT $3 + "#, + &[&user_id, &channel, &limit], + ) + .await?; + + Ok(rows + .iter() + .map(|r| { + let metadata: serde_json::Value = r.get("metadata"); + let thread_type = metadata + .get("thread_type") + .and_then(|v| v.as_str()) + .map(String::from); + ConversationSummary { + id: r.get("id"), + title: r.get("title"), + message_count: r.get("message_count"), + started_at: r.get("started_at"), + last_activity: r.get("last_activity"), + thread_type, + } + }) + .collect()) + } + + /// Get or create the singleton "assistant" conversation for a user+channel. + /// + /// Looks for a conversation where `metadata->>'thread_type' = 'assistant'`. + /// Creates one if it doesn't exist. + pub async fn get_or_create_assistant_conversation( + &self, + user_id: &str, + channel: &str, + ) -> Result { + let conn = self.conn().await?; + + // Try to find existing assistant conversation + let row = conn + .query_opt( + r#" + SELECT id FROM conversations + WHERE user_id = $1 AND channel = $2 AND metadata->>'thread_type' = 'assistant' + LIMIT 1 + "#, + &[&user_id, &channel], + ) + .await?; + + if let Some(row) = row { + return Ok(row.get("id")); + } + + // Create a new assistant conversation + let id = Uuid::new_v4(); + let metadata = serde_json::json!({"thread_type": "assistant", "title": "Assistant"}); + conn.execute( + r#" + INSERT INTO conversations (id, channel, user_id, metadata) + VALUES ($1, $2, $3, $4) + "#, + &[&id, &channel, &user_id, &metadata], + ) + .await?; + + Ok(id) + } + + /// Create a conversation with specific metadata. + pub async fn create_conversation_with_metadata( + &self, + channel: &str, + user_id: &str, + metadata: &serde_json::Value, + ) -> Result { + let conn = self.conn().await?; + let id = Uuid::new_v4(); + + conn.execute( + "INSERT INTO conversations (id, channel, user_id, metadata) VALUES ($1, $2, $3, $4)", + &[&id, &channel, &user_id, metadata], + ) + .await?; + + Ok(id) + } + + /// Load messages for a conversation with cursor-based pagination. + /// + /// Returns `(messages_oldest_first, has_more)`. + /// Pass `before` as a cursor to load older messages. + pub async fn list_conversation_messages_paginated( + &self, + conversation_id: Uuid, + before: Option>, + limit: i64, + ) -> Result<(Vec, bool), DatabaseError> { + let conn = self.conn().await?; + let fetch_limit = limit + 1; // Fetch one extra to determine has_more + + let rows = if let Some(before_ts) = before { + conn.query( + r#" + SELECT id, role, content, created_at + FROM conversation_messages + WHERE conversation_id = $1 AND created_at < $2 + ORDER BY created_at DESC + LIMIT $3 + "#, + &[&conversation_id, &before_ts, &fetch_limit], + ) + .await? + } else { + conn.query( + r#" + SELECT id, role, content, created_at + FROM conversation_messages + WHERE conversation_id = $1 + ORDER BY created_at DESC + LIMIT $2 + "#, + &[&conversation_id, &fetch_limit], + ) + .await? + }; + + let has_more = rows.len() as i64 > limit; + let take_count = (rows.len() as i64).min(limit) as usize; + + // Rows come newest-first from DB; reverse so caller gets oldest-first + let mut messages: Vec = rows + .iter() + .take(take_count) + .map(|r| ConversationMessage { + id: r.get("id"), + role: r.get("role"), + content: r.get("content"), + created_at: r.get("created_at"), + }) + .collect(); + messages.reverse(); + + Ok((messages, has_more)) + } + + /// Merge a single key into a conversation's metadata JSONB. + pub async fn update_conversation_metadata_field( + &self, + id: Uuid, + key: &str, + value: &serde_json::Value, + ) -> Result<(), DatabaseError> { + let conn = self.conn().await?; + let patch = serde_json::json!({ key: value }); + conn.execute( + "UPDATE conversations SET metadata = metadata || $2 WHERE id = $1", + &[&id, &patch], + ) + .await?; + Ok(()) + } + + /// Read the metadata JSONB for a conversation. + pub async fn get_conversation_metadata( + &self, + id: Uuid, + ) -> Result, DatabaseError> { + let conn = self.conn().await?; + let row = conn + .query_opt("SELECT metadata FROM conversations WHERE id = $1", &[&id]) + .await?; + Ok(row.map(|r| r.get::<_, serde_json::Value>(0))) + } + + /// Load all messages for a conversation, ordered chronologically. + pub async fn list_conversation_messages( + &self, + conversation_id: Uuid, + ) -> Result, DatabaseError> { + let conn = self.conn().await?; + let rows = conn + .query( + r#" + SELECT id, role, content, created_at + FROM conversation_messages + WHERE conversation_id = $1 + ORDER BY created_at ASC + "#, + &[&conversation_id], + ) + .await?; + + Ok(rows + .iter() + .map(|r| ConversationMessage { + id: r.get("id"), + role: r.get("role"), + content: r.get("content"), + created_at: r.get("created_at"), + }) + .collect()) + } +} + fn parse_job_state(s: &str) -> JobState { match s { "pending" => JobState::Pending, @@ -529,3 +1475,168 @@ impl Store { Ok(()) } } + +// ==================== Settings ==================== + +/// A single setting row from the database. +#[derive(Debug, Clone)] +pub struct SettingRow { + pub key: String, + pub value: serde_json::Value, + pub updated_at: DateTime, +} + +impl Store { + /// Get a single setting by key. + pub async fn get_setting( + &self, + user_id: &str, + key: &str, + ) -> Result, DatabaseError> { + let conn = self.conn().await?; + let row = conn + .query_opt( + "SELECT value FROM settings WHERE user_id = $1 AND key = $2", + &[&user_id, &key], + ) + .await?; + Ok(row.map(|r| r.get("value"))) + } + + /// Get a single setting with full metadata. + pub async fn get_setting_full( + &self, + user_id: &str, + key: &str, + ) -> Result, DatabaseError> { + let conn = self.conn().await?; + let row = conn + .query_opt( + "SELECT key, value, updated_at FROM settings WHERE user_id = $1 AND key = $2", + &[&user_id, &key], + ) + .await?; + Ok(row.map(|r| SettingRow { + key: r.get("key"), + value: r.get("value"), + updated_at: r.get("updated_at"), + })) + } + + /// Set a single setting (upsert). + pub async fn set_setting( + &self, + user_id: &str, + key: &str, + value: &serde_json::Value, + ) -> Result<(), DatabaseError> { + let conn = self.conn().await?; + conn.execute( + r#" + INSERT INTO settings (user_id, key, value, updated_at) + VALUES ($1, $2, $3, NOW()) + ON CONFLICT (user_id, key) DO UPDATE SET + value = EXCLUDED.value, + updated_at = NOW() + "#, + &[&user_id, &key, value], + ) + .await?; + Ok(()) + } + + /// Delete a single setting (reset to default). + pub async fn delete_setting(&self, user_id: &str, key: &str) -> Result { + let conn = self.conn().await?; + let count = conn + .execute( + "DELETE FROM settings WHERE user_id = $1 AND key = $2", + &[&user_id, &key], + ) + .await?; + Ok(count > 0) + } + + /// List all settings for a user (with metadata). + pub async fn list_settings(&self, user_id: &str) -> Result, DatabaseError> { + let conn = self.conn().await?; + let rows = conn + .query( + "SELECT key, value, updated_at FROM settings WHERE user_id = $1 ORDER BY key", + &[&user_id], + ) + .await?; + Ok(rows + .iter() + .map(|r| SettingRow { + key: r.get("key"), + value: r.get("value"), + updated_at: r.get("updated_at"), + }) + .collect()) + } + + /// Get all settings as a flat key-value map. + pub async fn get_all_settings( + &self, + user_id: &str, + ) -> Result, DatabaseError> { + let conn = self.conn().await?; + let rows = conn + .query( + "SELECT key, value FROM settings WHERE user_id = $1", + &[&user_id], + ) + .await?; + Ok(rows + .iter() + .map(|r| { + let key: String = r.get("key"); + let value: serde_json::Value = r.get("value"); + (key, value) + }) + .collect()) + } + + /// Bulk-write settings (used for migration/import). + /// + /// Each entry is upserted individually within a single transaction. + pub async fn set_all_settings( + &self, + user_id: &str, + settings: &std::collections::HashMap, + ) -> Result<(), DatabaseError> { + let mut conn = self.conn().await?; + let tx = conn.transaction().await?; + + for (key, value) in settings { + tx.execute( + r#" + INSERT INTO settings (user_id, key, value, updated_at) + VALUES ($1, $2, $3, NOW()) + ON CONFLICT (user_id, key) DO UPDATE SET + value = EXCLUDED.value, + updated_at = NOW() + "#, + &[&user_id, &key, value], + ) + .await?; + } + + tx.commit().await?; + Ok(()) + } + + /// Check if the settings table has any rows for a user. + pub async fn has_settings(&self, user_id: &str) -> Result { + let conn = self.conn().await?; + let row = conn + .query_one( + "SELECT COUNT(*) as cnt FROM settings WHERE user_id = $1", + &[&user_id], + ) + .await?; + let count: i64 = row.get("cnt"); + Ok(count > 0) + } +} diff --git a/src/lib.rs b/src/lib.rs index d78cbff7..6f3c5911 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -39,6 +39,7 @@ //! - **Continuous learning** - Improve estimates from historical data pub mod agent; +pub mod bootstrap; pub mod channels; pub mod cli; pub mod config; @@ -49,12 +50,14 @@ pub mod evaluation; pub mod extensions; pub mod history; pub mod llm; +pub mod orchestrator; pub mod safety; pub mod sandbox; pub mod secrets; pub mod settings; pub mod setup; pub mod tools; +pub mod worker; pub mod workspace; pub use config::Config; diff --git a/src/llm/mod.rs b/src/llm/mod.rs index f2d5ffcb..ee6063ad 100644 --- a/src/llm/mod.rs +++ b/src/llm/mod.rs @@ -13,8 +13,8 @@ pub mod session; pub use nearai::{ModelInfo, NearAiProvider}; pub use nearai_chat::NearAiChatProvider; pub use provider::{ - ChatMessage, CompletionRequest, CompletionResponse, LlmProvider, Role, ToolCall, - ToolCompletionRequest, ToolCompletionResponse, ToolDefinition, ToolResult, + ChatMessage, CompletionRequest, CompletionResponse, FinishReason, LlmProvider, ModelMetadata, + Role, ToolCall, ToolCompletionRequest, ToolCompletionResponse, ToolDefinition, ToolResult, }; pub use reasoning::{ActionPlan, Reasoning, ReasoningContext, RespondResult, ToolSelection}; pub use session::{SessionConfig, SessionManager, create_session_manager}; diff --git a/src/llm/nearai.rs b/src/llm/nearai.rs index b204bda5..193510bd 100644 --- a/src/llm/nearai.rs +++ b/src/llm/nearai.rs @@ -3,6 +3,7 @@ //! This provider uses the NEAR AI chat-api which provides a unified interface //! to multiple LLM models (OpenAI, Anthropic, etc.) with user authentication. +use std::collections::HashMap; use std::sync::Arc; use async_trait::async_trait; @@ -31,11 +32,23 @@ pub struct ModelInfo { pub provider: Option, } +/// Per-thread chaining state: the last response ID and how many input +/// messages were included in that request. This lets subsequent calls send +/// only the delta (new messages since last call). +struct ChainState { + response_id: String, + input_count: usize, +} + /// NEAR AI Chat API provider. pub struct NearAiProvider { client: Client, config: NearAiConfig, session: Arc, + active_model: std::sync::RwLock, + /// Per-thread response ID chaining state. + /// Key is thread_id from request metadata. + response_chains: std::sync::RwLock>, } impl NearAiProvider { @@ -46,13 +59,64 @@ impl NearAiProvider { .build() .unwrap_or_else(|_| Client::new()); + let active_model = std::sync::RwLock::new(config.model.clone()); Self { client, config, session, + active_model, + response_chains: std::sync::RwLock::new(HashMap::new()), } } + /// Seed a response chain for a thread (e.g. when restoring from DB). + pub fn seed_response_id(&self, thread_id: &str, response_id: String) { + let mut chains = self + .response_chains + .write() + .expect("response_chains lock poisoned"); + chains.insert( + thread_id.to_string(), + ChainState { + response_id, + input_count: 0, + }, + ); + } + + /// Get the last response ID for a thread (for persistence). + pub fn get_response_id(&self, thread_id: &str) -> Option { + let chains = self + .response_chains + .read() + .expect("response_chains lock poisoned"); + chains.get(thread_id).map(|c| c.response_id.clone()) + } + + /// Store a response chain state after a successful call. + fn store_chain(&self, thread_id: &str, response_id: String, input_count: usize) { + let mut chains = self + .response_chains + .write() + .expect("response_chains lock poisoned"); + chains.insert( + thread_id.to_string(), + ChainState { + response_id, + input_count, + }, + ); + } + + /// Clear the chain for a thread (on error / fallback). + fn clear_chain(&self, thread_id: &str) { + let mut chains = self + .response_chains + .write() + .expect("response_chains lock poisoned"); + chains.remove(thread_id); + } + fn api_url(&self, path: &str) -> String { format!( "{}/v1/{}", @@ -291,18 +355,34 @@ impl NearAiProvider { } } -/// Split messages into system instructions and non-system input messages. +/// Split messages into system instructions and non-system input items. /// The OpenAI Responses API expects system prompts in an `instructions` field, /// not as a message with role "system" in the input array. -fn split_messages(messages: Vec) -> (Option, Vec) { +/// +/// When `chaining` is true, tool result messages (role=tool) are converted to +/// `NearAiInputItem::FunctionCallOutput` for the Responses API protocol. +fn split_messages( + messages: Vec, + chaining: bool, +) -> (Option, Vec) { let mut instructions: Vec = Vec::new(); - let mut input: Vec = Vec::new(); + let mut input: Vec = Vec::new(); for msg in messages { if msg.role == Role::System { instructions.push(msg.content); + } else if chaining && msg.role == Role::Tool { + if let Some(ref call_id) = msg.tool_call_id { + input.push(NearAiInputItem::FunctionCallOutput { + item_type: "function_call_output".to_string(), + call_id: call_id.clone(), + output: msg.content, + }); + } else { + input.push(NearAiInputItem::Message(msg.into())); + } } else { - input.push(msg.into()); + input.push(NearAiInputItem::Message(msg.into())); } } @@ -318,12 +398,14 @@ fn split_messages(messages: Vec) -> (Option, Vec Result { - let (instructions, input) = split_messages(req.messages); + let thread_id = req.metadata.get("thread_id").cloned(); + let (instructions, input) = split_messages(req.messages, false); let request = NearAiRequest { - model: self.config.model.clone(), + model: self.active_model_name(), instructions, input, + previous_response_id: None, temperature: req.temperature, max_output_tokens: req.max_tokens, stream: Some(false), @@ -350,6 +432,7 @@ impl LlmProvider for NearAiProvider { finish_reason: FinishReason::Stop, input_tokens: usage.input_tokens, output_tokens: usage.output_tokens, + response_id: None, }); } @@ -367,6 +450,7 @@ impl LlmProvider for NearAiProvider { finish_reason: FinishReason::Stop, input_tokens: 0, output_tokens: 0, + response_id: None, }); } Err(e) => return Err(e), @@ -423,11 +507,17 @@ impl LlmProvider for NearAiProvider { ); } + // Store response ID for chaining + if let Some(ref tid) = thread_id { + self.store_chain(tid, response.id.clone(), 0); + } + Ok(CompletionResponse { content: text, finish_reason: FinishReason::Stop, input_tokens: response.usage.input_tokens, output_tokens: response.usage.output_tokens, + response_id: Some(response.id), }) } @@ -435,7 +525,33 @@ impl LlmProvider for NearAiProvider { &self, req: ToolCompletionRequest, ) -> Result { - let (instructions, input) = split_messages(req.messages); + let thread_id = req.metadata.get("thread_id").cloned(); + + // Look up chaining state for this thread + let chain_state = thread_id.as_ref().and_then(|tid| { + let chains = self + .response_chains + .read() + .expect("response_chains lock poisoned"); + chains + .get(tid) + .map(|c| (c.response_id.clone(), c.input_count)) + }); + + let chaining = chain_state.is_some(); + let (previous_response_id, prev_input_count) = chain_state + .map(|(rid, count)| (Some(rid), count)) + .unwrap_or((None, 0)); + + // When chaining, only send new messages (the delta since last call). + // Tool results are converted to function_call_output items. + let (instructions, all_input) = split_messages(req.messages, chaining); + let input = if chaining && all_input.len() > prev_input_count { + all_input[prev_input_count..].to_vec() + } else { + all_input.clone() + }; + let total_input_count = all_input.len(); let tools: Vec = req .tools @@ -449,18 +565,58 @@ impl LlmProvider for NearAiProvider { .collect(); let request = NearAiRequest { - model: self.config.model.clone(), - instructions, + model: self.active_model_name(), + instructions: if chaining { None } else { instructions.clone() }, input, + previous_response_id: previous_response_id.clone(), temperature: req.temperature, max_output_tokens: req.max_tokens, stream: Some(false), - tools: if tools.is_empty() { None } else { Some(tools) }, + tools: if tools.is_empty() { + None + } else { + Some(tools.clone()) + }, }; - // Try to get structured response, fall back to alternative formats + // Try to get structured response, fall back to alternative formats. + // If chaining fails (bad previous_response_id), retry with full history. let response: NearAiResponse = match self.send_request("responses", &request).await { Ok(r) => r, + Err(ref e) if chaining && is_chain_error(e) => { + tracing::warn!( + "Response chaining failed, retrying with full history: {}", + e + ); + if let Some(ref tid) = thread_id { + self.clear_chain(tid); + } + let (instructions_full, input_full) = split_messages( + // Rebuild from the original input (non-chaining mode) + { + let mut msgs = Vec::new(); + if let Some(ref instr) = instructions { + msgs.push(ChatMessage::system(instr.clone())); + } + for item in &all_input { + msgs.push(item.to_chat_message()); + } + msgs + }, + false, + ); + let retry_request = NearAiRequest { + model: self.active_model_name(), + instructions: instructions_full, + input: input_full, + previous_response_id: None, + temperature: request.temperature, + max_output_tokens: request.max_output_tokens, + stream: Some(false), + tools: request.tools.clone(), + }; + self.send_request("responses", &retry_request).await? + } Err(LlmError::InvalidResponse { reason, .. }) if reason.contains("Raw: ") => { let raw_text = reason.split("Raw: ").nth(1).unwrap_or(""); @@ -490,6 +646,7 @@ impl LlmProvider for NearAiProvider { finish_reason, input_tokens: usage.input_tokens, output_tokens: usage.output_tokens, + response_id: None, }); } @@ -507,6 +664,7 @@ impl LlmProvider for NearAiProvider { finish_reason: FinishReason::Stop, input_tokens: 0, output_tokens: 0, + response_id: None, }); } Err(e) => return Err(e), @@ -560,12 +718,18 @@ impl LlmProvider for NearAiProvider { FinishReason::ToolUse }; + // Store response ID for chaining on subsequent calls + if let Some(ref tid) = thread_id { + self.store_chain(tid, response.id.clone(), total_input_count); + } + Ok(ToolCompletionResponse { content: if text.is_empty() { None } else { Some(text) }, tool_calls, finish_reason, input_tokens: response.usage.input_tokens, output_tokens: response.usage.output_tokens, + response_id: Some(response.id), }) } @@ -584,6 +748,30 @@ impl LlmProvider for NearAiProvider { let models = NearAiProvider::list_models(self).await?; Ok(models.into_iter().map(|m| m.name).collect()) } + + fn active_model_name(&self) -> String { + self.active_model + .read() + .expect("active_model lock poisoned") + .clone() + } + + fn set_model(&self, model: &str) -> Result<(), LlmError> { + let mut guard = self + .active_model + .write() + .expect("active_model lock poisoned"); + *guard = model.to_string(); + Ok(()) + } + + fn seed_response_chain(&self, thread_id: &str, response_id: String) { + self.seed_response_id(thread_id, response_id); + } + + fn get_response_chain_id(&self, thread_id: &str) -> Option { + self.get_response_id(thread_id) + } } // NEAR AI API types @@ -597,8 +785,11 @@ struct NearAiRequest { /// System instructions (replaces sending system role in input) #[serde(skip_serializing_if = "Option::is_none")] instructions: Option, - /// Input messages (user/assistant/tool only, NOT system) - input: Vec, + /// Input items: messages and/or function_call_output entries. + input: Vec, + /// Chain this request to a previous response (avoids resending full context). + #[serde(skip_serializing_if = "Option::is_none")] + previous_response_id: Option, #[serde(skip_serializing_if = "Option::is_none")] temperature: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -609,7 +800,7 @@ struct NearAiRequest { tools: Option>, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, Clone)] struct NearAiMessage { role: String, content: String, @@ -630,7 +821,68 @@ impl From for NearAiMessage { } } -#[derive(Debug, Serialize)] +/// Input item for the Responses API. Either a regular message or a +/// function_call_output (for returning tool results when chaining). +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(untagged)] +enum NearAiInputItem { + Message(NearAiMessage), + FunctionCallOutput { + #[serde(rename = "type")] + item_type: String, + call_id: String, + output: String, + }, +} + +impl NearAiInputItem { + /// Convert back to a ChatMessage (used for fallback retry). + fn to_chat_message(&self) -> ChatMessage { + match self { + NearAiInputItem::Message(msg) => { + let role = match msg.role.as_str() { + "system" => Role::System, + "user" => Role::User, + "assistant" => Role::Assistant, + "tool" => Role::Tool, + _ => Role::User, + }; + ChatMessage { + role, + content: msg.content.clone(), + tool_call_id: None, + name: None, + tool_calls: None, + } + } + NearAiInputItem::FunctionCallOutput { + call_id, output, .. + } => ChatMessage { + role: Role::Tool, + content: output.clone(), + tool_call_id: Some(call_id.clone()), + name: None, + tool_calls: None, + }, + } + } +} + +/// Check if an LLM error is likely caused by an invalid previous_response_id. +fn is_chain_error(err: &LlmError) -> bool { + match err { + LlmError::RequestFailed { reason, .. } => { + let lower = reason.to_lowercase(); + lower.contains("previous_response_id") + || lower.contains("previous response") + || lower.contains("not found") + || lower.contains("invalid response id") + } + _ => false, + } +} + +#[derive(Debug, Clone, Serialize)] struct NearAiTool { #[serde(rename = "type")] tool_type: String, @@ -833,14 +1085,17 @@ mod tests { ChatMessage::user("Hello"), ChatMessage::assistant("Hi there!"), ]; - let (instructions, input) = split_messages(messages); + let (instructions, input) = split_messages(messages, false); assert_eq!( instructions, Some("You are a helpful assistant".to_string()) ); assert_eq!(input.len(), 2); - assert_eq!(input[0].role, "user"); - assert_eq!(input[1].role, "assistant"); + // Verify the input items are messages + match &input[0] { + NearAiInputItem::Message(m) => assert_eq!(m.role, "user"), + _ => panic!("expected Message"), + } } #[test] @@ -849,7 +1104,7 @@ mod tests { ChatMessage::user("Hello"), ChatMessage::assistant("Hi there!"), ]; - let (instructions, input) = split_messages(messages); + let (instructions, input) = split_messages(messages, false); assert!(instructions.is_none()); assert_eq!(input.len(), 2); } @@ -861,11 +1116,44 @@ mod tests { ChatMessage::system("Second instruction"), ChatMessage::user("Hello"), ]; - let (instructions, input) = split_messages(messages); + let (instructions, input) = split_messages(messages, false); assert_eq!( instructions, Some("First instruction\n\nSecond instruction".to_string()) ); assert_eq!(input.len(), 1); } + + #[test] + fn test_split_messages_chaining_converts_tool_results() { + let messages = vec![ + ChatMessage::user("Hello"), + ChatMessage::tool_result("call_123", "my_tool", "result data"), + ]; + let (_, input) = split_messages(messages, true); + assert_eq!(input.len(), 2); + match &input[1] { + NearAiInputItem::FunctionCallOutput { + call_id, output, .. + } => { + assert_eq!(call_id, "call_123"); + assert_eq!(output, "result data"); + } + _ => panic!("expected FunctionCallOutput"), + } + } + + #[test] + fn test_split_messages_no_chaining_keeps_tool_as_message() { + let messages = vec![ + ChatMessage::user("Hello"), + ChatMessage::tool_result("call_123", "my_tool", "result data"), + ]; + let (_, input) = split_messages(messages, false); + assert_eq!(input.len(), 2); + match &input[1] { + NearAiInputItem::Message(m) => assert_eq!(m.role, "tool"), + _ => panic!("expected Message"), + } + } } diff --git a/src/llm/nearai_chat.rs b/src/llm/nearai_chat.rs index 5a76e7b6..8c0c5747 100644 --- a/src/llm/nearai_chat.rs +++ b/src/llm/nearai_chat.rs @@ -13,14 +13,15 @@ use serde::{Deserialize, Serialize}; use crate::config::NearAiConfig; use crate::error::LlmError; use crate::llm::provider::{ - ChatMessage, CompletionRequest, CompletionResponse, FinishReason, LlmProvider, Role, ToolCall, - ToolCompletionRequest, ToolCompletionResponse, + ChatMessage, CompletionRequest, CompletionResponse, FinishReason, LlmProvider, ModelMetadata, + Role, ToolCall, ToolCompletionRequest, ToolCompletionResponse, }; /// NEAR AI Chat Completions API provider. pub struct NearAiChatProvider { client: Client, config: NearAiConfig, + active_model: std::sync::RwLock, } impl NearAiChatProvider { @@ -37,7 +38,12 @@ impl NearAiChatProvider { .build() .unwrap_or_else(|_| Client::new()); - Ok(Self { client, config }) + let active_model = std::sync::RwLock::new(config.model.clone()); + Ok(Self { + client, + config, + active_model, + }) } fn api_url(&self, path: &str) -> String { @@ -65,6 +71,11 @@ impl NearAiChatProvider { tracing::debug!("Sending request to NEAR AI Chat: {}", url); + // Log the request body for debugging tool call issues + if let Ok(json) = serde_json::to_string(body) { + tracing::debug!("NEAR AI Chat request body: {}", json); + } + let response = self .client .post(&url) @@ -111,8 +122,8 @@ impl NearAiChatProvider { }) } - /// Fetch available models. - pub async fn list_models(&self) -> Result, LlmError> { + /// Fetch available models with full metadata from the `/v1/models` endpoint. + async fn fetch_models(&self) -> Result, LlmError> { let url = self.api_url("models"); let response = self @@ -138,12 +149,7 @@ impl NearAiChatProvider { #[derive(Deserialize)] struct ModelsResponse { - data: Vec, - } - - #[derive(Deserialize)] - struct ModelEntry { - id: String, + data: Vec, } let resp: ModelsResponse = @@ -152,10 +158,18 @@ impl NearAiChatProvider { reason: format!("JSON parse error: {}", e), })?; - Ok(resp.data.into_iter().map(|m| m.id).collect()) + Ok(resp.data) } } +/// Model entry as returned by the `/v1/models` API. +#[derive(Debug, Deserialize)] +struct ApiModelEntry { + id: String, + #[serde(default)] + context_length: Option, +} + #[async_trait] impl LlmProvider for NearAiChatProvider { async fn complete(&self, req: CompletionRequest) -> Result { @@ -163,7 +177,7 @@ impl LlmProvider for NearAiChatProvider { req.messages.into_iter().map(|m| m.into()).collect(); let request = ChatCompletionRequest { - model: self.config.model.clone(), + model: self.active_model_name(), messages, temperature: req.temperature, max_tokens: req.max_tokens, @@ -197,6 +211,7 @@ impl LlmProvider for NearAiChatProvider { finish_reason, input_tokens: response.usage.prompt_tokens, output_tokens: response.usage.completion_tokens, + response_id: None, }) } @@ -221,7 +236,7 @@ impl LlmProvider for NearAiChatProvider { .collect(); let request = ChatCompletionRequest { - model: self.config.model.clone(), + model: self.active_model_name(), messages, temperature: req.temperature, max_tokens: req.max_tokens, @@ -278,6 +293,7 @@ impl LlmProvider for NearAiChatProvider { finish_reason, input_tokens: response.usage.prompt_tokens, output_tokens: response.usage.completion_tokens, + response_id: None, }) } @@ -291,7 +307,34 @@ impl LlmProvider for NearAiChatProvider { } async fn list_models(&self) -> Result, LlmError> { - NearAiChatProvider::list_models(self).await + let models = self.fetch_models().await?; + Ok(models.into_iter().map(|m| m.id).collect()) + } + + async fn model_metadata(&self) -> Result { + let active = self.active_model_name(); + let models = self.fetch_models().await?; + let current = models.iter().find(|m| m.id == active); + Ok(ModelMetadata { + id: active, + context_length: current.and_then(|m| m.context_length), + }) + } + + fn active_model_name(&self) -> String { + self.active_model + .read() + .expect("active_model lock poisoned") + .clone() + } + + fn set_model(&self, model: &str) -> Result<(), crate::error::LlmError> { + let mut guard = self + .active_model + .write() + .expect("active_model lock poisoned"); + *guard = model.to_string(); + Ok(()) } } @@ -332,6 +375,7 @@ impl From for ChatCompletionMessage { Role::Assistant => "assistant", Role::Tool => "tool", }; + let tool_calls = msg.tool_calls.map(|calls| { calls .into_iter() @@ -345,9 +389,16 @@ impl From for ChatCompletionMessage { }) .collect() }); + + let content = if role == "assistant" && tool_calls.is_some() && msg.content.is_empty() { + None + } else { + Some(msg.content) + }; + Self { role: role.to_string(), - content: Some(msg.content), + content, tool_call_id: msg.tool_call_id, name: msg.name, tool_calls, @@ -454,7 +505,7 @@ mod tests { }, ]; - let msg = ChatMessage::assistant_with_tool_calls("", tool_calls); + let msg = ChatMessage::assistant_with_tool_calls(None, tool_calls); let chat_msg: ChatCompletionMessage = msg.into(); assert_eq!(chat_msg.role, "assistant"); @@ -484,7 +535,7 @@ mod tests { name: "test".to_string(), arguments: serde_json::json!({"key": "value"}), }; - let msg = ChatMessage::assistant_with_tool_calls("", vec![tc]); + let msg = ChatMessage::assistant_with_tool_calls(None, vec![tc]); let chat_msg: ChatCompletionMessage = msg.into(); let calls = chat_msg.tool_calls.unwrap(); diff --git a/src/llm/provider.rs b/src/llm/provider.rs index bb6dcdcb..e06d8b77 100644 --- a/src/llm/provider.rs +++ b/src/llm/provider.rs @@ -27,9 +27,8 @@ pub struct ChatMessage { /// Name of the tool for tool results. #[serde(skip_serializing_if = "Option::is_none")] pub name: Option, - /// Tool calls requested by the assistant (for conversation replay). - /// OpenAI-compatible APIs require the assistant message to include - /// tool_calls when followed by tool result messages. + /// Tool calls made by the assistant (OpenAI protocol requires these + /// to appear on the assistant message preceding tool result messages). #[serde(skip_serializing_if = "Option::is_none")] pub tool_calls: Option>, } @@ -68,17 +67,14 @@ impl ChatMessage { } } - /// Create an assistant message that requested tool calls. + /// Create an assistant message that includes tool calls. /// - /// OpenAI-compatible APIs require the assistant message to carry the - /// `tool_calls` array when followed by tool-result messages. - pub fn assistant_with_tool_calls( - content: impl Into, - tool_calls: Vec, - ) -> Self { + /// Per the OpenAI protocol, an assistant message with tool_calls must + /// precede the corresponding tool result messages in the conversation. + pub fn assistant_with_tool_calls(content: Option, tool_calls: Vec) -> Self { Self { role: Role::Assistant, - content: content.into(), + content: content.unwrap_or_default(), tool_call_id: None, name: None, tool_calls: if tool_calls.is_empty() { @@ -112,6 +108,8 @@ pub struct CompletionRequest { pub max_tokens: Option, pub temperature: Option, pub stop_sequences: Option>, + /// Opaque metadata passed through to the provider (e.g. thread_id for chaining). + pub metadata: std::collections::HashMap, } impl CompletionRequest { @@ -122,6 +120,7 @@ impl CompletionRequest { max_tokens: None, temperature: None, stop_sequences: None, + metadata: std::collections::HashMap::new(), } } @@ -145,6 +144,8 @@ pub struct CompletionResponse { pub input_tokens: u32, pub output_tokens: u32, pub finish_reason: FinishReason, + /// Provider-specific response ID (e.g. for NEAR AI response chaining). + pub response_id: Option, } /// Why the completion finished. @@ -191,6 +192,8 @@ pub struct ToolCompletionRequest { pub temperature: Option, /// How to handle tool use: "auto", "required", or "none". pub tool_choice: Option, + /// Opaque metadata passed through to the provider (e.g. thread_id for chaining). + pub metadata: std::collections::HashMap, } impl ToolCompletionRequest { @@ -202,6 +205,7 @@ impl ToolCompletionRequest { max_tokens: None, temperature: None, tool_choice: None, + metadata: std::collections::HashMap::new(), } } @@ -234,6 +238,16 @@ pub struct ToolCompletionResponse { pub input_tokens: u32, pub output_tokens: u32, pub finish_reason: FinishReason, + /// Provider-specific response ID (e.g. for NEAR AI response chaining). + pub response_id: Option, +} + +/// Metadata about a model returned by the provider's API. +#[derive(Debug, Clone)] +pub struct ModelMetadata { + pub id: String, + /// Total context window size in tokens. + pub context_length: Option, } /// Trait for LLM providers. @@ -260,6 +274,45 @@ pub trait LlmProvider: Send + Sync { Ok(Vec::new()) } + /// Fetch metadata for the current model (context length, etc.). + /// Default returns the model name with no size info. + async fn model_metadata(&self) -> Result { + Ok(ModelMetadata { + id: self.model_name().to_string(), + context_length: None, + }) + } + + /// Get the currently active model name. + /// + /// May differ from `model_name()` if the model was switched at runtime + /// via `set_model()`. Default returns `model_name()`. + fn active_model_name(&self) -> String { + self.model_name().to_string() + } + + /// Switch the active model at runtime. Not all providers support this. + fn set_model(&self, _model: &str) -> Result<(), LlmError> { + Err(LlmError::RequestFailed { + provider: "unknown".to_string(), + reason: "Runtime model switching not supported by this provider".to_string(), + }) + } + + /// Seed a response chain for a thread (e.g. restoring from DB). + /// + /// Providers that support response chaining (e.g. NEAR AI `previous_response_id`) + /// store this so subsequent calls send only delta messages. + fn seed_response_chain(&self, _thread_id: &str, _response_id: String) {} + + /// Get the last response chain ID for a thread. + /// + /// Returns `None` if the provider doesn't support chaining or has no + /// stored state for this thread. + fn get_response_chain_id(&self, _thread_id: &str) -> Option { + None + } + /// Calculate cost for a completion. fn calculate_cost(&self, input_tokens: u32, output_tokens: u32) -> Decimal { let (input_cost, output_cost) = self.cost_per_token(); diff --git a/src/llm/reasoning.rs b/src/llm/reasoning.rs index 6d8bf183..95125298 100644 --- a/src/llm/reasoning.rs +++ b/src/llm/reasoning.rs @@ -21,6 +21,8 @@ pub struct ReasoningContext { pub job_description: Option, /// Current state description. pub current_state: Option, + /// Opaque metadata forwarded to the LLM provider (e.g. thread_id for chaining). + pub metadata: std::collections::HashMap, } impl ReasoningContext { @@ -31,6 +33,7 @@ impl ReasoningContext { available_tools: Vec::new(), job_description: None, current_state: None, + metadata: std::collections::HashMap::new(), } } @@ -57,6 +60,12 @@ impl ReasoningContext { self.job_description = Some(description.into()); self } + + /// Set metadata (forwarded to the LLM provider). + pub fn with_metadata(mut self, metadata: std::collections::HashMap) -> Self { + self.metadata = metadata; + self + } } impl Default for ReasoningContext { @@ -114,7 +123,12 @@ pub enum RespondResult { /// A text response (no tools needed). Text(String), /// The model wants to call tools. Caller should execute them and call back. - ToolCalls(Vec), + /// Includes the optional content from the assistant message (some models + /// include explanatory text alongside tool calls). + ToolCalls { + tool_calls: Vec, + content: Option, + }, } /// Reasoning engine for the agent. @@ -192,10 +206,11 @@ impl Reasoning { return Ok(vec![]); } - let request = + let mut request = ToolCompletionRequest::new(context.messages.clone(), context.available_tools.clone()) .with_max_tokens(1024) .with_tool_choice("auto"); + request.metadata = context.metadata.clone(); let response = self.llm.complete_with_tools(request).await?; @@ -271,7 +286,9 @@ Respond in JSON format: pub async fn respond(&self, context: &ReasoningContext) -> Result { match self.respond_with_tools(context).await? { RespondResult::Text(text) => Ok(text), - RespondResult::ToolCalls(calls) => { + RespondResult::ToolCalls { + tool_calls: calls, .. + } => { // Format tool calls as text (legacy behavior for non-agentic callers) let tool_info: Vec = calls .iter() @@ -298,28 +315,49 @@ Respond in JSON format: // If we have tools, use tool completion mode if !context.available_tools.is_empty() { - let request = ToolCompletionRequest::new(messages, context.available_tools.clone()) + let mut request = ToolCompletionRequest::new(messages, context.available_tools.clone()) .with_max_tokens(4096) .with_temperature(0.7) .with_tool_choice("auto"); + request.metadata = context.metadata.clone(); let response = self.llm.complete_with_tools(request).await?; // If there were tool calls, return them for execution if !response.tool_calls.is_empty() { - return Ok(RespondResult::ToolCalls(response.tool_calls)); + return Ok(RespondResult::ToolCalls { + tool_calls: response.tool_calls, + content: response.content, + }); } let content = response .content .unwrap_or_else(|| "I'm not sure how to respond to that.".to_string()); + // Some models (e.g. GLM-4.7) emit tool calls as XML tags in content + // instead of using the structured tool_calls field. Try to recover + // them before giving up and returning plain text. + let recovered = recover_tool_calls_from_content(&content, &context.available_tools); + if !recovered.is_empty() { + let cleaned = clean_response(&content); + return Ok(RespondResult::ToolCalls { + tool_calls: recovered, + content: if cleaned.is_empty() { + None + } else { + Some(cleaned) + }, + }); + } + Ok(RespondResult::Text(clean_response(&content))) } else { // No tools, use simple completion - let request = CompletionRequest::new(messages) + let mut request = CompletionRequest::new(messages) .with_max_tokens(4096) .with_temperature(0.7); + request.metadata = context.metadata.clone(); let response = self.llm.complete(request).await?; Ok(RespondResult::Text(clean_response(&response.content))) @@ -462,47 +500,178 @@ fn extract_json(text: &str) -> Option<&str> { } } -/// Clean up LLM response by stripping thinking tags and reasoning patterns. +/// Clean up LLM response by stripping model-internal tags and reasoning patterns. +/// +/// Some models (GLM-4.7, etc.) emit XML-tagged internal state like +/// Try to extract tool calls from content text where the model emitted them +/// as XML tags instead of using the structured tool_calls field. +/// +/// Handles these formats: +/// - `tool_name` (bare name) +/// - `{"name":"x","arguments":{}}` (JSON) +/// - `<|tool_call|>...<|/tool_call|>` (pipe-delimited variant) +/// - `...` (function_call variant) +/// +/// Only returns calls whose name matches an available tool. +fn recover_tool_calls_from_content( + content: &str, + available_tools: &[ToolDefinition], +) -> Vec { + let tool_names: std::collections::HashSet<&str> = + available_tools.iter().map(|t| t.name.as_str()).collect(); + let mut calls = Vec::new(); + + for (open, close) in &[ + ("", ""), + ("<|tool_call|>", "<|/tool_call|>"), + ("", ""), + ("<|function_call|>", "<|/function_call|>"), + ] { + let mut remaining = content; + while let Some(start) = remaining.find(open) { + let inner_start = start + open.len(); + let after = &remaining[inner_start..]; + let Some(end) = after.find(close) else { + break; + }; + let inner = after[..end].trim(); + remaining = &after[end + close.len()..]; + + if inner.is_empty() { + continue; + } + + // Try JSON first: {"name":"x","arguments":{}} + if let Ok(parsed) = serde_json::from_str::(inner) { + if let Some(name) = parsed.get("name").and_then(|v| v.as_str()) { + if tool_names.contains(name) { + let arguments = parsed + .get("arguments") + .cloned() + .unwrap_or(serde_json::Value::Object(Default::default())); + calls.push(ToolCall { + id: format!("recovered_{}", calls.len()), + name: name.to_string(), + arguments, + }); + continue; + } + } + } + + // Bare tool name (e.g. "tool_list") + let name = inner.trim(); + if tool_names.contains(name) { + calls.push(ToolCall { + id: format!("recovered_{}", calls.len()), + name: name.to_string(), + arguments: serde_json::Value::Object(Default::default()), + }); + } + } + } + + calls +} + +/// `tool_list` or `<|tool_call|>` in the content field +/// instead of using the standard OpenAI tool_calls array. We strip all of +/// these before the response reaches channels/users. fn clean_response(text: &str) -> String { - let text = strip_thinking_tags(text); + let text = strip_internal_tags(text); strip_reasoning_patterns(&text) } -/// Strip `...` blocks from LLM output. +/// Tags that are model-internal and should never reach users. +const INTERNAL_TAGS: &[&str] = &["thinking", "tool_call", "function_call", "tool_calls"]; + +/// Strip all model-internal XML tags from LLM output. /// -/// Some models (especially Claude with extended thinking) include internal -/// reasoning in thinking tags. We strip these before showing to users. -fn strip_thinking_tags(text: &str) -> String { +/// Handles standard XML tags (`...`) and pipe-delimited variants +/// (`<|tag|>...<|/tag|>`) used by some models (e.g. GLM-4.7). +fn strip_internal_tags(text: &str) -> String { + let mut result = text.to_string(); + for tag in INTERNAL_TAGS { + result = strip_xml_tag(&result, tag); + result = strip_pipe_tag(&result, tag); + } + // Collapse triple+ newlines left behind by removed blocks + while result.contains("\n\n\n") { + result = result.replace("\n\n\n", "\n\n"); + } + result.trim().to_string() +} + +/// Strip `...` and `...` blocks from text. +fn strip_xml_tag(text: &str, tag: &str) -> String { + let open_exact = format!("<{}>", tag); + let open_prefix = format!("<{} ", tag); // for + let close = format!("", tag); + let mut result = String::with_capacity(text.len()); let mut remaining = text; - while let Some(start) = remaining.find("") { + loop { + // Find the next opening tag (exact or with attributes) + let exact_pos = remaining.find(&open_exact); + let prefix_pos = remaining.find(&open_prefix); + let start = match (exact_pos, prefix_pos) { + (Some(a), Some(b)) => a.min(b), + (Some(a), None) => a, + (None, Some(b)) => b, + (None, None) => break, + }; + // Add everything before the tag result.push_str(&remaining[..start]); + // Find the end of the opening tag (the closing >) + let after_open = &remaining[start..]; + let open_end = match after_open.find('>') { + Some(pos) => start + pos + 1, + None => break, // malformed, stop + }; + // Find the closing tag - if let Some(end_offset) = remaining[start..].find("") { - // Skip past the closing tag (start + offset + tag length) - let end = start + end_offset + "".len(); + if let Some(close_offset) = remaining[open_end..].find(&close) { + let end = open_end + close_offset + close.len(); remaining = &remaining[end..]; } else { - // No closing tag found, discard everything from here - // (malformed, but handle gracefully by not including the unclosed tag) + // No closing tag, discard from here (malformed) remaining = ""; break; } } - // Add any remaining content after the last thinking block result.push_str(remaining); + result +} - // Clean up any double newlines left behind - let mut cleaned = result.trim().to_string(); - while cleaned.contains("\n\n\n") { - cleaned = cleaned.replace("\n\n\n", "\n\n"); +/// Strip `<|tag|>...<|/tag|>` pipe-delimited blocks from text. +/// +/// Some models (e.g. certain Chinese LLMs) use this format instead of +/// standard XML tags. +fn strip_pipe_tag(text: &str, tag: &str) -> String { + let open = format!("<|{}|>", tag); + let close = format!("<|/{}|>", tag); + + let mut result = String::with_capacity(text.len()); + let mut remaining = text; + + while let Some(start) = remaining.find(&open) { + result.push_str(&remaining[..start]); + + if let Some(close_offset) = remaining[start..].find(&close) { + let end = start + close_offset + close.len(); + remaining = &remaining[end..]; + } else { + remaining = ""; + break; + } } - cleaned + result.push_str(remaining); + result } /// Strip any remaining reasoning that wasn't in proper tags. @@ -574,7 +743,7 @@ That's my plan."#; #[test] fn test_strip_thinking_tags_basic() { let input = "Let me think about this...Hello, user!"; - let output = strip_thinking_tags(input); + let output = strip_internal_tags(input); assert_eq!(output, "Hello, user!"); } @@ -582,7 +751,7 @@ That's my plan."#; fn test_strip_thinking_tags_multiple() { let input = "First thoughtHelloSecond thought world!"; - let output = strip_thinking_tags(input); + let output = strip_internal_tags(input); assert_eq!(output, "Hello world!"); } @@ -594,14 +763,14 @@ I need to consider: 2. How to respond Here is my response to your question."#; - let output = strip_thinking_tags(input); + let output = strip_internal_tags(input); assert_eq!(output, "Here is my response to your question."); } #[test] fn test_strip_thinking_tags_no_tags() { let input = "Just a normal response without thinking tags."; - let output = strip_thinking_tags(input); + let output = strip_internal_tags(input); assert_eq!(output, "Just a normal response without thinking tags."); } @@ -609,10 +778,77 @@ Here is my response to your question."#; fn test_strip_thinking_tags_unclosed() { // Malformed: unclosed tag should strip from there to end let input = "Hello this never closes"; - let output = strip_thinking_tags(input); + let output = strip_internal_tags(input); assert_eq!(output, "Hello"); } + #[test] + fn test_strip_tool_call_tags() { + // GLM-4.7 emits this garbage instead of using the tool_calls array + let input = "tool_list"; + let output = strip_internal_tags(input); + assert_eq!(output, ""); + } + + #[test] + fn test_strip_tool_call_with_surrounding_text() { + let input = "Here is my answer.\n\n\n{\"name\": \"search\", \"arguments\": {}}\n"; + let output = strip_internal_tags(input); + assert_eq!(output, "Here is my answer."); + } + + #[test] + fn test_strip_multiple_internal_tags() { + let input = "Let me thinkHello!\nsome_tool"; + let output = strip_internal_tags(input); + assert_eq!(output, "Hello!"); + } + + #[test] + fn test_strip_function_call_tags() { + let input = "Response text{\"name\": \"foo\"}"; + let output = strip_internal_tags(input); + assert_eq!(output, "Response text"); + } + + #[test] + fn test_strip_tool_calls_plural() { + let input = "[{\"id\": \"1\"}]Actual response."; + let output = strip_internal_tags(input); + assert_eq!(output, "Actual response."); + } + + #[test] + fn test_strip_pipe_delimited_tags() { + let input = "<|tool_call|>{\"name\": \"search\"}<|/tool_call|>Hello!"; + let output = strip_internal_tags(input); + assert_eq!(output, "Hello!"); + } + + #[test] + fn test_strip_pipe_delimited_thinking() { + let input = "<|thinking|>reasoning here<|/thinking|>The answer is 42."; + let output = strip_internal_tags(input); + assert_eq!(output, "The answer is 42."); + } + + #[test] + fn test_strip_xml_tag_with_attributes() { + let input = "search()Done."; + let output = strip_internal_tags(input); + assert_eq!(output, "Done."); + } + + #[test] + fn test_clean_response_preserves_normal_content() { + let input = "The function tool_call_handler works great. No tags here!"; + let output = clean_response(input); + assert_eq!( + output, + "The function tool_call_handler works great. No tags here!" + ); + } + #[test] fn test_strip_reasoning_paragraph_break() { // Content after paragraph break with "here" marker @@ -660,4 +896,92 @@ Here is my response to your question."#; let output = clean_response(input); assert_eq!(output, "Here's the answer."); } + + // -- recover_tool_calls_from_content tests -- + + fn make_tools(names: &[&str]) -> Vec { + names + .iter() + .map(|n| ToolDefinition { + name: n.to_string(), + description: String::new(), + parameters: serde_json::json!({}), + }) + .collect() + } + + #[test] + fn test_recover_bare_tool_name() { + let tools = make_tools(&["tool_list", "tool_auth"]); + let content = "tool_list"; + let calls = recover_tool_calls_from_content(content, &tools); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].name, "tool_list"); + assert_eq!(calls[0].arguments, serde_json::json!({})); + } + + #[test] + fn test_recover_json_tool_call() { + let tools = make_tools(&["memory_search"]); + let content = + r#"{"name": "memory_search", "arguments": {"query": "test"}}"#; + let calls = recover_tool_calls_from_content(content, &tools); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].name, "memory_search"); + assert_eq!(calls[0].arguments, serde_json::json!({"query": "test"})); + } + + #[test] + fn test_recover_pipe_delimited() { + let tools = make_tools(&["tool_list"]); + let content = "<|tool_call|>tool_list<|/tool_call|>"; + let calls = recover_tool_calls_from_content(content, &tools); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].name, "tool_list"); + } + + #[test] + fn test_recover_unknown_tool_ignored() { + let tools = make_tools(&["tool_list"]); + let content = "nonexistent_tool"; + let calls = recover_tool_calls_from_content(content, &tools); + assert!(calls.is_empty()); + } + + #[test] + fn test_recover_no_tags() { + let tools = make_tools(&["tool_list"]); + let content = "Just a normal response."; + let calls = recover_tool_calls_from_content(content, &tools); + assert!(calls.is_empty()); + } + + #[test] + fn test_recover_multiple_tool_calls() { + let tools = make_tools(&["tool_list", "tool_auth"]); + let content = "tool_list\ntool_auth"; + let calls = recover_tool_calls_from_content(content, &tools); + assert_eq!(calls.len(), 2); + assert_eq!(calls[0].name, "tool_list"); + assert_eq!(calls[1].name, "tool_auth"); + } + + #[test] + fn test_recover_function_call_variant() { + let tools = make_tools(&["shell"]); + let content = + r#"{"name": "shell", "arguments": {"cmd": "ls"}}"#; + let calls = recover_tool_calls_from_content(content, &tools); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].name, "shell"); + } + + #[test] + fn test_recover_with_surrounding_text() { + let tools = make_tools(&["tool_list"]); + let content = "Let me check.\n\ntool_list\n\nDone."; + let calls = recover_tool_calls_from_content(content, &tools); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].name, "tool_list"); + } } diff --git a/src/llm/session.rs b/src/llm/session.rs index 5a136171..7dac5b72 100644 --- a/src/llm/session.rs +++ b/src/llm/session.rs @@ -61,6 +61,10 @@ pub struct SessionManager { token: RwLock>, /// Prevents thundering herd during concurrent 401s. renewal_lock: Mutex<()>, + /// Optional database store for persisting session to the settings table. + store: RwLock>>, + /// User ID for DB settings (default: "default"). + user_id: RwLock, } impl SessionManager { @@ -74,6 +78,8 @@ impl SessionManager { .unwrap_or_else(|_| Client::new()), token: RwLock::new(None), renewal_lock: Mutex::new(()), + store: RwLock::new(None), + user_id: RwLock::new("default".to_string()), }; // Try to load existing session synchronously during construction @@ -103,6 +109,8 @@ impl SessionManager { .unwrap_or_else(|_| Client::new()), token: RwLock::new(None), renewal_lock: Mutex::new(()), + store: RwLock::new(None), + user_id: RwLock::new("default".to_string()), }; if let Err(e) = manager.load_session().await { @@ -112,6 +120,21 @@ impl SessionManager { manager } + /// Attach a database store for persisting session tokens. + /// + /// When a store is attached, session tokens are saved to the `settings` + /// table (key: `nearai.session_token`) in addition to the disk file. + /// On load, DB is preferred over disk. + pub async fn attach_store(&self, store: Arc, user_id: &str) { + *self.store.write().await = Some(store); + *self.user_id.write().await = user_id.to_string(); + + // Try to load from DB (may have been saved by a previous run) + if let Err(e) = self.load_session_from_db().await { + tracing::debug!("No session in DB: {}", e); + } + } + /// Get the current session token, returning an error if not authenticated. pub async fn get_token(&self) -> Result { let guard = self.token.read().await; @@ -460,7 +483,7 @@ impl SessionManager { Ok(()) } - /// Save session data to disk. + /// Save session data to disk and (if available) to the database. async fn save_session(&self, token: &str, auth_provider: Option<&str>) -> Result<(), LlmError> { let session = SessionData { session_token: token.to_string(), @@ -468,7 +491,7 @@ impl SessionManager { auth_provider: auth_provider.map(String::from), }; - // Ensure parent directory exists + // Save to disk (always, as bootstrap fallback) if let Some(parent) = self.config.session_path.parent() { tokio::fs::create_dir_all(parent).await.map_err(|e| { LlmError::Io(std::io::Error::new( @@ -498,6 +521,58 @@ impl SessionManager { })?; tracing::debug!("Session saved to {}", self.config.session_path.display()); + + // Also save to DB if a store is attached + if let Some(ref store) = *self.store.read().await { + let user_id = self.user_id.read().await.clone(); + let session_json = serde_json::to_value(&session) + .unwrap_or(serde_json::Value::String(token.to_string())); + if let Err(e) = store + .set_setting(&user_id, "nearai.session_token", &session_json) + .await + { + tracing::warn!("Failed to save session to DB: {}", e); + } else { + tracing::debug!("Session also saved to DB settings"); + } + } + + Ok(()) + } + + /// Try to load session from the database. + async fn load_session_from_db(&self) -> Result<(), LlmError> { + let store_guard = self.store.read().await; + let store = store_guard + .as_ref() + .ok_or_else(|| LlmError::SessionRenewalFailed { + provider: "nearai".to_string(), + reason: "No DB store attached".to_string(), + })?; + + let user_id = self.user_id.read().await.clone(); + let value = store + .get_setting(&user_id, "nearai.session_token") + .await + .map_err(|e| LlmError::SessionRenewalFailed { + provider: "nearai".to_string(), + reason: format!("DB query failed: {}", e), + })? + .ok_or_else(|| LlmError::SessionRenewalFailed { + provider: "nearai".to_string(), + reason: "No session in DB".to_string(), + })?; + + let session: SessionData = + serde_json::from_value(value).map_err(|e| LlmError::SessionRenewalFailed { + provider: "nearai".to_string(), + reason: format!("Failed to parse DB session: {}", e), + })?; + + let mut guard = self.token.write().await; + *guard = Some(SecretString::from(session.session_token)); + tracing::info!("Loaded session from DB settings"); + Ok(()) } diff --git a/src/main.rs b/src/main.rs index 913e9717..0247e163 100644 --- a/src/main.rs +++ b/src/main.rs @@ -24,14 +24,17 @@ use ironclaw::{ extensions::ExtensionManager, history::Store, llm::{SessionConfig, create_llm_provider, create_session_manager}, + orchestrator::{ + ContainerJobConfig, ContainerJobManager, OrchestratorApi, TokenStore, + api::OrchestratorState, + }, safety::SafetyLayer, secrets::{PostgresSecretsStore, SecretsCrypto, SecretsStore}, - settings::Settings, setup::{SetupConfig, SetupWizard}, tools::{ ToolRegistry, - mcp::{McpClient, McpSessionManager, config::load_mcp_servers, is_authenticated}, - wasm::{WasmToolLoader, WasmToolRuntime}, + mcp::{McpClient, McpSessionManager, config::load_mcp_servers_from_db, is_authenticated}, + wasm::{WasmToolLoader, WasmToolRuntime, load_dev_tools}, }, workspace::{EmbeddingProvider, NearAiEmbeddings, OpenAiEmbeddings, Workspace}, }; @@ -53,9 +56,14 @@ async fn main() -> anyhow::Result<()> { return run_tool_command(tool_cmd.clone()).await; } Some(Command::Config(config_cmd)) => { - // Config commands don't need logging setup - return ironclaw::cli::run_config_command(config_cmd.clone()) - .map_err(|e| anyhow::anyhow!("{}", e)); + // Config commands need DB access for settings + tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("warn")), + ) + .init(); + + return ironclaw::cli::run_config_command(config_cmd.clone()).await; } Some(Command::Mcp(mcp_cmd)) => { // Simple logging for MCP commands @@ -130,6 +138,83 @@ async fn main() -> anyhow::Result<()> { return run_status_command().await; } + Some(Command::Worker { + job_id, + orchestrator_url, + max_iterations, + }) => { + // Worker mode: runs inside a Docker container. + // Simple logging (no TUI, no DB, no channels). + tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::try_from_default_env() + .unwrap_or_else(|_| EnvFilter::new("ironclaw=info")), + ) + .init(); + + tracing::info!( + "Starting worker for job {} (orchestrator: {})", + job_id, + orchestrator_url + ); + + let config = ironclaw::worker::runtime::WorkerConfig { + job_id: *job_id, + orchestrator_url: orchestrator_url.clone(), + max_iterations: *max_iterations, + timeout: std::time::Duration::from_secs(600), + }; + + let runtime = ironclaw::worker::WorkerRuntime::new(config) + .map_err(|e| anyhow::anyhow!("Worker init failed: {}", e))?; + + runtime + .run() + .await + .map_err(|e| anyhow::anyhow!("Worker failed: {}", e))?; + + return Ok(()); + } + Some(Command::ClaudeBridge { + job_id, + orchestrator_url, + max_turns, + model, + }) => { + // Claude Code bridge mode: runs inside a Docker container. + // Spawns the `claude` CLI and streams output to the orchestrator. + tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::try_from_default_env() + .unwrap_or_else(|_| EnvFilter::new("ironclaw=info")), + ) + .init(); + + tracing::info!( + "Starting Claude Code bridge for job {} (orchestrator: {}, model: {})", + job_id, + orchestrator_url, + model + ); + + let config = ironclaw::worker::claude_bridge::ClaudeBridgeConfig { + job_id: *job_id, + orchestrator_url: orchestrator_url.clone(), + max_turns: *max_turns, + model: model.clone(), + timeout: std::time::Duration::from_secs(1800), + }; + + let runtime = ironclaw::worker::ClaudeBridgeRuntime::new(config) + .map_err(|e| anyhow::anyhow!("Claude bridge init failed: {}", e))?; + + runtime + .run() + .await + .map_err(|e| anyhow::anyhow!("Claude bridge failed: {}", e))?; + + return Ok(()); + } Some(Command::Onboard { skip_auth, channels_only, @@ -163,8 +248,11 @@ async fn main() -> anyhow::Result<()> { } } - // Load configuration (after potential setup) - let config = match Config::from_env() { + // Load bootstrap config (4 fields that must live on disk) + let bootstrap = ironclaw::bootstrap::BootstrapConfig::load(); + + // Load initial config from env + disk (before DB is available) + let mut config = match Config::from_env() { Ok(c) => c, Err(ironclaw::error::ConfigError::MissingRequired { key, hint }) => { eprintln!("Configuration error: Missing required setting '{}'", key); @@ -224,7 +312,38 @@ async fn main() -> anyhow::Result<()> { let store = Store::new(&config.database).await?; store.run_migrations().await?; tracing::info!("Database connected and migrations applied"); - Some(Arc::new(store)) + + // One-time migration: move disk config files into the DB settings table. + if let Err(e) = ironclaw::bootstrap::migrate_disk_to_db(&store, "default").await { + tracing::warn!("Disk-to-DB settings migration failed: {}", e); + } + + // Reload config from DB now that we have a connection. + // Priority: env var > DB setting > default. + match Config::from_db(&store, "default", &bootstrap).await { + Ok(db_config) => { + config = db_config; + tracing::info!("Configuration reloaded from database"); + } + Err(e) => { + tracing::warn!( + "Failed to reload config from DB, keeping env-based config: {}", + e + ); + } + } + + let store = Arc::new(store); + + // Attach store to session manager so tokens save to DB too + session.attach_store(Arc::clone(&store), "default").await; + + // Mark any jobs left in "running" or "creating" state as "interrupted". + if let Err(e) = store.cleanup_stale_sandbox_jobs().await { + tracing::warn!("Failed to cleanup stale sandbox jobs: {}", e); + } + + Some(store) }; // Initialize LLM provider (clone session so we can reuse it for embeddings) @@ -289,8 +408,11 @@ async fn main() -> anyhow::Result<()> { tools.register_memory_tools(workspace); } - // Register builder tool if enabled - if config.builder.enabled { + // Register builder tool if enabled. + // When sandbox is enabled and allow_local_tools is false, skip builder registration + // because register_builder_tool also registers dev tools (shell, file ops) that would + // bypass the sandbox. The builder runs inside containers instead. + if config.builder.enabled && (config.agent.allow_local_tools || !config.sandbox.enabled) { tools .register_builder_tool( llm.clone(), @@ -339,6 +461,8 @@ async fn main() -> anyhow::Result<()> { let wasm_tools_future = async { if let Some(ref runtime) = wasm_tool_runtime { let loader = WasmToolLoader::new(Arc::clone(runtime), Arc::clone(&tools)); + + // Load installed tools from ~/.ironclaw/tools/ match loader.load_from_dir(&config.wasm.tools_dir).await { Ok(results) => { if !results.loaded.is_empty() { @@ -356,12 +480,32 @@ async fn main() -> anyhow::Result<()> { tracing::warn!("Failed to scan WASM tools directory: {}", e); } } + + // Load dev tools from build artifacts (overrides installed if newer) + match load_dev_tools(&loader, &config.wasm.tools_dir).await { + Ok(results) => { + if !results.loaded.is_empty() { + tracing::info!( + "Loaded {} dev WASM tools from build artifacts", + results.loaded.len() + ); + } + } + Err(e) => { + tracing::debug!("No dev WASM tools found: {}", e); + } + } } }; let mcp_servers_future = async { if let Some(ref secrets) = secrets_store { - match load_mcp_servers().await { + let servers_result = if let Some(ref s) = store { + load_mcp_servers_from_db(s, "default").await + } else { + ironclaw::tools::mcp::config::load_mcp_servers().await + }; + match servers_result { Ok(servers) => { let enabled: Vec<_> = servers.enabled_servers().cloned().collect(); if !enabled.is_empty() { @@ -470,6 +614,7 @@ async fn main() -> anyhow::Result<()> { config.channels.wasm_channels_dir.clone(), config.tunnel.public_url.clone(), "default".to_string(), + store.clone(), )); tools.register_extension_tools(Arc::clone(&manager)); tracing::info!("Extension manager initialized with in-chat discovery tools"); @@ -482,6 +627,77 @@ async fn main() -> anyhow::Result<()> { None }; + // Set up orchestrator for sandboxed job execution + // When allow_local_tools is false (default), the LLM uses create_job for FS/shell work. + // When allow_local_tools is true, dev tools are also registered directly (current behavior). + if config.agent.allow_local_tools { + tools.register_dev_tools(); + tracing::info!( + "Local tools enabled (allow_local_tools=true), dev tools registered directly" + ); + } + + // Shared state for job events (used by both orchestrator and web gateway) + let job_event_tx: Option< + tokio::sync::broadcast::Sender<(uuid::Uuid, ironclaw::channels::web::types::SseEvent)>, + > = if config.sandbox.enabled { + let (tx, _) = tokio::sync::broadcast::channel(256); + Some(tx) + } else { + None + }; + let prompt_queue = Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::< + uuid::Uuid, + std::collections::VecDeque, + >::new())); + + let container_job_manager: Option> = if config.sandbox.enabled { + let token_store = TokenStore::new(); + let job_config = ContainerJobConfig { + image: config.sandbox.image.clone(), + memory_limit_mb: config.sandbox.memory_limit_mb, + cpu_shares: config.sandbox.cpu_shares, + orchestrator_port: 50051, + claude_config_dir: if config.claude_code.enabled { + Some(config.claude_code.config_dir.clone()) + } else { + None + }, + claude_code_model: config.claude_code.model.clone(), + claude_code_max_turns: config.claude_code.max_turns, + claude_code_memory_limit_mb: config.claude_code.memory_limit_mb, + }; + let jm = Arc::new(ContainerJobManager::new(job_config, token_store.clone())); + + // Start the orchestrator internal API in the background + let orchestrator_state = OrchestratorState { + llm: llm.clone(), + job_manager: Arc::clone(&jm), + token_store, + job_event_tx: job_event_tx.clone(), + prompt_queue: Arc::clone(&prompt_queue), + store: store.clone(), + }; + + tokio::spawn(async move { + if let Err(e) = OrchestratorApi::start(orchestrator_state, 50051).await { + tracing::error!("Orchestrator API failed: {}", e); + } + }); + + tracing::info!("Orchestrator API started on :50051, sandbox delegation enabled"); + if config.claude_code.enabled { + tracing::info!( + "Claude Code sandbox mode available (model: {}, max_turns: {})", + config.claude_code.model, + config.claude_code.max_turns + ); + } + Some(jm) + } else { + None + }; + tracing::info!( "Tool registry initialized with {} total tools", tools.count() @@ -563,6 +779,17 @@ async fn main() -> anyhow::Result<()> { ); } + // Inject owner_id for Telegram so the bot only responds + // to the bound user account. + if channel_name == "telegram" { + if let Some(owner_id) = config.channels.telegram_owner_id { + config_updates.insert( + "owner_id".to_string(), + serde_json::json!(owner_id), + ); + } + } + if !config_updates.is_empty() { channel_arc.update_config(config_updates).await; tracing::info!( @@ -621,7 +848,7 @@ async fn main() -> anyhow::Result<()> { channels.add(Box::new(SharedWasmChannel::new(channel_arc))); } - if has_webhook_channels && config.tunnel.public_url.is_some() { + if has_webhook_channels { webhook_routes.push(create_wasm_channel_router( wasm_router, extension_manager.as_ref().map(Arc::clone), @@ -693,6 +920,19 @@ async fn main() -> anyhow::Result<()> { Arc::new(ws) }); + // Seed workspace with core identity files on first boot + if let Some(ref ws) = workspace { + match ws.seed_if_empty().await { + Ok(count) if count > 0 => { + tracing::info!("Workspace seeded with {} core files", count); + } + Ok(_) => {} + Err(e) => { + tracing::warn!("Failed to seed workspace: {}", e); + } + } + } + // Backfill embeddings if we just enabled the provider if let (Some(ws), Some(_)) = (&workspace, &embeddings) { match ws.backfill_embeddings().await { @@ -712,8 +952,12 @@ async fn main() -> anyhow::Result<()> { // Create session manager (shared between agent and web gateway) let session_manager = Arc::new(SessionManager::new()); - // Register job tools - tools.register_job_tools(Arc::clone(&context_manager)); + // Register job tools (sandbox deps auto-injected when container_job_manager is available) + tools.register_job_tools( + Arc::clone(&context_manager), + container_job_manager.clone(), + store.clone(), + ); // Add web gateway channel if configured if let Some(ref gw_config) = config.channels.gateway { @@ -721,19 +965,44 @@ async fn main() -> anyhow::Result<()> { if let Some(ref ws) = workspace { gw = gw.with_workspace(Arc::clone(ws)); } - gw = gw.with_context_manager(Arc::clone(&context_manager)); gw = gw.with_session_manager(Arc::clone(&session_manager)); gw = gw.with_log_broadcaster(Arc::clone(&log_broadcaster)); gw = gw.with_tool_registry(Arc::clone(&tools)); if let Some(ref ext_mgr) = extension_manager { gw = gw.with_extension_manager(Arc::clone(ext_mgr)); } + if let Some(ref s) = store { + gw = gw.with_store(Arc::clone(s)); + } + if let Some(ref jm) = container_job_manager { + gw = gw.with_job_manager(Arc::clone(jm)); + } + if config.sandbox.enabled { + gw = gw.with_prompt_queue(Arc::clone(&prompt_queue)); + + // Spawn a task to forward job events from the broadcast channel to SSE + if let Some(ref tx) = job_event_tx { + let mut rx = tx.subscribe(); + let gw_state = Arc::clone(gw.state()); + tokio::spawn(async move { + while let Ok((_job_id, event)) = rx.recv().await { + gw_state.sse.broadcast(event); + } + }); + } + } tracing::info!( "Web gateway enabled on {}:{}", gw_config.host, gw_config.port ); + tracing::info!( + "Web UI: http://{}:{}/?token={}", + gw_config.host, + gw_config.port, + gw.auth_token() + ); channels.add(Box::new(gw)); } @@ -752,6 +1021,7 @@ async fn main() -> anyhow::Result<()> { deps, channels, Some(config.heartbeat.clone()), + Some(config.routines.clone()), Some(context_manager), Some(session_manager), ); @@ -774,15 +1044,15 @@ async fn main() -> anyhow::Result<()> { /// /// Returns `Some(reason)` if onboarding should be triggered, `None` otherwise. fn check_onboard_needed() -> Option<&'static str> { - let settings = Settings::load(); + let bootstrap = ironclaw::bootstrap::BootstrapConfig::load(); // Database not configured (and not in env) - if settings.database_url.is_none() && std::env::var("DATABASE_URL").is_err() { + if bootstrap.database_url.is_none() && std::env::var("DATABASE_URL").is_err() { return Some("Database not configured"); } // Secrets not configured (and not in env) - if settings.secrets_master_key_source == ironclaw::settings::KeySource::None + if bootstrap.secrets_master_key_source == ironclaw::settings::KeySource::None && std::env::var("SECRETS_MASTER_KEY").is_err() && !ironclaw::secrets::keychain::has_master_key() { @@ -792,7 +1062,7 @@ fn check_onboard_needed() -> Option<&'static str> { // First run (onboarding never completed and no session) let session_path = ironclaw::llm::session::default_session_path(); - if !settings.onboard_completed && !session_path.exists() { + if !bootstrap.onboard_completed && !session_path.exists() { return Some("First run"); } diff --git a/src/orchestrator/api.rs b/src/orchestrator/api.rs new file mode 100644 index 00000000..b209403a --- /dev/null +++ b/src/orchestrator/api.rs @@ -0,0 +1,511 @@ +//! Internal HTTP API for worker-to-orchestrator communication. +//! +//! This runs on a separate port (default 50051) from the web gateway. +//! All endpoints are authenticated via per-job bearer tokens. + +use std::collections::{HashMap, VecDeque}; +use std::sync::Arc; + +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use serde::{Deserialize, Serialize}; +use tokio::sync::{Mutex, broadcast}; +use uuid::Uuid; + +use crate::channels::web::types::SseEvent; +use crate::history::Store; +use crate::llm::{CompletionRequest, LlmProvider, ToolCompletionRequest}; +use crate::orchestrator::auth::{TokenStore, worker_auth_middleware}; +use crate::orchestrator::job_manager::ContainerJobManager; +use crate::worker::api::JobEventPayload; +use crate::worker::api::{ + CompletionReport, JobDescription, ProxyCompletionRequest, ProxyCompletionResponse, + ProxyToolCompletionRequest, ProxyToolCompletionResponse, StatusUpdate, +}; + +/// A follow-up prompt queued for a Claude Code bridge. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PendingPrompt { + pub content: String, + pub done: bool, +} + +/// Shared state for the orchestrator API. +#[derive(Clone)] +pub struct OrchestratorState { + pub llm: Arc, + pub job_manager: Arc, + pub token_store: TokenStore, + /// Broadcast channel for job events (consumed by the web gateway SSE). + pub job_event_tx: Option>, + /// Buffered follow-up prompts for sandbox jobs, keyed by job_id. + pub prompt_queue: Arc>>>, + /// Database handle for persisting job events. + pub store: Option>, +} + +/// The orchestrator's internal API server. +pub struct OrchestratorApi; + +impl OrchestratorApi { + /// Build the axum router for the internal API. + pub fn router(state: OrchestratorState) -> Router { + Router::new() + // Worker routes: authenticated via route_layer middleware. + .route("/worker/{job_id}/job", get(get_job)) + .route("/worker/{job_id}/llm/complete", post(llm_complete)) + .route( + "/worker/{job_id}/llm/complete_with_tools", + post(llm_complete_with_tools), + ) + .route("/worker/{job_id}/status", post(report_status)) + .route("/worker/{job_id}/complete", post(report_complete)) + .route("/worker/{job_id}/event", post(job_event_handler)) + .route("/worker/{job_id}/prompt", get(get_prompt_handler)) + .route_layer(axum::middleware::from_fn_with_state( + state.token_store.clone(), + worker_auth_middleware, + )) + // Unauthenticated routes (added after the layer). + .route("/health", get(health_check)) + .with_state(state) + } + + /// Start the internal API server on the given port. + /// + /// On macOS/Windows (Docker Desktop), binds to loopback only because + /// Docker Desktop routes `host.docker.internal` through its VM to the + /// host's `127.0.0.1`. + /// + /// On Linux, containers reach the host via the docker bridge gateway + /// (`172.17.0.1`), which is NOT loopback. Binding to `127.0.0.1` + /// would reject container traffic. We bind to all interfaces instead + /// and rely on `worker_auth_middleware` (applied as a route_layer on + /// every `/worker/` endpoint) to reject unauthenticated requests. + pub async fn start( + state: OrchestratorState, + port: u16, + ) -> Result<(), Box> { + let router = Self::router(state); + let addr = if cfg!(target_os = "linux") { + std::net::SocketAddr::from(([0, 0, 0, 0], port)) + } else { + std::net::SocketAddr::from(([127, 0, 0, 1], port)) + }; + + tracing::info!("Orchestrator internal API listening on {}", addr); + + let listener = tokio::net::TcpListener::bind(addr).await?; + axum::serve(listener, router).await?; + + Ok(()) + } +} + +// -- Handlers -- +// +// All /worker/ handlers below are behind the worker_auth_middleware route_layer, +// so they don't need to validate tokens themselves. + +async fn health_check() -> &'static str { + "ok" +} + +async fn get_job( + State(state): State, + Path(job_id): Path, +) -> Result, StatusCode> { + let handle = state + .job_manager + .get_handle(job_id) + .await + .ok_or(StatusCode::NOT_FOUND)?; + + Ok(Json(JobDescription { + title: format!("Job {}", job_id), + description: handle.task_description, + project_dir: handle.project_dir.map(|p| p.display().to_string()), + })) +} + +async fn llm_complete( + State(state): State, + Path(job_id): Path, + Json(req): Json, +) -> Result, StatusCode> { + let completion_req = CompletionRequest { + messages: req.messages, + max_tokens: req.max_tokens, + temperature: req.temperature, + stop_sequences: req.stop_sequences, + metadata: std::collections::HashMap::new(), + }; + + let resp = state.llm.complete(completion_req).await.map_err(|e| { + tracing::error!("LLM completion failed for job {}: {}", job_id, e); + StatusCode::BAD_GATEWAY + })?; + + Ok(Json(ProxyCompletionResponse { + content: resp.content, + input_tokens: resp.input_tokens, + output_tokens: resp.output_tokens, + finish_reason: format_finish_reason(resp.finish_reason), + })) +} + +async fn llm_complete_with_tools( + State(state): State, + Path(job_id): Path, + Json(req): Json, +) -> Result, StatusCode> { + let tool_req = ToolCompletionRequest { + messages: req.messages, + tools: req.tools, + max_tokens: req.max_tokens, + temperature: req.temperature, + tool_choice: req.tool_choice, + metadata: std::collections::HashMap::new(), + }; + + let resp = state.llm.complete_with_tools(tool_req).await.map_err(|e| { + tracing::error!("LLM tool completion failed for job {}: {}", job_id, e); + StatusCode::BAD_GATEWAY + })?; + + Ok(Json(ProxyToolCompletionResponse { + content: resp.content, + tool_calls: resp.tool_calls, + input_tokens: resp.input_tokens, + output_tokens: resp.output_tokens, + finish_reason: format_finish_reason(resp.finish_reason), + })) +} + +async fn report_status( + Path(job_id): Path, + Json(update): Json, +) -> Result { + tracing::debug!( + job_id = %job_id, + state = %update.state, + iteration = update.iteration, + "Worker status update" + ); + + Ok(StatusCode::OK) +} + +async fn report_complete( + State(state): State, + Path(job_id): Path, + Json(report): Json, +) -> Result { + if report.success { + tracing::info!( + job_id = %job_id, + "Worker reported job complete" + ); + } else { + tracing::warn!( + job_id = %job_id, + message = ?report.message, + "Worker reported job failure" + ); + } + + // Store the result and clean up the container + let result = crate::orchestrator::job_manager::CompletionResult { + success: report.success, + message: report.message.clone(), + }; + let _ = state.job_manager.complete_job(job_id, result).await; + + Ok(StatusCode::OK) +} + +// -- Sandbox job event handlers -- + +/// Receive a job event from a worker or Claude Code bridge and broadcast + persist it. +async fn job_event_handler( + State(state): State, + Path(job_id): Path, + Json(payload): Json, +) -> Result { + tracing::debug!( + job_id = %job_id, + event_type = %payload.event_type, + "Job event received" + ); + + // Persist to DB (fire-and-forget) + if let Some(ref store) = state.store { + let store = Arc::clone(store); + let event_type = payload.event_type.clone(); + let data = payload.data.clone(); + tokio::spawn(async move { + if let Err(e) = store.save_job_event(job_id, &event_type, &data).await { + tracing::warn!(job_id = %job_id, "Failed to persist job event: {}", e); + } + }); + } + + // Convert to SSE event and broadcast + let job_id_str = job_id.to_string(); + let sse_event = match payload.event_type.as_str() { + "message" => SseEvent::JobMessage { + job_id: job_id_str, + role: payload + .data + .get("role") + .and_then(|v| v.as_str()) + .unwrap_or("assistant") + .to_string(), + content: payload + .data + .get("content") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + }, + "tool_use" => SseEvent::JobToolUse { + job_id: job_id_str, + tool_name: payload + .data + .get("tool_name") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(), + input: payload + .data + .get("input") + .cloned() + .unwrap_or(serde_json::Value::Null), + }, + "tool_result" => SseEvent::JobToolResult { + job_id: job_id_str, + tool_name: payload + .data + .get("tool_name") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(), + output: payload + .data + .get("output") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + }, + "result" => SseEvent::JobResult { + job_id: job_id_str, + status: payload + .data + .get("status") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(), + session_id: payload + .data + .get("session_id") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + }, + _ => SseEvent::JobStatus { + job_id: job_id_str, + message: payload + .data + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + }, + }; + + // Broadcast via the channel (if configured) + if let Some(ref tx) = state.job_event_tx { + let _ = tx.send((job_id, sse_event)); + } + + Ok(StatusCode::OK) +} + +/// Return the next queued follow-up prompt for a Claude Code bridge. +/// Returns 204 No Content if no prompt is available. +async fn get_prompt_handler( + State(state): State, + Path(job_id): Path, +) -> Result<(StatusCode, Json), StatusCode> { + let mut queue = state.prompt_queue.lock().await; + if let Some(prompts) = queue.get_mut(&job_id) { + if let Some(prompt) = prompts.pop_front() { + return Ok(( + StatusCode::OK, + Json(serde_json::json!({ + "content": prompt.content, + "done": prompt.done, + })), + )); + } + } + + // Return 204 with an empty body. The Json wrapper requires some value + // but the status code signals "nothing here". + Ok((StatusCode::NO_CONTENT, Json(serde_json::Value::Null))) +} + +fn format_finish_reason(reason: crate::llm::FinishReason) -> String { + match reason { + crate::llm::FinishReason::Stop => "stop".to_string(), + crate::llm::FinishReason::Length => "length".to_string(), + crate::llm::FinishReason::ToolUse => "tool_use".to_string(), + crate::llm::FinishReason::ContentFilter => "content_filter".to_string(), + crate::llm::FinishReason::Unknown => "unknown".to_string(), + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use axum::body::Body; + use axum::http::Request; + use tower::ServiceExt; + use uuid::Uuid; + + use crate::error::LlmError; + use crate::llm::{ + CompletionRequest, CompletionResponse, ToolCompletionRequest, ToolCompletionResponse, + }; + use crate::orchestrator::auth::TokenStore; + use crate::orchestrator::job_manager::{ContainerJobConfig, ContainerJobManager}; + + use super::*; + + /// Stub LLM provider that panics if called (tests only exercise routing/auth). + struct StubLlm; + + #[async_trait::async_trait] + impl crate::llm::LlmProvider for StubLlm { + fn model_name(&self) -> &str { + "stub" + } + fn cost_per_token(&self) -> (rust_decimal::Decimal, rust_decimal::Decimal) { + (rust_decimal::Decimal::ZERO, rust_decimal::Decimal::ZERO) + } + async fn complete(&self, _req: CompletionRequest) -> Result { + Err(LlmError::RequestFailed { + provider: "stub".into(), + reason: "not implemented".into(), + }) + } + async fn complete_with_tools( + &self, + _req: ToolCompletionRequest, + ) -> Result { + Err(LlmError::RequestFailed { + provider: "stub".into(), + reason: "not implemented".into(), + }) + } + } + + fn test_state() -> OrchestratorState { + let token_store = TokenStore::new(); + let jm = ContainerJobManager::new(ContainerJobConfig::default(), token_store.clone()); + OrchestratorState { + llm: Arc::new(StubLlm), + job_manager: Arc::new(jm), + token_store, + job_event_tx: None, + prompt_queue: Arc::new(Mutex::new(HashMap::new())), + store: None, + } + } + + #[tokio::test] + async fn health_requires_no_auth() { + let state = test_state(); + let router = OrchestratorApi::router(state); + + let req = Request::builder() + .uri("/health") + .body(Body::empty()) + .unwrap(); + + let resp = router.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + } + + #[tokio::test] + async fn worker_route_rejects_missing_token() { + let state = test_state(); + let router = OrchestratorApi::router(state); + + let job_id = Uuid::new_v4(); + let req = Request::builder() + .uri(format!("/worker/{}/job", job_id)) + .body(Body::empty()) + .unwrap(); + + let resp = router.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn worker_route_rejects_wrong_token() { + let state = test_state(); + let router = OrchestratorApi::router(state); + + let job_id = Uuid::new_v4(); + let req = Request::builder() + .uri(format!("/worker/{}/job", job_id)) + .header("Authorization", "Bearer totally-bogus") + .body(Body::empty()) + .unwrap(); + + let resp = router.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn worker_route_accepts_valid_token() { + let state = test_state(); + let job_id = Uuid::new_v4(); + let token = state.token_store.create_token(job_id).await; + + let router = OrchestratorApi::router(state); + + let req = Request::builder() + .uri(format!("/worker/{}/job", job_id)) + .header("Authorization", format!("Bearer {}", token)) + .body(Body::empty()) + .unwrap(); + + let resp = router.oneshot(req).await.unwrap(); + // 404 because no container exists for this job_id, but NOT 401. + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn token_for_job_a_rejected_on_job_b() { + let state = test_state(); + let job_a = Uuid::new_v4(); + let job_b = Uuid::new_v4(); + let token_a = state.token_store.create_token(job_a).await; + + let router = OrchestratorApi::router(state); + + // Use job_a's token to hit job_b's endpoint + let req = Request::builder() + .uri(format!("/worker/{}/job", job_b)) + .header("Authorization", format!("Bearer {}", token_a)) + .body(Body::empty()) + .unwrap(); + + let resp = router.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + } +} diff --git a/src/orchestrator/auth.rs b/src/orchestrator/auth.rs new file mode 100644 index 00000000..c3045b9a --- /dev/null +++ b/src/orchestrator/auth.rs @@ -0,0 +1,162 @@ +//! Per-job bearer token authentication for worker-to-orchestrator communication. +//! +//! Security properties: +//! - Tokens are cryptographically random (32 bytes, hex-encoded) +//! - Tokens are scoped to a specific job_id +//! - Tokens are ephemeral (in-memory only, never persisted) +//! - A token for Job A cannot access endpoints for Job B + +use std::collections::HashMap; +use std::sync::Arc; + +use axum::extract::{Request, State}; +use axum::http::StatusCode; +use axum::middleware::Next; +use axum::response::Response; +use rand::Rng; +use tokio::sync::RwLock; +use uuid::Uuid; + +/// In-memory store for per-job authentication tokens. +#[derive(Clone)] +pub struct TokenStore { + /// Maps job_id -> bearer token. Never logged or persisted. + tokens: Arc>>, +} + +impl TokenStore { + pub fn new() -> Self { + Self { + tokens: Arc::new(RwLock::new(HashMap::new())), + } + } + + /// Generate and store a new token for a job. + pub async fn create_token(&self, job_id: Uuid) -> String { + let token = generate_token(); + self.tokens.write().await.insert(job_id, token.clone()); + token + } + + /// Validate a token for a specific job. + pub async fn validate(&self, job_id: Uuid, token: &str) -> bool { + self.tokens + .read() + .await + .get(&job_id) + .map(|stored| stored == token) + .unwrap_or(false) + } + + /// Remove a token (on container cleanup). + pub async fn revoke(&self, job_id: Uuid) { + self.tokens.write().await.remove(&job_id); + } + + /// Get the number of active tokens (for diagnostics). + pub async fn active_count(&self) -> usize { + self.tokens.read().await.len() + } +} + +impl Default for TokenStore { + fn default() -> Self { + Self::new() + } +} + +/// Generate a cryptographically random token (32 bytes, hex-encoded = 64 chars). +fn generate_token() -> String { + let mut bytes = [0u8; 32]; + rand::thread_rng().fill(&mut bytes); + hex_encode(&bytes) +} + +fn hex_encode(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{:02x}", b)).collect() +} + +/// Axum middleware that validates worker bearer tokens. +/// +/// Extracts the job_id from the path (`/worker/{job_id}/...`) and validates +/// the `Authorization: Bearer ` header against the token store. +/// +/// Wire up with `axum::middleware::from_fn_with_state(token_store, worker_auth_middleware)`. +pub async fn worker_auth_middleware( + State(token_store): State, + request: Request, + next: Next, +) -> Result { + let path = request.uri().path().to_string(); + let job_id = extract_job_id_from_path(&path).ok_or(StatusCode::BAD_REQUEST)?; + + let token = request + .headers() + .get("authorization") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")) + .ok_or(StatusCode::UNAUTHORIZED)?; + + if !token_store.validate(job_id, token).await { + return Err(StatusCode::UNAUTHORIZED); + } + + Ok(next.run(request).await) +} + +/// Extract job UUID from a path like `/worker/{uuid}/...` +fn extract_job_id_from_path(path: &str) -> Option { + let parts: Vec<&str> = path.trim_start_matches('/').split('/').collect(); + if parts.len() >= 2 && parts[0] == "worker" { + Uuid::parse_str(parts[1]).ok() + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_token_create_and_validate() { + let store = TokenStore::new(); + let job_id = Uuid::new_v4(); + + let token = store.create_token(job_id).await; + assert_eq!(token.len(), 64); // 32 bytes hex = 64 chars + + assert!(store.validate(job_id, &token).await); + assert!(!store.validate(job_id, "wrong-token").await); + assert!(!store.validate(Uuid::new_v4(), &token).await); + } + + #[tokio::test] + async fn test_token_revoke() { + let store = TokenStore::new(); + let job_id = Uuid::new_v4(); + + let token = store.create_token(job_id).await; + assert!(store.validate(job_id, &token).await); + + store.revoke(job_id).await; + assert!(!store.validate(job_id, &token).await); + } + + #[test] + fn test_extract_job_id() { + let id = Uuid::new_v4(); + let path = format!("/worker/{}/llm/complete", id); + assert_eq!(extract_job_id_from_path(&path), Some(id)); + + assert_eq!(extract_job_id_from_path("/other/path"), None); + assert_eq!(extract_job_id_from_path("/worker/not-a-uuid/foo"), None); + } + + #[test] + fn test_token_is_random() { + let t1 = generate_token(); + let t2 = generate_token(); + assert_ne!(t1, t2); + } +} diff --git a/src/orchestrator/job_manager.rs b/src/orchestrator/job_manager.rs new file mode 100644 index 00000000..6d4f9204 --- /dev/null +++ b/src/orchestrator/job_manager.rs @@ -0,0 +1,474 @@ +//! Container lifecycle management for sandboxed jobs. +//! +//! Extends the existing `SandboxManager` infrastructure to support persistent +//! containers with their own agent loops (as opposed to ephemeral per-command containers). + +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; + +use chrono::{DateTime, Utc}; +use tokio::sync::RwLock; +use uuid::Uuid; + +use crate::error::OrchestratorError; +use crate::orchestrator::auth::TokenStore; +use crate::sandbox::connect_docker; + +/// Which mode a sandbox container runs in. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum JobMode { + /// Standard IronClaw worker with proxied LLM calls. + Worker, + /// Claude Code bridge that spawns the `claude` CLI directly. + ClaudeCode, +} + +impl JobMode { + pub fn as_str(&self) -> &'static str { + match self { + Self::Worker => "worker", + Self::ClaudeCode => "claude_code", + } + } +} + +impl std::fmt::Display for JobMode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +/// Configuration for the container job manager. +#[derive(Debug, Clone)] +pub struct ContainerJobConfig { + /// Docker image for worker containers. + pub image: String, + /// Default memory limit in MB. + pub memory_limit_mb: u64, + /// Default CPU shares. + pub cpu_shares: u32, + /// Port the orchestrator internal API listens on. + pub orchestrator_port: u16, + /// Host directory containing Claude auth config (mounted read-only for ClaudeCode mode). + pub claude_config_dir: Option, + /// Claude model to use in ClaudeCode mode. + pub claude_code_model: String, + /// Maximum turns for Claude Code. + pub claude_code_max_turns: u32, + /// Memory limit in MB for Claude Code containers (heavier than workers). + pub claude_code_memory_limit_mb: u64, +} + +impl Default for ContainerJobConfig { + fn default() -> Self { + Self { + image: "ironclaw-worker:latest".to_string(), + memory_limit_mb: 2048, + cpu_shares: 1024, + orchestrator_port: 50051, + claude_config_dir: None, + claude_code_model: "sonnet".to_string(), + claude_code_max_turns: 50, + claude_code_memory_limit_mb: 4096, + } + } +} + +/// State of a container. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ContainerState { + Creating, + Running, + Stopped, + Failed, +} + +impl std::fmt::Display for ContainerState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Creating => write!(f, "creating"), + Self::Running => write!(f, "running"), + Self::Stopped => write!(f, "stopped"), + Self::Failed => write!(f, "failed"), + } + } +} + +/// Handle to a running container job. +#[derive(Debug, Clone)] +pub struct ContainerHandle { + pub job_id: Uuid, + pub container_id: String, + pub state: ContainerState, + pub mode: JobMode, + pub created_at: DateTime, + pub project_dir: Option, + pub task_description: String, + /// Completion result from the worker (set when the worker reports done). + pub completion_result: Option, + // NOTE: auth_token is intentionally NOT in this struct. + // It lives only in the TokenStore (never logged, serialized, or persisted). +} + +/// Result reported by a worker on completion. +#[derive(Debug, Clone)] +pub struct CompletionResult { + pub success: bool, + pub message: Option, +} + +/// Manages the lifecycle of Docker containers for sandboxed job execution. +pub struct ContainerJobManager { + config: ContainerJobConfig, + token_store: TokenStore, + containers: Arc>>, +} + +impl ContainerJobManager { + pub fn new(config: ContainerJobConfig, token_store: TokenStore) -> Self { + Self { + config, + token_store, + containers: Arc::new(RwLock::new(HashMap::new())), + } + } + + /// Create and start a new container for a job. + /// + /// The caller provides the `job_id` so it can be persisted to the database + /// before the container is created. Returns the auth token for the worker. + pub async fn create_job( + &self, + job_id: Uuid, + task: &str, + project_dir: Option, + mode: JobMode, + ) -> Result { + // Generate auth token (stored in TokenStore, never logged) + let token = self.token_store.create_token(job_id).await; + + // Record the handle + let handle = ContainerHandle { + job_id, + container_id: String::new(), // set after container creation + state: ContainerState::Creating, + mode, + created_at: Utc::now(), + project_dir: project_dir.clone(), + task_description: task.to_string(), + completion_result: None, + }; + self.containers.write().await.insert(job_id, handle); + + // Connect to Docker + let docker = connect_docker() + .await + .map_err(|e| OrchestratorError::Docker { + reason: e.to_string(), + })?; + + // Build container configuration + let orchestrator_host = if cfg!(target_os = "linux") { + "172.17.0.1" + } else { + "host.docker.internal" + }; + + let orchestrator_url = format!( + "http://{}:{}", + orchestrator_host, self.config.orchestrator_port + ); + + let mut env_vec = vec![ + format!("IRONCLAW_WORKER_TOKEN={}", token), + format!("IRONCLAW_JOB_ID={}", job_id), + format!("IRONCLAW_ORCHESTRATOR_URL={}", orchestrator_url), + ]; + + // Build volume mounts (validate project_dir stays within ~/.ironclaw/projects/) + let mut binds = Vec::new(); + if let Some(ref dir) = project_dir { + let canonical = + dir.canonicalize() + .map_err(|e| OrchestratorError::ContainerCreationFailed { + job_id, + reason: format!( + "failed to canonicalize project dir {}: {}", + dir.display(), + e + ), + })?; + let projects_base = dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".ironclaw") + .join("projects"); + if let Ok(canonical_base) = projects_base.canonicalize() { + if !canonical.starts_with(&canonical_base) { + return Err(OrchestratorError::ContainerCreationFailed { + job_id, + reason: format!( + "project directory {} is outside allowed base {}", + canonical.display(), + canonical_base.display() + ), + }); + } + } + binds.push(format!("{}:/workspace:rw", canonical.display())); + env_vec.push("IRONCLAW_WORKSPACE=/workspace".to_string()); + } + + // Claude Code mode: mount host ~/.claude read-only for auth + if mode == JobMode::ClaudeCode { + if let Some(ref claude_dir) = self.config.claude_config_dir { + binds.push(format!("{}:/home/sandbox/.claude:ro", claude_dir.display())); + } + } + + // Memory limit: Claude Code gets more memory + let memory_mb = match mode { + JobMode::ClaudeCode => self.config.claude_code_memory_limit_mb, + JobMode::Worker => self.config.memory_limit_mb, + }; + + // Create the container + use bollard::container::{Config, CreateContainerOptions}; + use bollard::models::HostConfig; + + let host_config = HostConfig { + binds: if binds.is_empty() { None } else { Some(binds) }, + memory: Some((memory_mb * 1024 * 1024) as i64), + cpu_shares: Some(self.config.cpu_shares as i64), + network_mode: Some("bridge".to_string()), + extra_hosts: Some(vec!["host.docker.internal:host-gateway".to_string()]), + cap_drop: Some(vec!["ALL".to_string()]), + cap_add: Some(vec![ + "CHOWN".to_string(), + "SETUID".to_string(), + "SETGID".to_string(), + ]), + security_opt: Some(vec!["no-new-privileges:true".to_string()]), + tmpfs: Some( + [("/tmp".to_string(), "size=512M".to_string())] + .into_iter() + .collect(), + ), + ..Default::default() + }; + + // Build CMD based on mode + let cmd = match mode { + JobMode::Worker => vec![ + "worker".to_string(), + "--job-id".to_string(), + job_id.to_string(), + "--orchestrator-url".to_string(), + orchestrator_url, + ], + JobMode::ClaudeCode => vec![ + "claude-bridge".to_string(), + "--job-id".to_string(), + job_id.to_string(), + "--orchestrator-url".to_string(), + orchestrator_url, + "--max-turns".to_string(), + self.config.claude_code_max_turns.to_string(), + "--model".to_string(), + self.config.claude_code_model.clone(), + ], + }; + + let container_config = Config { + image: Some(self.config.image.clone()), + cmd: Some(cmd), + env: Some(env_vec), + host_config: Some(host_config), + user: Some("1000:1000".to_string()), + working_dir: Some("/workspace".to_string()), + ..Default::default() + }; + + let container_name = match mode { + JobMode::Worker => format!("ironclaw-worker-{}", job_id), + JobMode::ClaudeCode => format!("ironclaw-claude-{}", job_id), + }; + let options = CreateContainerOptions { + name: container_name, + ..Default::default() + }; + + let response = docker + .create_container(Some(options), container_config) + .await + .map_err(|e| OrchestratorError::ContainerCreationFailed { + job_id, + reason: e.to_string(), + })?; + + let container_id = response.id; + + // Start the container + docker + .start_container::(&container_id, None) + .await + .map_err(|e| OrchestratorError::ContainerCreationFailed { + job_id, + reason: format!("failed to start container: {}", e), + })?; + + // Update handle with container ID + if let Some(handle) = self.containers.write().await.get_mut(&job_id) { + handle.container_id = container_id; + handle.state = ContainerState::Running; + } + + tracing::info!( + job_id = %job_id, + "Created and started worker container" + ); + + Ok(token) + } + + /// Stop a running container job. + pub async fn stop_job(&self, job_id: Uuid) -> Result<(), OrchestratorError> { + let container_id = { + let containers = self.containers.read().await; + containers + .get(&job_id) + .map(|h| h.container_id.clone()) + .ok_or(OrchestratorError::ContainerNotFound { job_id })? + }; + + if container_id.is_empty() { + return Err(OrchestratorError::InvalidContainerState { + job_id, + state: "creating (no container ID yet)".to_string(), + }); + } + + let docker = connect_docker() + .await + .map_err(|e| OrchestratorError::Docker { + reason: e.to_string(), + })?; + + // Stop the container (10 second grace period) + let _ = docker + .stop_container( + &container_id, + Some(bollard::container::StopContainerOptions { t: 10 }), + ) + .await; + + // Remove the container + let _ = docker + .remove_container( + &container_id, + Some(bollard::container::RemoveContainerOptions { + force: true, + ..Default::default() + }), + ) + .await; + + // Update state + if let Some(handle) = self.containers.write().await.get_mut(&job_id) { + handle.state = ContainerState::Stopped; + } + + // Revoke the auth token + self.token_store.revoke(job_id).await; + + tracing::info!(job_id = %job_id, "Stopped worker container"); + + Ok(()) + } + + /// Mark a job as complete with a result. The container is stopped but the + /// handle is kept so `CreateJobTool` can read the completion message. + pub async fn complete_job( + &self, + job_id: Uuid, + result: CompletionResult, + ) -> Result<(), OrchestratorError> { + // Store the result before stopping + { + let mut containers = self.containers.write().await; + if let Some(handle) = containers.get_mut(&job_id) { + handle.completion_result = Some(result); + handle.state = ContainerState::Stopped; + } + } + + // Stop container and revoke token (but keep handle in map) + let container_id = { + let containers = self.containers.read().await; + containers.get(&job_id).map(|h| h.container_id.clone()) + }; + if let Some(cid) = container_id { + if !cid.is_empty() { + if let Ok(docker) = connect_docker().await { + let _ = docker + .stop_container( + &cid, + Some(bollard::container::StopContainerOptions { t: 5 }), + ) + .await; + let _ = docker + .remove_container( + &cid, + Some(bollard::container::RemoveContainerOptions { + force: true, + ..Default::default() + }), + ) + .await; + } + } + } + self.token_store.revoke(job_id).await; + + tracing::info!(job_id = %job_id, "Completed worker container"); + Ok(()) + } + + /// Remove a completed job handle from memory (called after result is read). + pub async fn cleanup_job(&self, job_id: Uuid) { + self.containers.write().await.remove(&job_id); + } + + /// Get the handle for a job. + pub async fn get_handle(&self, job_id: Uuid) -> Option { + self.containers.read().await.get(&job_id).cloned() + } + + /// List all active container jobs. + pub async fn list_jobs(&self) -> Vec { + self.containers.read().await.values().cloned().collect() + } + + /// Get a reference to the token store. + pub fn token_store(&self) -> &TokenStore { + &self.token_store + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_container_job_config_default() { + let config = ContainerJobConfig::default(); + assert_eq!(config.orchestrator_port, 50051); + assert_eq!(config.memory_limit_mb, 2048); + } + + #[test] + fn test_container_state_display() { + assert_eq!(ContainerState::Running.to_string(), "running"); + assert_eq!(ContainerState::Stopped.to_string(), "stopped"); + } +} diff --git a/src/orchestrator/mod.rs b/src/orchestrator/mod.rs new file mode 100644 index 00000000..8462f6aa --- /dev/null +++ b/src/orchestrator/mod.rs @@ -0,0 +1,37 @@ +//! Orchestrator for managing sandboxed worker containers. +//! +//! The orchestrator runs in the main agent process and provides: +//! - An internal HTTP API for worker communication (LLM proxy, status, secrets) +//! - Per-job bearer token authentication +//! - Container lifecycle management (create, monitor, stop) +//! +//! ```text +//! ┌───────────────────────────────────────────────┐ +//! │ Orchestrator │ +//! │ │ +//! │ Internal API (:50051) │ +//! │ POST /worker/{id}/llm/complete │ +//! │ POST /worker/{id}/llm/complete_with_tools │ +//! │ GET /worker/{id}/job │ +//! │ POST /worker/{id}/status │ +//! │ POST /worker/{id}/complete │ +//! │ │ +//! │ ContainerJobManager │ +//! │ create_job() -> container + token │ +//! │ stop_job() │ +//! │ list_jobs() │ +//! │ │ +//! │ TokenStore │ +//! │ per-job bearer tokens (in-memory only) │ +//! └───────────────────────────────────────────────┘ +//! ``` + +pub mod api; +pub mod auth; +pub mod job_manager; + +pub use api::OrchestratorApi; +pub use auth::TokenStore; +pub use job_manager::{ + CompletionResult, ContainerHandle, ContainerJobConfig, ContainerJobManager, JobMode, +}; diff --git a/src/sandbox/container.rs b/src/sandbox/container.rs index 29289667..87bec652 100644 --- a/src/sandbox/container.rs +++ b/src/sandbox/container.rs @@ -491,9 +491,36 @@ impl ContainerRunner { } /// Connect to the Docker daemon. +/// +/// Tries these locations in order: +/// 1. `DOCKER_HOST` env var (bollard default) +/// 2. `/var/run/docker.sock` (Linux default) +/// 3. `~/.docker/run/docker.sock` (Docker Desktop on macOS) pub async fn connect_docker() -> Result { - Docker::connect_with_local_defaults().map_err(|e| SandboxError::DockerNotAvailable { - reason: e.to_string(), + // First try bollard defaults (checks DOCKER_HOST, then /var/run/docker.sock) + if let Ok(docker) = Docker::connect_with_local_defaults() { + if docker.ping().await.is_ok() { + return Ok(docker); + } + } + + // Try Docker Desktop socket (macOS) + if let Some(home) = std::env::var_os("HOME") { + let desktop_sock = std::path::Path::new(&home).join(".docker/run/docker.sock"); + if desktop_sock.exists() { + let sock_str = desktop_sock.to_string_lossy(); + if let Ok(docker) = + Docker::connect_with_socket(&sock_str, 120, bollard::API_DEFAULT_VERSION) + { + if docker.ping().await.is_ok() { + return Ok(docker); + } + } + } + } + + Err(SandboxError::DockerNotAvailable { + reason: "Socket not found: /var/run/docker.sock".to_string(), }) } diff --git a/src/settings.rs b/src/settings.rs index 4f8cda30..59165c55 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -149,6 +149,11 @@ pub struct ChannelSettings { #[serde(default)] pub http_host: Option, + /// Telegram owner user ID. When set, the bot only responds to this user. + /// Captured during setup by having the user message the bot. + #[serde(default)] + pub telegram_owner_id: Option, + /// Enabled WASM channels by name. /// Channels not in this list but present in the channels directory will still load. /// This is primarily used by the setup wizard to track which channels were configured. @@ -490,6 +495,51 @@ impl Settings { .join("settings.json") } + /// Reconstruct Settings from a flat key-value map (as stored in the DB). + /// + /// Each key is a dotted path (e.g., "agent.name"), value is a JSONB value. + /// Missing keys get their default value. + pub fn from_db_map(map: &std::collections::HashMap) -> Self { + // Start with defaults, then overlay each DB setting + let mut settings = Self::default(); + + for (key, value) in map { + // Convert the JSONB value to a string for the existing set() method + let value_str = match value { + serde_json::Value::String(s) => s.clone(), + serde_json::Value::Bool(b) => b.to_string(), + serde_json::Value::Number(n) => n.to_string(), + serde_json::Value::Null => "null".to_string(), + other => other.to_string(), + }; + + if let Err(e) = settings.set(key, &value_str) { + tracing::warn!( + "Failed to apply DB setting '{}' = '{}': {}", + key, + value_str, + e + ); + } + } + + settings + } + + /// Flatten Settings into a key-value map suitable for DB storage. + /// + /// Each entry is a (dotted_path, JSONB value) pair. + pub fn to_db_map(&self) -> std::collections::HashMap { + let json = match serde_json::to_value(self) { + Ok(v) => v, + Err(_) => return std::collections::HashMap::new(), + }; + + let mut map = std::collections::HashMap::new(); + collect_settings_json(&json, String::new(), &mut map); + map + } + /// Load settings from disk, returning default if not found. pub fn load() -> Self { Self::load_from(&Self::default_path()) @@ -656,6 +706,29 @@ impl Settings { } } +/// Recursively collect settings paths with their JSON values (for DB storage). +fn collect_settings_json( + value: &serde_json::Value, + prefix: String, + results: &mut std::collections::HashMap, +) { + match value { + serde_json::Value::Object(obj) => { + for (key, val) in obj { + let path = if prefix.is_empty() { + key.clone() + } else { + format!("{}.{}", prefix, key) + }; + collect_settings_json(val, path, results); + } + } + other => { + results.insert(prefix, other.clone()); + } + } +} + /// Recursively collect settings paths and values. fn collect_settings( value: &serde_json::Value, @@ -799,4 +872,32 @@ mod tests { assert_eq!(settings.embeddings.provider, "nearai"); assert_eq!(settings.embeddings.model, "text-embedding-3-small"); } + + #[test] + fn test_telegram_owner_id_round_trip() { + let dir = tempdir().unwrap(); + let path = dir.path().join("settings.json"); + + let mut settings = Settings::default(); + settings.channels.telegram_owner_id = Some(123456789); + settings.save_to(&path).unwrap(); + + let loaded = Settings::load_from(&path); + assert_eq!(loaded.channels.telegram_owner_id, Some(123456789)); + } + + #[test] + fn test_telegram_owner_id_default_none() { + let settings = Settings::default(); + assert_eq!(settings.channels.telegram_owner_id, None); + } + + #[test] + fn test_telegram_owner_id_via_set() { + let mut settings = Settings::default(); + settings + .set("channels.telegram_owner_id", "987654321") + .unwrap(); + assert_eq!(settings.channels.telegram_owner_id, Some(987654321)); + } } diff --git a/src/setup/channels.rs b/src/setup/channels.rs index f99081a9..7728ff0d 100644 --- a/src/setup/channels.rs +++ b/src/setup/channels.rs @@ -52,6 +52,16 @@ impl SecretsContext { .await .unwrap_or(false) } + + /// Read a secret from the database (decrypted). + pub async fn get_secret(&self, name: &str) -> Result { + let decrypted = self + .store + .get_decrypted(&self.user_id, name) + .await + .map_err(|e| format!("Failed to read secret: {}", e))?; + Ok(SecretString::from(decrypted.expose().to_string())) + } } /// Result of Telegram setup. @@ -60,6 +70,7 @@ pub struct TelegramSetupResult { pub enabled: bool, pub bot_username: Option, pub webhook_secret: Option, + pub owner_id: Option, } /// Telegram Bot API response for getMe. @@ -76,6 +87,32 @@ struct TelegramUser { first_name: String, } +/// Telegram Bot API response for getUpdates. +#[derive(Debug, Deserialize)] +struct TelegramGetUpdatesResponse { + ok: bool, + result: Vec, +} + +#[derive(Debug, Deserialize)] +struct TelegramUpdate { + #[allow(dead_code)] + update_id: i64, + message: Option, +} + +#[derive(Debug, Deserialize)] +struct TelegramUpdateMessage { + from: Option, +} + +#[derive(Debug, Deserialize)] +struct TelegramUpdateUser { + id: i64, + first_name: String, + username: Option, +} + /// Set up Telegram bot channel. /// /// Guides the user through: @@ -96,12 +133,14 @@ pub async fn setup_telegram(secrets: &SecretsContext) -> Result Result Result { @@ -141,12 +184,128 @@ pub async fn setup_telegram(secrets: &SecretsContext) -> Result Result, String> { + println!(); + print_info("Account Binding (recommended):"); + print_info("Binding restricts the bot so only YOU can use it."); + print_info("Without this, anyone who finds your bot can send it messages."); + println!(); + + if !confirm("Bind bot to your Telegram account?", true).map_err(|e| e.to_string())? { + print_info("Skipping account binding. Bot will accept messages from all users."); + return Ok(None); + } + + print_info("Send any message (e.g. /start) to your bot in Telegram."); + print_info("Waiting for your message (up to 120 seconds)..."); + + let client = Client::builder() + .timeout(std::time::Duration::from_secs(35)) + .build() + .map_err(|e| format!("Failed to create HTTP client: {}", e))?; + + // Clear any existing webhook so getUpdates works + let delete_url = format!( + "https://api.telegram.org/bot{}/deleteWebhook", + token.expose_secret() + ); + let _ = client.post(&delete_url).send().await; + + let updates_url = format!( + "https://api.telegram.org/bot{}/getUpdates", + token.expose_secret() + ); + + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(120); + + while std::time::Instant::now() < deadline { + let response = client + .get(&updates_url) + .query(&[("timeout", "30"), ("allowed_updates", "[\"message\"]")]) + .send() + .await + .map_err(|e| format!("getUpdates request failed: {}", e))?; + + if !response.status().is_success() { + return Err(format!("getUpdates returned status {}", response.status())); + } + + let body: TelegramGetUpdatesResponse = response + .json() + .await + .map_err(|e| format!("Failed to parse getUpdates response: {}", e))?; + + if !body.ok { + return Err("Telegram API returned error for getUpdates".to_string()); + } + + // Find the first message with a sender + for update in &body.result { + if let Some(ref msg) = update.message { + if let Some(ref from) = msg.from { + let display_name = from + .username + .as_ref() + .map(|u| format!("@{}", u)) + .unwrap_or_else(|| from.first_name.clone()); + + print_success(&format!( + "Received message from {} (ID: {})", + display_name, from.id + )); + + // Acknowledge the update so it doesn't pile up + let ack_url = format!( + "https://api.telegram.org/bot{}/getUpdates", + token.expose_secret() + ); + let _ = client + .get(&ack_url) + .query(&[("offset", &(update.update_id + 1).to_string())]) + .send() + .await; + + return Ok(Some(from.id)); + } + } + } + } + + print_error("Timed out waiting for a message. You can re-run setup to try again."); + print_info("Bot will accept messages from all users until owner is bound."); + Ok(None) +} + +/// Bind flow when the token already exists (reads from secrets store). +/// +/// Retrieves the saved bot token and delegates to `bind_telegram_owner`. +async fn bind_telegram_owner_flow(secrets: &SecretsContext) -> Result, String> { + // Check current settings first + let settings = Settings::load(); + if settings.channels.telegram_owner_id.is_some() { + print_info("Bot is already bound to a Telegram account."); + if !confirm("Re-bind to a different account?", false).map_err(|e| e.to_string())? { + return Ok(settings.channels.telegram_owner_id); + } + } + + // We need the token to poll getUpdates + let token = secrets.get_secret("telegram_bot_token").await?; + + bind_telegram_owner(&token).await +} + /// Set up a tunnel for exposing the agent to the internet. /// /// This is shared across all channels that need webhook endpoints. diff --git a/src/setup/wizard.rs b/src/setup/wizard.rs index f4ed590f..adf76d25 100644 --- a/src/setup/wizard.rs +++ b/src/setup/wizard.rs @@ -17,7 +17,7 @@ use secrecy::SecretString; use tokio_postgres::NoTls; use crate::channels::wasm::{ - ChannelCapabilitiesFile, bundled_channel_names, install_bundled_channel, + ChannelCapabilitiesFile, available_channel_names, install_bundled_channel, }; use crate::llm::{SessionConfig, SessionManager}; use crate::secrets::SecretsCrypto; @@ -689,6 +689,9 @@ impl SetupWizard { } else if channel_name == "telegram" { let telegram_result = setup_telegram(ctx).await.map_err(SetupError::Channel)?; + if let Some(owner_id) = telegram_result.owner_id { + self.settings.channels.telegram_owner_id = Some(owner_id); + } crate::setup::channels::WasmChannelSetupResult { enabled: telegram_result.enabled, channel_name: "telegram".to_string(), @@ -978,7 +981,7 @@ async fn install_missing_bundled_channels( ) -> Result, SetupError> { let mut installed = Vec::new(); - for name in bundled_channel_names().iter().copied() { + for name in available_channel_names().iter().copied() { if already_installed.contains(name) { continue; } @@ -995,7 +998,7 @@ async fn install_missing_bundled_channels( fn wasm_channel_option_names(discovered: &[(String, ChannelCapabilitiesFile)]) -> Vec { let mut names: Vec = discovered.iter().map(|(name, _)| name.clone()).collect(); - for bundled in bundled_channel_names().iter().copied() { + for bundled in available_channel_names().iter().copied() { if !names.iter().any(|name| name == bundled) { names.push(bundled.to_string()); } @@ -1009,7 +1012,7 @@ async fn install_selected_bundled_channels( selected_channels: &[String], already_installed: &HashSet, ) -> Result>, SetupError> { - let bundled: HashSet<&str> = bundled_channel_names().iter().copied().collect(); + let bundled: HashSet<&str> = available_channel_names().iter().copied().collect(); let selected_missing: HashSet = selected_channels .iter() .filter(|name| bundled.contains(name.as_str()) && !already_installed.contains(*name)) @@ -1092,16 +1095,29 @@ mod tests { } #[test] - fn test_wasm_channel_option_names_includes_bundled_when_missing() { + fn test_wasm_channel_option_names_includes_available_when_missing() { let discovered = Vec::new(); let options = wasm_channel_option_names(&discovered); - assert_eq!(options, vec!["telegram".to_string()]); + let available = available_channel_names(); + // All available (built) channels should appear + for name in &available { + assert!( + options.contains(&name.to_string()), + "expected '{}' in options", + name + ); + } } #[test] - fn test_wasm_channel_option_names_dedupes_bundled() { + fn test_wasm_channel_option_names_dedupes_available() { let discovered = vec![(String::from("telegram"), ChannelCapabilitiesFile::default())]; let options = wasm_channel_option_names(&discovered); - assert_eq!(options, vec!["telegram".to_string()]); + // telegram should appear exactly once despite being both discovered and available + assert_eq!( + options.iter().filter(|n| *n == "telegram").count(), + 1, + "telegram should not be duplicated" + ); } } diff --git a/src/tools/builder/core.rs b/src/tools/builder/core.rs index e46c8aaa..5f71b8dc 100644 --- a/src/tools/builder/core.rs +++ b/src/tools/builder/core.rs @@ -689,9 +689,20 @@ Create alongside the .wasm file to grant capabilities: .messages .push(ChatMessage::user("Continue with the next step.")); } - RespondResult::ToolCalls(tool_calls) => { + RespondResult::ToolCalls { + tool_calls, + content, + } => { tools_executed = true; + // Add assistant message with tool_calls (OpenAI protocol) + reason_ctx + .messages + .push(ChatMessage::assistant_with_tool_calls( + content, + tool_calls.clone(), + )); + // Execute each tool call for tc in tool_calls { logs.push(BuildLog { diff --git a/src/tools/builtin/extension_tools.rs b/src/tools/builtin/extension_tools.rs index 6b4ca532..c707fd69 100644 --- a/src/tools/builtin/extension_tools.rs +++ b/src/tools/builtin/extension_tools.rs @@ -185,8 +185,9 @@ impl Tool for ToolAuthTool { } fn description(&self) -> &str { - "Authenticate an installed extension. For MCP servers, starts OAuth flow. \ - For WASM tools with manual auth, returns instructions; call again with token param to complete." + "Initiate authentication for an extension. For OAuth, returns a URL. \ + For manual auth, returns instructions. The user provides their token \ + through a secure channel, never through this tool." } fn parameters_schema(&self) -> serde_json::Value { @@ -196,10 +197,6 @@ impl Tool for ToolAuthTool { "name": { "type": "string", "description": "Extension name to authenticate" - }, - "token": { - "type": "string", - "description": "API token/key for manual auth (WASM tools). Provide after user gives you the token." } }, "required": ["name"] @@ -218,11 +215,9 @@ impl Tool for ToolAuthTool { .and_then(|v| v.as_str()) .ok_or_else(|| ToolError::InvalidParameters("name is required".to_string()))?; - let token = params.get("token").and_then(|v| v.as_str()); - let result = self .manager - .auth(name, token) + .auth(name, None) .await .map_err(|e| ToolError::ExecutionFailed(e.to_string()))?; @@ -316,16 +311,53 @@ impl Tool for ToolActivateTool { .and_then(|v| v.as_str()) .ok_or_else(|| ToolError::InvalidParameters("name is required".to_string()))?; - let result = self - .manager - .activate(name) - .await - .map_err(|e| ToolError::ExecutionFailed(e.to_string()))?; + match self.manager.activate(name).await { + Ok(result) => { + let output = serde_json::to_value(&result) + .unwrap_or_else(|_| serde_json::json!({"error": "serialization failed"})); + Ok(ToolOutput::success(output, start.elapsed())) + } + Err(activate_err) => { + let err_str = activate_err.to_string(); + let needs_auth = err_str.contains("authentication") + || err_str.contains("401") + || err_str.contains("Unauthorized") + || err_str.contains("not authenticated"); - let output = serde_json::to_value(&result) - .unwrap_or_else(|_| serde_json::json!({"error": "serialization failed"})); + if !needs_auth { + return Err(ToolError::ExecutionFailed(err_str)); + } - Ok(ToolOutput::success(output, start.elapsed())) + // Activation failed due to missing auth; initiate auth flow + // so the agent loop can show the auth card. + match self.manager.auth(name, None).await { + Ok(auth_result) if auth_result.status == "authenticated" => { + // Auth succeeded (e.g. env var was set); retry activation. + let result = self + .manager + .activate(name) + .await + .map_err(|e| ToolError::ExecutionFailed(e.to_string()))?; + let output = serde_json::to_value(&result).unwrap_or_else( + |_| serde_json::json!({"error": "serialization failed"}), + ); + Ok(ToolOutput::success(output, start.elapsed())) + } + Ok(auth_result) => { + // Auth needs user input (awaiting_token). Return the auth + // result so detect_auth_awaiting picks it up. + let output = serde_json::to_value(&auth_result).unwrap_or_else( + |_| serde_json::json!({"error": "serialization failed"}), + ); + Ok(ToolOutput::success(output, start.elapsed())) + } + Err(auth_err) => Err(ToolError::ExecutionFailed(format!( + "Activation failed ({}), and authentication also failed: {}", + err_str, auth_err + ))), + } + } + } } } @@ -499,7 +531,11 @@ mod tests { assert!(tool.requires_approval()); let schema = tool.parameters_schema(); assert!(schema["properties"].get("name").is_some()); - assert!(schema["properties"].get("token").is_some()); + // token param must NOT be in schema (security: tokens never go through LLM) + assert!( + schema["properties"].get("token").is_none(), + "tool_auth must not have a token parameter" + ); } #[test] @@ -550,6 +586,7 @@ mod tests { std::path::PathBuf::from("/tmp/ironclaw-test-channels"), None, "test".to_string(), + None, )) } } diff --git a/src/tools/builtin/file.rs b/src/tools/builtin/file.rs index e72df9f8..14b76677 100644 --- a/src/tools/builtin/file.rs +++ b/src/tools/builtin/file.rs @@ -11,7 +11,7 @@ use async_trait::async_trait; use tokio::fs; use crate::context::JobContext; -use crate::tools::tool::{Tool, ToolError, ToolOutput}; +use crate::tools::tool::{Tool, ToolDomain, ToolError, ToolOutput}; use crate::workspace::paths as ws_paths; /// Well-known workspace filenames that must go through memory_write, not write_file. @@ -246,6 +246,10 @@ impl Tool for ReadFileTool { fn requires_approval(&self) -> bool { true // Reading local files should require approval } + + fn domain(&self) -> ToolDomain { + ToolDomain::Container + } } /// Write file contents tool. @@ -359,6 +363,10 @@ impl Tool for WriteFileTool { fn requires_sanitization(&self) -> bool { false // We're writing, not reading external data } + + fn domain(&self) -> ToolDomain { + ToolDomain::Container + } } /// List directory contents tool. @@ -467,6 +475,10 @@ impl Tool for ListDirTool { fn requires_approval(&self) -> bool { true // Directory listings can leak filesystem structure } + + fn domain(&self) -> ToolDomain { + ToolDomain::Container + } } /// Recursively list directory contents. @@ -685,6 +697,10 @@ impl Tool for ApplyPatchTool { fn requires_sanitization(&self) -> bool { false // We're writing, not reading external data } + + fn domain(&self) -> ToolDomain { + ToolDomain::Container + } } #[cfg(test)] diff --git a/src/tools/builtin/job.rs b/src/tools/builtin/job.rs index 7f6663c9..20fda2ad 100644 --- a/src/tools/builtin/job.rs +++ b/src/tools/builtin/job.rs @@ -1,77 +1,108 @@ //! Job management tools. //! //! These tools allow the LLM to manage jobs: -//! - Create new jobs/tasks +//! - Create new jobs/tasks (with optional sandbox delegation) //! - List existing jobs //! - Check job status //! - Cancel running jobs +use std::path::PathBuf; use std::sync::Arc; +use std::time::Duration; use async_trait::async_trait; +use chrono::Utc; use uuid::Uuid; use crate::context::{ContextManager, JobContext, JobState}; +use crate::history::{SandboxJobRecord, Store}; +use crate::orchestrator::job_manager::{ContainerJobManager, JobMode}; use crate::tools::tool::{Tool, ToolError, ToolOutput}; /// Tool for creating a new job. +/// +/// When sandbox deps are injected (via `with_sandbox`), the tool automatically +/// delegates execution to a Docker container. Otherwise it creates an in-memory +/// job via the ContextManager. The LLM never needs to know the difference. pub struct CreateJobTool { context_manager: Arc, + job_manager: Option>, + store: Option>, } impl CreateJobTool { pub fn new(context_manager: Arc) -> Self { - Self { context_manager } - } -} - -#[async_trait] -impl Tool for CreateJobTool { - fn name(&self) -> &str { - "create_job" + Self { + context_manager, + job_manager: None, + store: None, + } } - fn description(&self) -> &str { - "Create a new job or task for the agent to work on. Use this when the user wants \ - you to do something substantial that should be tracked as a separate job." + /// Inject sandbox dependencies so `create_job` delegates to Docker containers. + pub fn with_sandbox( + mut self, + job_manager: Arc, + store: Option>, + ) -> Self { + self.job_manager = Some(job_manager); + self.store = store; + self } - fn parameters_schema(&self) -> serde_json::Value { - serde_json::json!({ - "type": "object", - "properties": { - "title": { - "type": "string", - "description": "A short title for the job (max 100 chars)" - }, - "description": { - "type": "string", - "description": "Full description of what needs to be done" + fn sandbox_enabled(&self) -> bool { + self.job_manager.is_some() + } + + /// Persist a sandbox job record (fire-and-forget). + fn persist_job(&self, record: SandboxJobRecord) { + if let Some(store) = self.store.clone() { + tokio::spawn(async move { + if let Err(e) = store.save_sandbox_job(&record).await { + tracing::warn!(job_id = %record.id, "Failed to persist sandbox job: {}", e); } - }, - "required": ["title", "description"] - }) + }); + } } - async fn execute( + /// Update sandbox job status in DB (fire-and-forget). + fn update_status( &self, - params: serde_json::Value, + job_id: Uuid, + status: &str, + success: Option, + message: Option, + started_at: Option>, + completed_at: Option>, + ) { + if let Some(store) = self.store.clone() { + let status = status.to_string(); + tokio::spawn(async move { + if let Err(e) = store + .update_sandbox_job_status( + job_id, + &status, + success, + message.as_deref(), + started_at, + completed_at, + ) + .await + { + tracing::warn!(job_id = %job_id, "Failed to update sandbox job status: {}", e); + } + }); + } + } + + /// Execute via in-memory ContextManager (no sandbox). + async fn execute_local( + &self, + title: &str, + description: &str, ctx: &JobContext, ) -> Result { let start = std::time::Instant::now(); - - let title = params - .get("title") - .and_then(|v| v.as_str()) - .ok_or_else(|| ToolError::InvalidParameters("missing 'title' parameter".into()))?; - - let description = params - .get("description") - .and_then(|v| v.as_str()) - .ok_or_else(|| { - ToolError::InvalidParameters("missing 'description' parameter".into()) - })?; - match self .context_manager .create_job_for_user(&ctx.user_id, title, description) @@ -95,6 +126,374 @@ impl Tool for CreateJobTool { } } + /// Execute via sandboxed Docker container. + async fn execute_sandbox( + &self, + task: &str, + explicit_dir: Option, + wait: bool, + mode: JobMode, + ctx: &JobContext, + ) -> Result { + let start = std::time::Instant::now(); + let jm = self.job_manager.as_ref().expect("sandbox deps required"); + + let job_id = Uuid::new_v4(); + let (project_dir, browse_id) = resolve_project_dir(explicit_dir, job_id)?; + let project_dir_str = project_dir.display().to_string(); + + // Persist the job to DB before creating the container. + self.persist_job(SandboxJobRecord { + id: job_id, + task: task.to_string(), + status: "creating".to_string(), + user_id: ctx.user_id.clone(), + project_dir: project_dir_str.clone(), + success: None, + failure_reason: None, + created_at: Utc::now(), + started_at: None, + completed_at: None, + }); + + // Persist the job mode to DB + if mode == JobMode::ClaudeCode { + if let Some(store) = self.store.clone() { + let job_id_copy = job_id; + tokio::spawn(async move { + if let Err(e) = store + .update_sandbox_job_mode(job_id_copy, "claude_code") + .await + { + tracing::warn!(job_id = %job_id_copy, "Failed to set job mode: {}", e); + } + }); + } + } + + // Create the container job with the pre-determined job_id. + let _token = jm + .create_job(job_id, task, Some(project_dir), mode) + .await + .map_err(|e| { + self.update_status( + job_id, + "failed", + Some(false), + Some(e.to_string()), + None, + Some(Utc::now()), + ); + ToolError::ExecutionFailed(format!("failed to create container: {}", e)) + })?; + + // Container started successfully. + let now = Utc::now(); + self.update_status(job_id, "running", None, None, Some(now), None); + + if !wait { + let result = serde_json::json!({ + "job_id": job_id.to_string(), + "status": "started", + "message": "Container started. Use job tools to check status.", + "project_dir": project_dir_str, + "browse_url": format!("/projects/{}", browse_id), + }); + return Ok(ToolOutput::success(result, start.elapsed())); + } + + // Wait for completion by polling the container state. + let timeout = Duration::from_secs(600); + let poll_interval = Duration::from_secs(2); + let deadline = tokio::time::Instant::now() + timeout; + + loop { + if tokio::time::Instant::now() > deadline { + let _ = jm.stop_job(job_id).await; + jm.cleanup_job(job_id).await; + self.update_status( + job_id, + "failed", + Some(false), + Some("Timed out (10 minutes)".to_string()), + None, + Some(Utc::now()), + ); + return Err(ToolError::ExecutionFailed( + "container execution timed out (10 minutes)".to_string(), + )); + } + + match jm.get_handle(job_id).await { + Some(handle) => match handle.state { + crate::orchestrator::job_manager::ContainerState::Running + | crate::orchestrator::job_manager::ContainerState::Creating => { + tokio::time::sleep(poll_interval).await; + } + crate::orchestrator::job_manager::ContainerState::Stopped => { + let message = handle + .completion_result + .as_ref() + .and_then(|r| r.message.clone()) + .unwrap_or_else(|| "Container job completed".to_string()); + let success = handle + .completion_result + .as_ref() + .map(|r| r.success) + .unwrap_or(true); + jm.cleanup_job(job_id).await; + + let finished_at = Utc::now(); + if success { + self.update_status( + job_id, + "completed", + Some(true), + None, + None, + Some(finished_at), + ); + let result = serde_json::json!({ + "job_id": job_id.to_string(), + "status": "completed", + "output": message, + "project_dir": project_dir_str, + "browse_url": format!("/projects/{}", browse_id), + }); + return Ok(ToolOutput::success(result, start.elapsed())); + } else { + self.update_status( + job_id, + "failed", + Some(false), + Some(message.clone()), + None, + Some(finished_at), + ); + return Err(ToolError::ExecutionFailed(format!( + "container job failed: {}", + message + ))); + } + } + crate::orchestrator::job_manager::ContainerState::Failed => { + let message = handle + .completion_result + .as_ref() + .and_then(|r| r.message.clone()) + .unwrap_or_else(|| "unknown failure".to_string()); + jm.cleanup_job(job_id).await; + self.update_status( + job_id, + "failed", + Some(false), + Some(message.clone()), + None, + Some(Utc::now()), + ); + return Err(ToolError::ExecutionFailed(format!( + "container job failed: {}", + message + ))); + } + }, + None => { + self.update_status( + job_id, + "completed", + Some(true), + None, + None, + Some(Utc::now()), + ); + let result = serde_json::json!({ + "job_id": job_id.to_string(), + "status": "completed", + "output": "Container job completed", + "project_dir": project_dir_str, + "browse_url": format!("/projects/{}", browse_id), + }); + return Ok(ToolOutput::success(result, start.elapsed())); + } + } + } + } +} + +/// The base directory where all project directories must live. +fn projects_base() -> PathBuf { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".ironclaw") + .join("projects") +} + +/// Resolve the project directory, creating it if it doesn't exist. +/// +/// Auto-creates `~/.ironclaw/projects/{project_id}/` so every sandbox job has a +/// persistent bind mount that survives container teardown. +/// +/// When an explicit path is provided (e.g. job restarts reusing the old dir), +/// it is validated to fall within `~/.ironclaw/projects/` after canonicalization. +fn resolve_project_dir( + explicit: Option, + project_id: Uuid, +) -> Result<(PathBuf, String), ToolError> { + let base = projects_base(); + std::fs::create_dir_all(&base).map_err(|e| { + ToolError::ExecutionFailed(format!( + "failed to create projects base {}: {}", + base.display(), + e + )) + })?; + let canonical_base = base.canonicalize().map_err(|e| { + ToolError::ExecutionFailed(format!("failed to canonicalize projects base: {}", e)) + })?; + + let dir = match explicit { + Some(d) => d, + None => canonical_base.join(project_id.to_string()), + }; + + std::fs::create_dir_all(&dir).map_err(|e| { + ToolError::ExecutionFailed(format!( + "failed to create project dir {}: {}", + dir.display(), + e + )) + })?; + + // Canonicalize resolves symlinks, `..`, etc. so we can do a reliable prefix check. + let canonical_dir = dir.canonicalize().map_err(|e| { + ToolError::ExecutionFailed(format!( + "failed to canonicalize project dir {}: {}", + dir.display(), + e + )) + })?; + + if !canonical_dir.starts_with(&canonical_base) { + return Err(ToolError::InvalidParameters(format!( + "project directory must be under {}", + canonical_base.display() + ))); + } + + let browse_id = canonical_dir + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| project_id.to_string()); + Ok((canonical_dir, browse_id)) +} + +#[async_trait] +impl Tool for CreateJobTool { + fn name(&self) -> &str { + "create_job" + } + + fn description(&self) -> &str { + if self.sandbox_enabled() { + "Create and execute a job. The job runs in a sandboxed Docker container with its own \ + sub-agent that has shell, file read/write, list_dir, and apply_patch tools. Use this \ + whenever the user asks you to build, create, or work on something. The task \ + description should be detailed enough for the sub-agent to work independently. \ + Set wait=false to start immediately while continuing the conversation. Set mode \ + to 'claude_code' for complex software engineering tasks." + } else { + "Create a new job or task for the agent to work on. Use this when the user wants \ + you to do something substantial that should be tracked as a separate job." + } + } + + fn parameters_schema(&self) -> serde_json::Value { + if self.sandbox_enabled() { + serde_json::json!({ + "type": "object", + "properties": { + "title": { + "type": "string", + "description": "Clear description of what to accomplish" + }, + "description": { + "type": "string", + "description": "Full description of what needs to be done" + }, + "wait": { + "type": "boolean", + "description": "If true (default), wait for the container to complete and return results. \ + If false, start the container and return the job_id immediately." + }, + "mode": { + "type": "string", + "enum": ["worker", "claude_code"], + "description": "Execution mode. 'worker' (default) uses the IronClaw sub-agent. \ + 'claude_code' uses Claude Code CLI for full agentic software engineering." + } + }, + "required": ["title", "description"] + }) + } else { + serde_json::json!({ + "type": "object", + "properties": { + "title": { + "type": "string", + "description": "A short title for the job (max 100 chars)" + }, + "description": { + "type": "string", + "description": "Full description of what needs to be done" + } + }, + "required": ["title", "description"] + }) + } + } + + fn execution_timeout(&self) -> Duration { + if self.sandbox_enabled() { + // Sandbox polls for up to 10 min internally; give an extra 60s buffer. + Duration::from_secs(660) + } else { + Duration::from_secs(30) + } + } + + async fn execute( + &self, + params: serde_json::Value, + ctx: &JobContext, + ) -> Result { + let title = params + .get("title") + .and_then(|v| v.as_str()) + .ok_or_else(|| ToolError::InvalidParameters("missing 'title' parameter".into()))?; + + let description = params + .get("description") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + ToolError::InvalidParameters("missing 'description' parameter".into()) + })?; + + if self.sandbox_enabled() { + let wait = params.get("wait").and_then(|v| v.as_bool()).unwrap_or(true); + + let mode = match params.get("mode").and_then(|v| v.as_str()) { + Some("claude_code") => JobMode::ClaudeCode, + _ => JobMode::Worker, + }; + + // Combine title and description into the task prompt for the sub-agent. + let task = format!("{}\n\n{}", title, description); + self.execute_sandbox(&task, None, wait, mode, ctx).await + } else { + self.execute_local(title, description, ctx).await + } + } + fn requires_sanitization(&self) -> bool { false } @@ -377,10 +776,13 @@ mod tests { use super::*; #[tokio::test] - async fn test_create_job_tool() { + async fn test_create_job_tool_local() { let manager = Arc::new(ContextManager::new(5)); let tool = CreateJobTool::new(manager.clone()); + // Without sandbox deps, it should use the local path + assert!(!tool.sandbox_enabled()); + let params = serde_json::json!({ "title": "Test Job", "description": "A test job description" @@ -391,6 +793,37 @@ mod tests { let job_id = result.result.get("job_id").unwrap().as_str().unwrap(); assert!(!job_id.is_empty()); + assert_eq!( + result.result.get("status").unwrap().as_str().unwrap(), + "pending" + ); + } + + #[test] + fn test_schema_changes_with_sandbox() { + let manager = Arc::new(ContextManager::new(5)); + + // Without sandbox + let tool = CreateJobTool::new(Arc::clone(&manager)); + let schema = tool.parameters_schema(); + let props = schema.get("properties").unwrap().as_object().unwrap(); + assert!(props.contains_key("title")); + assert!(props.contains_key("description")); + assert!( + !props.contains_key("project_dir"), + "project_dir must not be exposed to the LLM" + ); + assert!(!props.contains_key("wait")); + assert!(!props.contains_key("mode")); + } + + #[test] + fn test_execution_timeout_sandbox() { + let manager = Arc::new(ContextManager::new(5)); + + // Without sandbox: default timeout + let tool = CreateJobTool::new(Arc::clone(&manager)); + assert_eq!(tool.execution_timeout(), Duration::from_secs(30)); } #[tokio::test] @@ -429,4 +862,67 @@ mod tests { "Test Job" ); } + + #[test] + fn test_resolve_project_dir_auto() { + let project_id = Uuid::new_v4(); + let (dir, browse_id) = resolve_project_dir(None, project_id).unwrap(); + assert!(dir.exists()); + assert!(dir.ends_with(project_id.to_string())); + assert_eq!(browse_id, project_id.to_string()); + + // Must be under the projects base + let base = projects_base().canonicalize().unwrap(); + assert!(dir.starts_with(&base)); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn test_resolve_project_dir_explicit_under_base() { + let base = projects_base(); + std::fs::create_dir_all(&base).unwrap(); + let explicit = base.join("test_explicit_project"); + let project_id = Uuid::new_v4(); + + let (dir, browse_id) = resolve_project_dir(Some(explicit.clone()), project_id).unwrap(); + assert!(dir.exists()); + assert_eq!(browse_id, "test_explicit_project"); + + let canonical_base = base.canonicalize().unwrap(); + assert!(dir.starts_with(&canonical_base)); + + let _ = std::fs::remove_dir_all(&explicit); + } + + #[test] + fn test_resolve_project_dir_rejects_outside_base() { + let tmp = tempfile::tempdir().unwrap(); + let escape_attempt = tmp.path().join("evil_project"); + + let result = resolve_project_dir(Some(escape_attempt), Uuid::new_v4()); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("must be under"), + "expected 'must be under' error, got: {}", + err + ); + } + + #[test] + fn test_resolve_project_dir_rejects_traversal() { + // Attempt to escape via `..` components + let base = projects_base(); + let traversal = base.join("legit").join("..").join("..").join(".ssh"); + + let result = resolve_project_dir(Some(traversal), Uuid::new_v4()); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("must be under"), + "expected 'must be under' error, got: {}", + err + ); + } } diff --git a/src/tools/builtin/mod.rs b/src/tools/builtin/mod.rs index 46aee56b..c834aa9d 100644 --- a/src/tools/builtin/mod.rs +++ b/src/tools/builtin/mod.rs @@ -10,6 +10,7 @@ mod json; mod marketplace; mod memory; mod restaurant; +pub mod routine; mod shell; mod taskrabbit; mod time; @@ -26,6 +27,9 @@ pub use json::JsonTool; pub use marketplace::MarketplaceTool; pub use memory::{MemoryReadTool, MemorySearchTool, MemoryTreeTool, MemoryWriteTool}; pub use restaurant::RestaurantTool; +pub use routine::{ + RoutineCreateTool, RoutineDeleteTool, RoutineHistoryTool, RoutineListTool, RoutineUpdateTool, +}; pub use shell::ShellTool; pub use taskrabbit::TaskRabbitTool; pub use time::TimeTool; diff --git a/src/tools/builtin/routine.rs b/src/tools/builtin/routine.rs new file mode 100644 index 00000000..decf7357 --- /dev/null +++ b/src/tools/builtin/routine.rs @@ -0,0 +1,654 @@ +//! LLM-facing tools for managing routines. +//! +//! Five tools let the agent manage routines conversationally: +//! - `routine_create` - Create a new routine +//! - `routine_list` - List all routines with status +//! - `routine_update` - Modify or toggle a routine +//! - `routine_delete` - Remove a routine +//! - `routine_history` - View past runs + +use std::sync::Arc; +use std::time::Duration; + +use async_trait::async_trait; +use chrono::Utc; +use uuid::Uuid; + +use crate::agent::routine::{ + NotifyConfig, Routine, RoutineAction, RoutineGuardrails, Trigger, next_cron_fire, +}; +use crate::agent::routine_engine::RoutineEngine; +use crate::context::JobContext; +use crate::history::Store; +use crate::tools::tool::{Tool, ToolError, ToolOutput}; + +// ==================== routine_create ==================== + +pub struct RoutineCreateTool { + store: Arc, + engine: Arc, +} + +impl RoutineCreateTool { + pub fn new(store: Arc, engine: Arc) -> Self { + Self { store, engine } + } +} + +#[async_trait] +impl Tool for RoutineCreateTool { + fn name(&self) -> &str { + "routine_create" + } + + fn description(&self) -> &str { + "Create a new routine (scheduled or event-driven task). \ + Supports cron schedules, event pattern matching, webhooks, and manual triggers. \ + Use this when the user wants something to happen periodically or reactively." + } + + fn parameters_schema(&self) -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Unique name for the routine (e.g. 'daily-pr-review')" + }, + "description": { + "type": "string", + "description": "What this routine does" + }, + "trigger_type": { + "type": "string", + "enum": ["cron", "event", "webhook", "manual"], + "description": "When the routine fires" + }, + "schedule": { + "type": "string", + "description": "Cron expression (for cron trigger). E.g. '0 9 * * MON-FRI' for weekdays at 9am. Uses 6-field cron (sec min hour day month weekday)." + }, + "event_pattern": { + "type": "string", + "description": "Regex pattern to match messages (for event trigger)" + }, + "event_channel": { + "type": "string", + "description": "Optional channel filter for event trigger (e.g. 'telegram')" + }, + "prompt": { + "type": "string", + "description": "The prompt/instructions for the routine" + }, + "context_paths": { + "type": "array", + "items": { "type": "string" }, + "description": "Workspace paths to load as context (e.g. ['context/priorities.md'])" + }, + "action_type": { + "type": "string", + "enum": ["lightweight", "full_job"], + "description": "Execution mode: 'lightweight' (single LLM call, default) or 'full_job' (multi-turn with tools)" + }, + "cooldown_secs": { + "type": "integer", + "description": "Minimum seconds between fires (default: 300)" + } + }, + "required": ["name", "trigger_type", "prompt"] + }) + } + + async fn execute( + &self, + params: serde_json::Value, + ctx: &JobContext, + ) -> Result { + let start = std::time::Instant::now(); + + let name = params + .get("name") + .and_then(|v| v.as_str()) + .ok_or_else(|| ToolError::InvalidParameters("missing 'name'".to_string()))?; + + let description = params + .get("description") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + let trigger_type = params + .get("trigger_type") + .and_then(|v| v.as_str()) + .ok_or_else(|| ToolError::InvalidParameters("missing 'trigger_type'".to_string()))?; + + let prompt = params + .get("prompt") + .and_then(|v| v.as_str()) + .ok_or_else(|| ToolError::InvalidParameters("missing 'prompt'".to_string()))?; + + // Build trigger + let trigger = match trigger_type { + "cron" => { + let schedule = + params + .get("schedule") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + ToolError::InvalidParameters( + "cron trigger requires 'schedule'".to_string(), + ) + })?; + // Validate cron expression + next_cron_fire(schedule).map_err(|e| { + ToolError::InvalidParameters(format!("invalid cron schedule: {e}")) + })?; + Trigger::Cron { + schedule: schedule.to_string(), + } + } + "event" => { + let pattern = params + .get("event_pattern") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + ToolError::InvalidParameters( + "event trigger requires 'event_pattern'".to_string(), + ) + })?; + // Validate regex + regex::Regex::new(pattern) + .map_err(|e| ToolError::InvalidParameters(format!("invalid regex: {e}")))?; + let channel = params + .get("event_channel") + .and_then(|v| v.as_str()) + .map(String::from); + Trigger::Event { + channel, + pattern: pattern.to_string(), + } + } + "webhook" => Trigger::Webhook { + path: None, + secret: None, + }, + "manual" => Trigger::Manual, + other => { + return Err(ToolError::InvalidParameters(format!( + "unknown trigger_type: {other}" + ))); + } + }; + + // Build action + let action_type = params + .get("action_type") + .and_then(|v| v.as_str()) + .unwrap_or("lightweight"); + + let context_paths: Vec = params + .get("context_paths") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(); + + let action = match action_type { + "lightweight" => RoutineAction::Lightweight { + prompt: prompt.to_string(), + context_paths, + max_tokens: 4096, + }, + "full_job" => RoutineAction::FullJob { + title: name.to_string(), + description: prompt.to_string(), + max_iterations: 10, + }, + other => { + return Err(ToolError::InvalidParameters(format!( + "unknown action_type: {other}" + ))); + } + }; + + let cooldown_secs = params + .get("cooldown_secs") + .and_then(|v| v.as_u64()) + .unwrap_or(300); + + // Compute next fire time for cron + let next_fire = if let Trigger::Cron { ref schedule } = trigger { + next_cron_fire(schedule).unwrap_or(None) + } else { + None + }; + + let routine = Routine { + id: Uuid::new_v4(), + name: name.to_string(), + description: description.to_string(), + user_id: ctx.user_id.clone(), + enabled: true, + trigger, + action, + guardrails: RoutineGuardrails { + cooldown: Duration::from_secs(cooldown_secs), + max_concurrent: 1, + dedup_window: None, + }, + notify: NotifyConfig::default(), + last_run_at: None, + next_fire_at: next_fire, + run_count: 0, + consecutive_failures: 0, + state: serde_json::json!({}), + created_at: Utc::now(), + updated_at: Utc::now(), + }; + + self.store + .create_routine(&routine) + .await + .map_err(|e| ToolError::ExecutionFailed(format!("failed to create routine: {e}")))?; + + // Refresh event cache if this is an event trigger + if routine.trigger.type_tag() == "event" { + self.engine.refresh_event_cache().await; + } + + let result = serde_json::json!({ + "id": routine.id.to_string(), + "name": routine.name, + "trigger_type": routine.trigger.type_tag(), + "next_fire_at": routine.next_fire_at.map(|t| t.to_rfc3339()), + "status": "created", + }); + + Ok(ToolOutput::success(result, start.elapsed())) + } + + fn requires_sanitization(&self) -> bool { + false + } +} + +// ==================== routine_list ==================== + +pub struct RoutineListTool { + store: Arc, +} + +impl RoutineListTool { + pub fn new(store: Arc) -> Self { + Self { store } + } +} + +#[async_trait] +impl Tool for RoutineListTool { + fn name(&self) -> &str { + "routine_list" + } + + fn description(&self) -> &str { + "List all routines with their status, trigger info, and next fire time." + } + + fn parameters_schema(&self) -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": {}, + "required": [] + }) + } + + async fn execute( + &self, + _params: serde_json::Value, + ctx: &JobContext, + ) -> Result { + let start = std::time::Instant::now(); + + let routines = self + .store + .list_routines(&ctx.user_id) + .await + .map_err(|e| ToolError::ExecutionFailed(format!("failed to list routines: {e}")))?; + + let list: Vec = routines + .iter() + .map(|r| { + serde_json::json!({ + "id": r.id.to_string(), + "name": r.name, + "description": r.description, + "enabled": r.enabled, + "trigger_type": r.trigger.type_tag(), + "action_type": r.action.type_tag(), + "last_run_at": r.last_run_at.map(|t| t.to_rfc3339()), + "next_fire_at": r.next_fire_at.map(|t| t.to_rfc3339()), + "run_count": r.run_count, + "consecutive_failures": r.consecutive_failures, + }) + }) + .collect(); + + let result = serde_json::json!({ + "count": list.len(), + "routines": list, + }); + + Ok(ToolOutput::success(result, start.elapsed())) + } + + fn requires_sanitization(&self) -> bool { + false + } +} + +// ==================== routine_update ==================== + +pub struct RoutineUpdateTool { + store: Arc, + engine: Arc, +} + +impl RoutineUpdateTool { + pub fn new(store: Arc, engine: Arc) -> Self { + Self { store, engine } + } +} + +#[async_trait] +impl Tool for RoutineUpdateTool { + fn name(&self) -> &str { + "routine_update" + } + + fn description(&self) -> &str { + "Update an existing routine. Can modify trigger, prompt, schedule, or toggle enabled state. \ + Pass the routine name and only the fields you want to change." + } + + fn parameters_schema(&self) -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Name of the routine to update" + }, + "enabled": { + "type": "boolean", + "description": "Enable or disable the routine" + }, + "prompt": { + "type": "string", + "description": "New prompt/instructions" + }, + "schedule": { + "type": "string", + "description": "New cron schedule (for cron triggers)" + }, + "description": { + "type": "string", + "description": "New description" + } + }, + "required": ["name"] + }) + } + + async fn execute( + &self, + params: serde_json::Value, + ctx: &JobContext, + ) -> Result { + let start = std::time::Instant::now(); + + let name = params + .get("name") + .and_then(|v| v.as_str()) + .ok_or_else(|| ToolError::InvalidParameters("missing 'name'".to_string()))?; + + let mut routine = self + .store + .get_routine_by_name(&ctx.user_id, name) + .await + .map_err(|e| ToolError::ExecutionFailed(format!("DB error: {e}")))? + .ok_or_else(|| ToolError::ExecutionFailed(format!("routine '{}' not found", name)))?; + + // Apply updates + if let Some(enabled) = params.get("enabled").and_then(|v| v.as_bool()) { + routine.enabled = enabled; + } + + if let Some(desc) = params.get("description").and_then(|v| v.as_str()) { + routine.description = desc.to_string(); + } + + if let Some(prompt) = params.get("prompt").and_then(|v| v.as_str()) { + match &mut routine.action { + RoutineAction::Lightweight { prompt: p, .. } => *p = prompt.to_string(), + RoutineAction::FullJob { description: d, .. } => *d = prompt.to_string(), + } + } + + if let Some(schedule) = params.get("schedule").and_then(|v| v.as_str()) { + // Validate + next_cron_fire(schedule) + .map_err(|e| ToolError::InvalidParameters(format!("invalid cron schedule: {e}")))?; + + routine.trigger = Trigger::Cron { + schedule: schedule.to_string(), + }; + routine.next_fire_at = next_cron_fire(schedule).unwrap_or(None); + } + + self.store + .update_routine(&routine) + .await + .map_err(|e| ToolError::ExecutionFailed(format!("failed to update: {e}")))?; + + // Refresh event cache in case trigger changed + self.engine.refresh_event_cache().await; + + let result = serde_json::json!({ + "name": routine.name, + "enabled": routine.enabled, + "trigger_type": routine.trigger.type_tag(), + "next_fire_at": routine.next_fire_at.map(|t| t.to_rfc3339()), + "status": "updated", + }); + + Ok(ToolOutput::success(result, start.elapsed())) + } + + fn requires_sanitization(&self) -> bool { + false + } +} + +// ==================== routine_delete ==================== + +pub struct RoutineDeleteTool { + store: Arc, + engine: Arc, +} + +impl RoutineDeleteTool { + pub fn new(store: Arc, engine: Arc) -> Self { + Self { store, engine } + } +} + +#[async_trait] +impl Tool for RoutineDeleteTool { + fn name(&self) -> &str { + "routine_delete" + } + + fn description(&self) -> &str { + "Delete a routine permanently. This also removes all run history." + } + + fn parameters_schema(&self) -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Name of the routine to delete" + } + }, + "required": ["name"] + }) + } + + async fn execute( + &self, + params: serde_json::Value, + ctx: &JobContext, + ) -> Result { + let start = std::time::Instant::now(); + + let name = params + .get("name") + .and_then(|v| v.as_str()) + .ok_or_else(|| ToolError::InvalidParameters("missing 'name'".to_string()))?; + + let routine = self + .store + .get_routine_by_name(&ctx.user_id, name) + .await + .map_err(|e| ToolError::ExecutionFailed(format!("DB error: {e}")))? + .ok_or_else(|| ToolError::ExecutionFailed(format!("routine '{}' not found", name)))?; + + let deleted = self + .store + .delete_routine(routine.id) + .await + .map_err(|e| ToolError::ExecutionFailed(format!("failed to delete: {e}")))?; + + // Refresh event cache + self.engine.refresh_event_cache().await; + + let result = serde_json::json!({ + "name": name, + "deleted": deleted, + }); + + Ok(ToolOutput::success(result, start.elapsed())) + } + + fn requires_sanitization(&self) -> bool { + false + } +} + +// ==================== routine_history ==================== + +pub struct RoutineHistoryTool { + store: Arc, +} + +impl RoutineHistoryTool { + pub fn new(store: Arc) -> Self { + Self { store } + } +} + +#[async_trait] +impl Tool for RoutineHistoryTool { + fn name(&self) -> &str { + "routine_history" + } + + fn description(&self) -> &str { + "View the execution history of a routine. Shows recent runs with status, duration, and results." + } + + fn parameters_schema(&self) -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Name of the routine" + }, + "limit": { + "type": "integer", + "description": "Max runs to return (default: 10)", + "default": 10 + } + }, + "required": ["name"] + }) + } + + async fn execute( + &self, + params: serde_json::Value, + ctx: &JobContext, + ) -> Result { + let start = std::time::Instant::now(); + + let name = params + .get("name") + .and_then(|v| v.as_str()) + .ok_or_else(|| ToolError::InvalidParameters("missing 'name'".to_string()))?; + + let limit = params + .get("limit") + .and_then(|v| v.as_i64()) + .unwrap_or(10) + .min(50); + + let routine = self + .store + .get_routine_by_name(&ctx.user_id, name) + .await + .map_err(|e| ToolError::ExecutionFailed(format!("DB error: {e}")))? + .ok_or_else(|| ToolError::ExecutionFailed(format!("routine '{}' not found", name)))?; + + let runs = self + .store + .list_routine_runs(routine.id, limit) + .await + .map_err(|e| ToolError::ExecutionFailed(format!("failed to list runs: {e}")))?; + + let run_list: Vec = runs + .iter() + .map(|r| { + let duration_secs = r + .completed_at + .map(|c| c.signed_duration_since(r.started_at).num_seconds()); + serde_json::json!({ + "id": r.id.to_string(), + "trigger_type": r.trigger_type, + "trigger_detail": r.trigger_detail, + "started_at": r.started_at.to_rfc3339(), + "completed_at": r.completed_at.map(|t| t.to_rfc3339()), + "duration_secs": duration_secs, + "status": r.status.to_string(), + "result_summary": r.result_summary, + "tokens_used": r.tokens_used, + }) + }) + .collect(); + + let result = serde_json::json!({ + "routine": name, + "total_runs": routine.run_count, + "runs": run_list, + }); + + Ok(ToolOutput::success(result, start.elapsed())) + } + + fn requires_sanitization(&self) -> bool { + false + } +} diff --git a/src/tools/builtin/shell.rs b/src/tools/builtin/shell.rs index 202a9109..a428f125 100644 --- a/src/tools/builtin/shell.rs +++ b/src/tools/builtin/shell.rs @@ -30,7 +30,7 @@ use tokio::process::Command; use crate::context::JobContext; use crate::sandbox::{SandboxManager, SandboxPolicy}; -use crate::tools::tool::{Tool, ToolError, ToolOutput}; +use crate::tools::tool::{Tool, ToolDomain, ToolError, ToolOutput}; /// Maximum output size before truncation (64KB). const MAX_OUTPUT_SIZE: usize = 64 * 1024; @@ -386,6 +386,10 @@ impl Tool for ShellTool { fn requires_sanitization(&self) -> bool { true // Shell output could contain anything } + + fn domain(&self) -> ToolDomain { + ToolDomain::Container + } } /// Truncate output to fit within limits. diff --git a/src/tools/mcp/config.rs b/src/tools/mcp/config.rs index 54893654..4eb3fc2f 100644 --- a/src/tools/mcp/config.rs +++ b/src/tools/mcp/config.rs @@ -327,6 +327,86 @@ pub async fn get_mcp_server(name: &str) -> Result }) } +// ==================== Database-backed MCP server config ==================== + +/// Load MCP server configurations from the database settings table. +/// +/// Falls back to the disk file if DB has no entry. +pub async fn load_mcp_servers_from_db( + store: &crate::history::Store, + user_id: &str, +) -> Result { + match store.get_setting(user_id, "mcp_servers").await { + Ok(Some(value)) => { + let config: McpServersFile = serde_json::from_value(value)?; + Ok(config) + } + Ok(None) => { + // No entry in DB, fall back to disk + load_mcp_servers().await + } + Err(e) => { + tracing::warn!( + "Failed to load MCP servers from DB: {}, falling back to disk", + e + ); + load_mcp_servers().await + } + } +} + +/// Save MCP server configurations to the database settings table. +pub async fn save_mcp_servers_to_db( + store: &crate::history::Store, + user_id: &str, + config: &McpServersFile, +) -> Result<(), ConfigError> { + let value = serde_json::to_value(config)?; + store + .set_setting(user_id, "mcp_servers", &value) + .await + .map_err(|e| { + ConfigError::Io(std::io::Error::new( + std::io::ErrorKind::Other, + e.to_string(), + )) + })?; + Ok(()) +} + +/// Add a new MCP server configuration (DB-backed). +pub async fn add_mcp_server_db( + store: &crate::history::Store, + user_id: &str, + config: McpServerConfig, +) -> Result<(), ConfigError> { + config.validate()?; + + let mut servers = load_mcp_servers_from_db(store, user_id).await?; + servers.upsert(config); + save_mcp_servers_to_db(store, user_id, &servers).await?; + + Ok(()) +} + +/// Remove an MCP server by name (DB-backed). +pub async fn remove_mcp_server_db( + store: &crate::history::Store, + user_id: &str, + name: &str, +) -> Result<(), ConfigError> { + let mut servers = load_mcp_servers_from_db(store, user_id).await?; + + if !servers.remove(name) { + return Err(ConfigError::ServerNotFound { + name: name.to_string(), + }); + } + + save_mcp_servers_to_db(store, user_id, &servers).await?; + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/tools/mod.rs b/src/tools/mod.rs index 5943c722..d26f79d3 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -23,4 +23,4 @@ pub use builder::{ }; pub use registry::ToolRegistry; pub use sandbox::ToolSandbox; -pub use tool::{Tool, ToolError, ToolOutput}; +pub use tool::{Tool, ToolDomain, ToolError, ToolOutput}; diff --git a/src/tools/registry.rs b/src/tools/registry.rs index acc038a7..845af8e2 100644 --- a/src/tools/registry.rs +++ b/src/tools/registry.rs @@ -7,7 +7,9 @@ use tokio::sync::RwLock; use crate::context::ContextManager; use crate::extensions::ExtensionManager; +use crate::history::Store; use crate::llm::{LlmProvider, ToolDefinition}; +use crate::orchestrator::job_manager::ContainerJobManager; use crate::safety::SafetyLayer; use crate::tools::builder::{BuildSoftwareTool, BuilderConfig, LlmSoftwareBuilder}; use crate::tools::builtin::{ @@ -16,7 +18,7 @@ use crate::tools::builtin::{ ReadFileTool, ShellTool, TimeTool, ToolActivateTool, ToolAuthTool, ToolInstallTool, ToolListTool, ToolRemoveTool, ToolSearchTool, WriteFileTool, }; -use crate::tools::tool::Tool; +use crate::tools::tool::{Tool, ToolDomain}; use crate::tools::wasm::{ Capabilities, ResourceLimits, WasmError, WasmStorageError, WasmToolRuntime, WasmToolStore, WasmToolWrapper, @@ -120,6 +122,39 @@ impl ToolRegistry { tracing::info!("Registered {} built-in tools", self.count()); } + /// Register only orchestrator-domain tools (safe for the main process). + /// + /// This registers tools that don't touch the filesystem or run shell commands: + /// echo, time, json, http. Use this when `allow_local_tools = false` and + /// container-domain tools should only be available inside sandboxed containers. + pub fn register_orchestrator_tools(&self) { + self.register_builtin_tools(); + // register_builtin_tools already only registers orchestrator-domain tools + } + + /// Register container-domain tools (filesystem, shell, code). + /// + /// These tools are intended to run inside sandboxed Docker containers. + /// Call this in the worker process, not the orchestrator (unless `allow_local_tools = true`). + pub fn register_container_tools(&self) { + self.register_dev_tools(); + } + + /// Get tool definitions filtered by domain. + pub async fn tool_definitions_for_domain(&self, domain: ToolDomain) -> Vec { + self.tools + .read() + .await + .values() + .filter(|tool| tool.domain() == domain) + .map(|tool| ToolDefinition { + name: tool.name().to_string(), + description: tool.description().to_string(), + parameters: tool.parameters_schema(), + }) + .collect() + } + /// Register development tools for building software. /// /// These tools provide shell access, file operations, and code editing @@ -151,9 +186,19 @@ impl ToolRegistry { /// Register job management tools. /// /// Job tools allow the LLM to create, list, check status, and cancel jobs. - /// These enable natural language job management without hardcoded intent parsing. - pub fn register_job_tools(&self, context_manager: Arc) { - self.register_sync(Arc::new(CreateJobTool::new(Arc::clone(&context_manager)))); + /// When sandbox deps are provided, `create_job` automatically delegates to + /// Docker containers. Otherwise it creates in-memory jobs via ContextManager. + pub fn register_job_tools( + &self, + context_manager: Arc, + job_manager: Option>, + store: Option>, + ) { + let mut create_tool = CreateJobTool::new(Arc::clone(&context_manager)); + if let Some(jm) = job_manager { + create_tool = create_tool.with_sandbox(jm, store); + } + self.register_sync(Arc::new(create_tool)); self.register_sync(Arc::new(ListJobsTool::new(Arc::clone(&context_manager)))); self.register_sync(Arc::new(JobStatusTool::new(Arc::clone(&context_manager)))); self.register_sync(Arc::new(CancelJobTool::new(context_manager))); @@ -174,6 +219,36 @@ impl ToolRegistry { tracing::info!("Registered 6 extension management tools"); } + /// Register routine management tools. + /// + /// These allow the LLM to create, list, update, delete, and view history + /// of routines (scheduled and event-driven tasks). + pub fn register_routine_tools( + &self, + store: Arc, + engine: Arc, + ) { + use crate::tools::builtin::{ + RoutineCreateTool, RoutineDeleteTool, RoutineHistoryTool, RoutineListTool, + RoutineUpdateTool, + }; + self.register_sync(Arc::new(RoutineCreateTool::new( + Arc::clone(&store), + Arc::clone(&engine), + ))); + self.register_sync(Arc::new(RoutineListTool::new(Arc::clone(&store)))); + self.register_sync(Arc::new(RoutineUpdateTool::new( + Arc::clone(&store), + Arc::clone(&engine), + ))); + self.register_sync(Arc::new(RoutineDeleteTool::new( + Arc::clone(&store), + Arc::clone(&engine), + ))); + self.register_sync(Arc::new(RoutineHistoryTool::new(store))); + tracing::info!("Registered 5 routine management tools"); + } + /// Register the software builder tool. /// /// The builder tool allows the agent to create new software including WASM tools, diff --git a/src/tools/tool.rs b/src/tools/tool.rs index 06fcb2c6..bfe51820 100644 --- a/src/tools/tool.rs +++ b/src/tools/tool.rs @@ -9,6 +9,18 @@ use thiserror::Error; use crate::context::JobContext; +/// Where a tool should execute: orchestrator process or inside a container. +/// +/// Orchestrator tools run in the main agent process (memory access, job mgmt, etc). +/// Container tools run inside Docker containers (shell, file ops, code mods). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ToolDomain { + /// Safe to run in the orchestrator (pure functions, memory, job management). + Orchestrator, + /// Must run inside a sandboxed container (filesystem, shell, code). + Container, +} + /// Error type for tool execution. #[derive(Debug, Error)] pub enum ToolError { @@ -160,6 +172,23 @@ pub trait Tool: Send + Sync { false } + /// Maximum time this tool is allowed to run before the caller kills it. + /// Override for long-running tools like sandbox execution. + /// Default: 60 seconds. + fn execution_timeout(&self) -> Duration { + Duration::from_secs(60) + } + + /// Where this tool should execute. + /// + /// `Orchestrator` tools run in the main agent process (safe, no FS access). + /// `Container` tools run inside Docker containers (shell, file ops). + /// + /// Default: `Orchestrator` (safe for the main process). + fn domain(&self) -> ToolDomain { + ToolDomain::Orchestrator + } + /// Get the tool schema for LLM function calling. fn schema(&self) -> ToolSchema { ToolSchema { @@ -242,4 +271,10 @@ mod tests { assert_eq!(schema.name, "echo"); assert!(!schema.description.is_empty()); } + + #[test] + fn test_execution_timeout_default() { + let tool = EchoTool; + assert_eq!(tool.execution_timeout(), Duration::from_secs(60)); + } } diff --git a/src/tools/wasm/loader.rs b/src/tools/wasm/loader.rs index ade7661c..230de268 100644 --- a/src/tools/wasm/loader.rs +++ b/src/tools/wasm/loader.rs @@ -2,6 +2,7 @@ //! //! This module provides a way to load WASM tools dynamically at runtime from: //! - A directory containing `.wasm` and `.capabilities.json` +//! - Build artifacts in `tools-src/` (dev mode, auto-detected) //! - Database storage (via [`WasmToolStore`]) //! //! # Example: Loading from Directory @@ -19,6 +20,13 @@ //! loader.load_from_dir(Path::new("~/.ironclaw/tools/")).await?; //! ``` //! +//! # Dev Mode +//! +//! When `load_dev_tools()` is called, the loader scans `tools-src/*/` for build +//! artifacts. Tools found there are loaded directly from the build output, +//! skipping the install directory. This means during development you just +//! rebuild the WASM and restart the host, no manual copy step needed. +//! //! # Security //! //! Tools loaded from files are assigned `TrustLevel::User` by default, meaning @@ -312,6 +320,159 @@ impl LoadResults { } } +/// Compile-time project root, used to locate tools-src/ in dev builds. +const CARGO_MANIFEST_DIR: &str = env!("CARGO_MANIFEST_DIR"); + +/// Resolve the tools source directory. +/// +/// Checks (in order): +/// 1. `IRONCLAW_TOOLS_SRC` env var +/// 2. `/tools-src/` (dev builds) +fn tools_src_dir() -> PathBuf { + if let Ok(dir) = std::env::var("IRONCLAW_TOOLS_SRC") { + return PathBuf::from(dir); + } + PathBuf::from(CARGO_MANIFEST_DIR).join("tools-src") +} + +/// Discover WASM tools available as build artifacts in `tools-src/`. +/// +/// Scans each subdirectory for: +/// - `tools-src//target/wasm32-wasip2/release/_tool.wasm` +/// - `tools-src//-tool.capabilities.json` +/// +/// Returns a map of install-name (e.g. "gmail-tool") to paths. +pub async fn discover_dev_tools() -> Result, std::io::Error> { + let src_dir = tools_src_dir(); + let mut tools = HashMap::new(); + + if !src_dir.is_dir() { + return Ok(tools); + } + + let mut entries = fs::read_dir(&src_dir).await?; + while let Some(entry) = entries.next_entry().await? { + let path = entry.path(); + if !path.is_dir() { + continue; + } + + let dir_name = match path.file_name().and_then(|n| n.to_str()) { + Some(n) => n.to_string(), + None => continue, + }; + + // Convention: crate name uses underscores, directory uses hyphens + let crate_name = dir_name.replace('-', "_"); + let install_name = format!("{}-tool", dir_name); + + let wasm_path = path + .join("target/wasm32-wasip2/release") + .join(format!("{}_tool.wasm", crate_name)); + + if !wasm_path.exists() { + continue; + } + + let caps_path = path.join(format!("{}-tool.capabilities.json", dir_name)); + + tools.insert( + install_name, + DiscoveredTool { + wasm_path, + capabilities_path: if caps_path.exists() { + Some(caps_path) + } else { + None + }, + }, + ); + } + + Ok(tools) +} + +/// Load WASM tools from build artifacts in `tools-src/`. +/// +/// In dev mode, tools can be loaded directly from their build output without +/// needing to install them to `~/.ironclaw/tools/` first. Build artifacts +/// that are newer than installed copies take priority. +/// +/// Set `IRONCLAW_TOOLS_SRC` env var to override the source directory. +pub async fn load_dev_tools( + loader: &WasmToolLoader, + install_dir: &Path, +) -> Result { + let dev_tools = discover_dev_tools().await?; + let mut results = LoadResults::default(); + + if dev_tools.is_empty() { + return Ok(results); + } + + for (name, discovered) in &dev_tools { + // Check if the build artifact is newer than the installed copy + let installed_path = install_dir.join(format!("{}.wasm", name)); + let should_load = if installed_path.exists() { + // Compare modification times: prefer fresher build artifact + match ( + fs::metadata(&discovered.wasm_path).await, + fs::metadata(&installed_path).await, + ) { + (Ok(dev_meta), Ok(inst_meta)) => { + let dev_modified = dev_meta.modified().unwrap_or(std::time::UNIX_EPOCH); + let inst_modified = inst_meta.modified().unwrap_or(std::time::UNIX_EPOCH); + dev_modified > inst_modified + } + _ => true, + } + } else { + true + }; + + if !should_load { + continue; + } + + tracing::info!( + name = name, + wasm_path = %discovered.wasm_path.display(), + "Loading dev tool from build artifacts (newer than installed)" + ); + + match loader + .load_from_files( + name, + &discovered.wasm_path, + discovered.capabilities_path.as_deref(), + ) + .await + { + Ok(()) => { + results.loaded.push(name.clone()); + } + Err(e) => { + tracing::error!( + name = name, + error = %e, + "Failed to load dev tool" + ); + results.errors.push((discovered.wasm_path.clone(), e)); + } + } + } + + if !results.loaded.is_empty() { + tracing::info!( + count = results.loaded.len(), + tools = ?results.loaded, + "Loaded dev tools from build artifacts" + ); + } + + Ok(results) +} + /// Discover WASM tool files in a directory without loading them. /// /// Returns a map of tool name -> (wasm_path, capabilities_path). @@ -430,4 +591,31 @@ mod tests { let err = WasmLoadError::WasmNotFound(std::path::PathBuf::from("/foo/bar.wasm")); assert!(err.to_string().contains("/foo/bar.wasm")); } + + #[test] + fn test_tools_src_dir_default() { + let dir = super::tools_src_dir(); + assert!(dir.ends_with("tools-src")); + } + + #[tokio::test] + async fn test_discover_dev_tools_finds_build_artifacts() { + // This test relies on the actual tools-src/ directory in the repo. + // If build artifacts exist, they should be discovered. + let tools = super::discover_dev_tools().await.unwrap(); + + // If any tools have been built, they should appear with "-tool" suffix + for (name, discovered) in &tools { + assert!( + name.ends_with("-tool"), + "Dev tool name should end with -tool: {}", + name + ); + assert!( + discovered.wasm_path.exists(), + "WASM should exist: {:?}", + discovered.wasm_path + ); + } + } } diff --git a/src/tools/wasm/mod.rs b/src/tools/wasm/mod.rs index d898dbbe..fea18e44 100644 --- a/src/tools/wasm/mod.rs +++ b/src/tools/wasm/mod.rs @@ -115,7 +115,10 @@ pub use storage::{ }; // Loader -pub use loader::{DiscoveredTool, LoadResults, WasmLoadError, WasmToolLoader, discover_tools}; +pub use loader::{ + DiscoveredTool, LoadResults, WasmLoadError, WasmToolLoader, discover_dev_tools, discover_tools, + load_dev_tools, +}; // Capabilities schema (for parsing *.capabilities.json files) pub use capabilities_schema::{ diff --git a/src/tools/wasm/wrapper.rs b/src/tools/wasm/wrapper.rs index b499cc3b..bd2be44e 100644 --- a/src/tools/wasm/wrapper.rs +++ b/src/tools/wasm/wrapper.rs @@ -1,16 +1,23 @@ //! WASM tool wrapper implementing the Tool trait. //! +//! Uses wasmtime::component::bindgen! to generate typed bindings from the WIT +//! interface, ensuring all host functions are properly registered under the +//! correct `near:agent/host` namespace. +//! //! Each execution creates a fresh instance (NEAR pattern) to ensure //! isolation and deterministic behavior. +use std::collections::HashMap; use std::sync::Arc; use std::time::{Duration, Instant}; use async_trait::async_trait; use wasmtime::Store; -use wasmtime::component::{Component, Linker, Val}; +use wasmtime::component::{Component, Linker}; +use wasmtime_wasi::{ResourceTable, WasiCtx, WasiCtxBuilder, WasiView}; use crate::context::JobContext; +use crate::safety::LeakDetector; use crate::tools::tool::{Tool, ToolError, ToolOutput}; use crate::tools::wasm::capabilities::Capabilities; use crate::tools::wasm::error::WasmError; @@ -18,21 +25,259 @@ use crate::tools::wasm::host::{HostState, LogLevel}; use crate::tools::wasm::limits::{ResourceLimits, WasmResourceLimiter}; use crate::tools::wasm::runtime::{PreparedModule, WasmToolRuntime}; -/// Store data for WASM execution. +// Generate component model bindings from the WIT file. +// +// This creates: +// - `near::agent::host::Host` trait + `add_to_linker()` for the import interface +// - `SandboxedTool` struct with `instantiate()` for the world +// - `exports::near::agent::tool::*` types for the export interface +wasmtime::component::bindgen!({ + path: "wit/tool.wit", + world: "sandboxed-tool", + async: false, + with: {}, +}); + +// Alias the export interface types for convenience. +use exports::near::agent::tool as wit_tool; + +/// Store data for WASM tool execution. /// -/// Contains both the resource limiter and host state. +/// Contains the resource limiter, host state, WASI context, and injected +/// credentials. Fresh instance created per execution (NEAR pattern). struct StoreData { limiter: WasmResourceLimiter, host_state: HostState, + wasi: WasiCtx, + table: ResourceTable, + /// Injected credentials for URL/header substitution. + /// Keys are placeholder names like "GOOGLE_ACCESS_TOKEN". + credentials: HashMap, } impl StoreData { - fn new(memory_limit: u64, capabilities: Capabilities) -> Self { + fn new( + memory_limit: u64, + capabilities: Capabilities, + credentials: HashMap, + ) -> Self { + // Minimal WASI context: no filesystem, no env vars (security) + let wasi = WasiCtxBuilder::new().build(); + Self { limiter: WasmResourceLimiter::new(memory_limit), host_state: HostState::new(capabilities), + wasi, + table: ResourceTable::new(), + credentials, } } + + /// Inject credentials into a string by replacing placeholders. + /// + /// Replaces patterns like `{GOOGLE_ACCESS_TOKEN}` with actual values. + /// WASM tools reference credentials by placeholder, never seeing real values. + fn inject_credentials(&self, input: &str, context: &str) -> String { + let mut result = input.to_string(); + + for (name, value) in &self.credentials { + let placeholder = format!("{{{}}}", name); + if result.contains(&placeholder) { + tracing::debug!( + placeholder = %placeholder, + context = %context, + "Replacing credential placeholder in tool request" + ); + result = result.replace(&placeholder, value); + } + } + + result + } + + /// Replace injected credential values with `[REDACTED]` in text. + /// + /// Prevents credentials from leaking through error messages or logs. + /// reqwest::Error includes the full URL in its Display output, so any + /// error from an injected-URL request will contain the raw credential + /// unless we scrub it. + fn redact_credentials(&self, text: &str) -> String { + let mut result = text.to_string(); + for (name, value) in &self.credentials { + if !value.is_empty() { + result = result.replace(value, &format!("[REDACTED:{}]", name)); + } + } + result + } +} + +// Provide WASI context for the WASM component. +// Required because tools are compiled with wasm32-wasip2 target. +impl WasiView for StoreData { + fn ctx(&mut self) -> &mut WasiCtx { + &mut self.wasi + } + + fn table(&mut self) -> &mut ResourceTable { + &mut self.table + } +} + +// Implement the generated Host trait from bindgen. +// +// This registers all 6 host functions under the `near:agent/host` namespace: +// log, now-millis, workspace-read, http-request, secret-exists, tool-invoke +impl near::agent::host::Host for StoreData { + fn log(&mut self, level: near::agent::host::LogLevel, message: String) { + let log_level = match level { + near::agent::host::LogLevel::Trace => LogLevel::Trace, + near::agent::host::LogLevel::Debug => LogLevel::Debug, + near::agent::host::LogLevel::Info => LogLevel::Info, + near::agent::host::LogLevel::Warn => LogLevel::Warn, + near::agent::host::LogLevel::Error => LogLevel::Error, + }; + let _ = self.host_state.log(log_level, message); + } + + fn now_millis(&mut self) -> u64 { + self.host_state.now_millis() + } + + fn workspace_read(&mut self, path: String) -> Option { + self.host_state.workspace_read(&path).ok().flatten() + } + + fn http_request( + &mut self, + method: String, + url: String, + headers_json: String, + body: Option>, + timeout_ms: Option, + ) -> Result { + // Inject credentials into URL (e.g., replace {TELEGRAM_BOT_TOKEN}) + let injected_url = self.inject_credentials(&url, "url"); + + // Check HTTP allowlist + self.host_state + .check_http_allowed(&injected_url, &method) + .map_err(|e| format!("HTTP not allowed: {}", e))?; + + // Record for rate limiting + self.host_state + .record_http_request() + .map_err(|e| format!("Rate limit exceeded: {}", e))?; + + // Parse headers and inject credentials into header values + let raw_headers: HashMap = + serde_json::from_str(&headers_json).unwrap_or_default(); + + let headers: HashMap = raw_headers + .into_iter() + .map(|(k, v)| { + ( + k.clone(), + self.inject_credentials(&v, &format!("header:{}", k)), + ) + }) + .collect(); + + let url = injected_url; + let leak_detector = LeakDetector::new(); + let header_vec: Vec<(String, String)> = headers + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + + leak_detector + .scan_http_request(&url, &header_vec, body.as_deref()) + .map_err(|e| format!("Potential secret leak blocked: {}", e))?; + + // Make HTTP request using blocking I/O. + // We're inside a spawn_blocking context, so use block_on. + let result = tokio::runtime::Handle::current().block_on(async { + let client = reqwest::Client::new(); + + let mut request = match method.to_uppercase().as_str() { + "GET" => client.get(&url), + "POST" => client.post(&url), + "PUT" => client.put(&url), + "DELETE" => client.delete(&url), + "PATCH" => client.patch(&url), + "HEAD" => client.head(&url), + _ => return Err(format!("Unsupported HTTP method: {}", method)), + }; + + for (key, value) in headers { + request = request.header(&key, &value); + } + + if let Some(body_bytes) = body { + request = request.body(body_bytes); + } + + // Caller-specified timeout (default 30s) + let timeout = Duration::from_millis(timeout_ms.unwrap_or(30_000) as u64); + let response = request.timeout(timeout).send().await.map_err(|e| { + // Walk the full error chain for the actual root cause + let mut chain = format!("HTTP request failed: {}", e); + let mut source = std::error::Error::source(&e); + while let Some(cause) = source { + chain.push_str(&format!(" -> {}", cause)); + source = cause.source(); + } + chain + })?; + + let status = response.status().as_u16(); + let response_headers: HashMap = response + .headers() + .iter() + .filter_map(|(k, v)| { + v.to_str() + .ok() + .map(|v| (k.as_str().to_string(), v.to_string())) + }) + .collect(); + let headers_json = serde_json::to_string(&response_headers).unwrap_or_default(); + let body = response + .bytes() + .await + .map_err(|e| format!("Failed to read response body: {}", e))? + .to_vec(); + + // Leak detection on response body + if let Ok(body_str) = std::str::from_utf8(&body) { + leak_detector + .scan_and_clean(body_str) + .map_err(|e| format!("Potential secret leak in response: {}", e))?; + } + + Ok(near::agent::host::HttpResponse { + status, + headers_json, + body, + }) + }); + + // Redact credentials from error messages before returning to WASM + result.map_err(|e| self.redact_credentials(&e)) + } + + fn tool_invoke(&mut self, alias: String, _params_json: String) -> Result { + // Validate capability and resolve alias + let _real_name = self.host_state.check_tool_invoke_allowed(&alias)?; + self.host_state.record_tool_invoke()?; + + // Tool invocation requires async context and access to the tool registry, + // which aren't available inside a synchronous WASM callback. + Err("Tool invocation from WASM tools is not yet supported".to_string()) + } + + fn secret_exists(&mut self, name: String) -> bool { + self.host_state.secret_exists(&name) + } } /// A Tool implementation backed by a WASM component. @@ -49,6 +294,9 @@ pub struct WasmToolWrapper { description: String, /// Cached schema (from PreparedModule or override). schema: serde_json::Value, + /// Injected credentials for HTTP requests (e.g., OAuth tokens). + /// Keys are placeholder names like "GOOGLE_ACCESS_TOKEN". + credentials: HashMap, } impl WasmToolWrapper { @@ -64,6 +312,7 @@ impl WasmToolWrapper { runtime, prepared, capabilities, + credentials: HashMap::new(), } } @@ -79,11 +328,34 @@ impl WasmToolWrapper { self } + /// Set credentials for HTTP request injection. + pub fn with_credentials(mut self, credentials: HashMap) -> Self { + self.credentials = credentials; + self + } + /// Get the resource limits for this tool. pub fn limits(&self) -> &ResourceLimits { &self.prepared.limits } + /// Add all host functions to the linker using generated bindings. + /// + /// Uses the bindgen-generated `add_to_linker` function to properly register + /// all host functions with correct component model signatures under the + /// `near:agent/host` namespace. + fn add_host_functions(linker: &mut Linker) -> Result<(), WasmError> { + // Add WASI support (required by components built with wasm32-wasip2) + wasmtime_wasi::add_to_linker_sync(linker) + .map_err(|e| WasmError::ConfigError(format!("Failed to add WASI functions: {}", e)))?; + + // Add our custom host interface using the generated add_to_linker + near::agent::host::add_to_linker(linker, |state| state) + .map_err(|e| WasmError::ConfigError(format!("Failed to add host functions: {}", e)))?; + + Ok(()) + } + /// Execute the WASM tool synchronously (called from spawn_blocking). fn execute_sync( &self, @@ -94,7 +366,11 @@ impl WasmToolWrapper { let limits = &self.prepared.limits; // Create store with fresh state (NEAR pattern: fresh instance per call) - let store_data = StoreData::new(limits.memory_bytes, self.capabilities.clone()); + let store_data = StoreData::new( + limits.memory_bytes, + self.capabilities.clone(), + self.credentials.clone(), + ); let mut store = Store::new(engine, store_data); // Configure fuel if enabled @@ -115,172 +391,46 @@ impl WasmToolWrapper { let component = Component::new(engine, self.prepared.component_bytes()) .map_err(|e| WasmError::CompilationFailed(e.to_string()))?; - // Create linker and add host functions + // Create linker with all host functions properly namespaced let mut linker = Linker::new(engine); - self.add_host_functions(&mut linker)?; + Self::add_host_functions(&mut linker)?; - // Instantiate the component - let instance = linker - .instantiate(&mut store, &component) + // Instantiate using the generated bindings + let instance = SandboxedTool::instantiate(&mut store, &component, &linker) .map_err(|e| WasmError::InstantiationFailed(e.to_string()))?; - // Get the execute function - let execute_func = instance - .get_func(&mut store, "execute") - .ok_or_else(|| WasmError::MissingExport("execute".to_string()))?; - - // Prepare request + // Prepare the request let params_json = serde_json::to_string(¶ms) .map_err(|e| WasmError::InvalidResponseJson(e.to_string()))?; - // Build request record - // Note: The exact calling convention depends on how WIT records are lowered. - // With component model, we'd use typed bindings from wit-bindgen. - // For now, we use the lower-level Val API. - let request_params = Val::String(params_json); - let request_context = match context_json { - Some(ctx) => Val::Option(Some(Box::new(Val::String(ctx)))), - None => Val::Option(None), + let request = wit_tool::Request { + params: params_json, + context: context_json, }; - // Create request record (params, context) - let request = Val::Record(vec![ - ("params".to_string(), request_params), - ("context".to_string(), request_context), - ]); - - // Call the function - let mut results = vec![Val::Bool(false)]; // Placeholder for response - execute_func - .call(&mut store, &[request], &mut results) - .map_err(|e| { - // Check for specific trap types - let error_str = e.to_string(); - if error_str.contains("out of fuel") { - WasmError::FuelExhausted { limit: limits.fuel } - } else if error_str.contains("unreachable") { - WasmError::Trapped("unreachable code executed".to_string()) - } else { - WasmError::Trapped(error_str) - } - })?; - - // Post-call completion (cleanup) - execute_func - .post_return(&mut store) - .map_err(|e| WasmError::Trapped(format!("post_return failed: {}", e)))?; - - // Extract response - let response = &results[0]; - let (result_str, error_str) = extract_response(response)?; + // Call execute using the generated typed interface + let tool_iface = instance.near_agent_tool(); + let response = tool_iface.call_execute(&mut store, &request).map_err(|e| { + let error_str = e.to_string(); + if error_str.contains("out of fuel") { + WasmError::FuelExhausted { limit: limits.fuel } + } else if error_str.contains("unreachable") { + WasmError::Trapped("unreachable code executed".to_string()) + } else { + WasmError::Trapped(error_str) + } + })?; // Get logs from host state let logs = store.data_mut().host_state.take_logs(); // Check for tool-level error - if let Some(err) = error_str { + if let Some(err) = response.error { return Err(WasmError::ToolReturnedError(err)); } // Return result (or empty string if none) - Ok((result_str.unwrap_or_default(), logs)) - } - - /// Add host functions to the linker. - fn add_host_functions(&self, linker: &mut Linker) -> Result<(), WasmError> { - // Note: With WIT bindgen, these would be generated automatically. - // For now, we manually define the host functions. - // - // Component model func_wrap signature: F: Fn(StoreContextMut, Params) -> Result - // where Params is a tuple of the function arguments. - - // host.log(level: log-level, message: string) - linker - .root() - .func_wrap( - "log", - |mut ctx: wasmtime::StoreContextMut<'_, StoreData>, - (level, message): (i32, String)| { - let log_level = match level { - 0 => LogLevel::Trace, - 1 => LogLevel::Debug, - 2 => LogLevel::Info, - 3 => LogLevel::Warn, - 4 => LogLevel::Error, - _ => LogLevel::Info, - }; - // Ignore errors from logging (rate limiting) - let _ = ctx.data_mut().host_state.log(log_level, message); - Ok(()) - }, - ) - .map_err(|e| WasmError::ConfigError(format!("Failed to add log function: {}", e)))?; - - // host.now-millis() -> u64 - linker - .root() - .func_wrap( - "now-millis", - |ctx: wasmtime::StoreContextMut<'_, StoreData>, (): ()| -> anyhow::Result<(u64,)> { - Ok((ctx.data().host_state.now_millis(),)) - }, - ) - .map_err(|e| { - WasmError::ConfigError(format!("Failed to add now-millis function: {}", e)) - })?; - - // host.workspace-read(path: string) -> option - linker - .root() - .func_wrap( - "workspace-read", - |ctx: wasmtime::StoreContextMut<'_, StoreData>, - (path,): (String,)| - -> anyhow::Result<(Option,)> { - let result = ctx.data().host_state.workspace_read(&path).ok().flatten(); - Ok((result,)) - }, - ) - .map_err(|e| { - WasmError::ConfigError(format!("Failed to add workspace-read function: {}", e)) - })?; - - Ok(()) - } -} - -/// Extract result and error from a WIT response record. -fn extract_response(response: &Val) -> Result<(Option, Option), WasmError> { - match response { - Val::Record(fields) => { - let mut result = None; - let mut error = None; - - for (name, val) in fields { - match name.as_str() { - "output" => { - if let Val::Option(Some(inner)) = val { - if let Val::String(s) = inner.as_ref() { - result = Some(s.to_string()); - } - } - } - "error" => { - if let Val::Option(Some(inner)) = val { - if let Val::String(s) = inner.as_ref() { - error = Some(s.to_string()); - } - } - } - _ => {} - } - } - - Ok((result, error)) - } - _ => Err(WasmError::InvalidResponseJson( - "Expected record response".to_string(), - )), + Ok((response.output.unwrap_or_default(), logs)) } } @@ -315,6 +465,7 @@ impl Tool for WasmToolWrapper { let capabilities = self.capabilities.clone(); let description = self.description.clone(); let schema = self.schema.clone(); + let credentials = self.credentials.clone(); // Execute in blocking task with timeout let result = tokio::time::timeout(timeout, async move { @@ -324,6 +475,7 @@ impl Tool for WasmToolWrapper { capabilities, description, schema, + credentials, }; tokio::task::spawn_blocking(move || wrapper.execute_sync(params, context_json)) @@ -359,7 +511,7 @@ impl Tool for WasmToolWrapper { } fn requires_sanitization(&self) -> bool { - // WASM tools always require sanitization - they're untrusted by definition + // WASM tools always require sanitization, they're untrusted by definition true } diff --git a/src/worker/api.rs b/src/worker/api.rs new file mode 100644 index 00000000..2974d21e --- /dev/null +++ b/src/worker/api.rs @@ -0,0 +1,400 @@ +//! HTTP client for worker-to-orchestrator communication. +//! +//! Every request includes a bearer token from `IRONCLAW_WORKER_TOKEN` env var. +//! The orchestrator validates this token is scoped to the correct job. + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::error::WorkerError; +use crate::llm::{ + ChatMessage, CompletionRequest, CompletionResponse, FinishReason, ToolCall, + ToolCompletionRequest, ToolCompletionResponse, ToolDefinition, +}; + +/// HTTP client that a container worker uses to talk to the orchestrator. +pub struct WorkerHttpClient { + client: reqwest::Client, + orchestrator_url: String, + job_id: Uuid, + token: String, +} + +/// Status update sent from worker to orchestrator. +#[derive(Debug, Serialize, Deserialize)] +pub struct StatusUpdate { + pub state: String, + pub message: Option, + pub iteration: u32, +} + +/// Job description fetched from orchestrator. +#[derive(Debug, Serialize, Deserialize)] +pub struct JobDescription { + pub title: String, + pub description: String, + pub project_dir: Option, +} + +/// Completion result from the orchestrator (proxied from the real LLM). +#[derive(Debug, Serialize, Deserialize)] +pub struct ProxyCompletionRequest { + pub messages: Vec, + pub max_tokens: Option, + pub temperature: Option, + pub stop_sequences: Option>, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ProxyCompletionResponse { + pub content: String, + pub input_tokens: u32, + pub output_tokens: u32, + pub finish_reason: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ProxyToolCompletionRequest { + pub messages: Vec, + pub tools: Vec, + pub max_tokens: Option, + pub temperature: Option, + pub tool_choice: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ProxyToolCompletionResponse { + pub content: Option, + pub tool_calls: Vec, + pub input_tokens: u32, + pub output_tokens: u32, + pub finish_reason: String, +} + +/// Completion result for the worker to report when done. +#[derive(Debug, Serialize, Deserialize)] +pub struct CompletionReport { + pub success: bool, + pub message: Option, + pub iterations: u32, +} + +/// Payload sent to the orchestrator for each job event (shared by worker and Claude Code bridge). +#[derive(Debug, Serialize, Deserialize)] +pub struct JobEventPayload { + pub event_type: String, + pub data: serde_json::Value, +} + +/// Response from the prompt polling endpoint. +#[derive(Debug, Deserialize)] +pub struct PromptResponse { + pub content: String, + #[serde(default)] + pub done: bool, +} + +impl WorkerHttpClient { + /// Create a new client from environment. + /// + /// Reads `IRONCLAW_WORKER_TOKEN` from the environment. + pub fn from_env(orchestrator_url: String, job_id: Uuid) -> Result { + let token = + std::env::var("IRONCLAW_WORKER_TOKEN").map_err(|_| WorkerError::MissingToken)?; + + Ok(Self { + client: reqwest::Client::new(), + orchestrator_url: orchestrator_url.trim_end_matches('/').to_string(), + job_id, + token, + }) + } + + /// Create with an explicit token (for testing). + pub fn new(orchestrator_url: String, job_id: Uuid, token: String) -> Self { + Self { + client: reqwest::Client::new(), + orchestrator_url: orchestrator_url.trim_end_matches('/').to_string(), + job_id, + token, + } + } + + /// Get the base orchestrator URL. + pub fn orchestrator_url(&self) -> &str { + &self.orchestrator_url + } + + fn url(&self, path: &str) -> String { + format!("{}/worker/{}/{}", self.orchestrator_url, self.job_id, path) + } + + /// Fetch the job description from the orchestrator. + pub async fn get_job(&self) -> Result { + let resp = self + .client + .get(self.url("job")) + .bearer_auth(&self.token) + .send() + .await + .map_err(|e| WorkerError::ConnectionFailed { + url: self.orchestrator_url.clone(), + reason: e.to_string(), + })?; + + if !resp.status().is_success() { + return Err(WorkerError::OrchestratorRejected { + job_id: self.job_id, + reason: format!("GET /job returned {}", resp.status()), + }); + } + + resp.json().await.map_err(|e| WorkerError::LlmProxyFailed { + reason: format!("failed to parse job description: {}", e), + }) + } + + /// Proxy an LLM completion request through the orchestrator. + pub async fn llm_complete( + &self, + request: &CompletionRequest, + ) -> Result { + let proxy_req = ProxyCompletionRequest { + messages: request.messages.clone(), + max_tokens: request.max_tokens, + temperature: request.temperature, + stop_sequences: request.stop_sequences.clone(), + }; + + let resp = self + .client + .post(self.url("llm/complete")) + .bearer_auth(&self.token) + .json(&proxy_req) + .send() + .await + .map_err(|e| WorkerError::LlmProxyFailed { + reason: e.to_string(), + })?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(WorkerError::LlmProxyFailed { + reason: format!("orchestrator returned {}: {}", status, body), + }); + } + + let proxy_resp: ProxyCompletionResponse = + resp.json().await.map_err(|e| WorkerError::LlmProxyFailed { + reason: format!("failed to parse LLM response: {}", e), + })?; + + Ok(CompletionResponse { + content: proxy_resp.content, + input_tokens: proxy_resp.input_tokens, + output_tokens: proxy_resp.output_tokens, + finish_reason: parse_finish_reason(&proxy_resp.finish_reason), + response_id: None, + }) + } + + /// Proxy an LLM tool completion request through the orchestrator. + pub async fn llm_complete_with_tools( + &self, + request: &ToolCompletionRequest, + ) -> Result { + let proxy_req = ProxyToolCompletionRequest { + messages: request.messages.clone(), + tools: request.tools.clone(), + max_tokens: request.max_tokens, + temperature: request.temperature, + tool_choice: request.tool_choice.clone(), + }; + + let resp = self + .client + .post(self.url("llm/complete_with_tools")) + .bearer_auth(&self.token) + .json(&proxy_req) + .send() + .await + .map_err(|e| WorkerError::LlmProxyFailed { + reason: e.to_string(), + })?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(WorkerError::LlmProxyFailed { + reason: format!("orchestrator returned {}: {}", status, body), + }); + } + + let proxy_resp: ProxyToolCompletionResponse = + resp.json().await.map_err(|e| WorkerError::LlmProxyFailed { + reason: format!("failed to parse tool completion response: {}", e), + })?; + + Ok(ToolCompletionResponse { + content: proxy_resp.content, + tool_calls: proxy_resp.tool_calls, + input_tokens: proxy_resp.input_tokens, + output_tokens: proxy_resp.output_tokens, + finish_reason: parse_finish_reason(&proxy_resp.finish_reason), + response_id: None, + }) + } + + /// Report status to the orchestrator. + pub async fn report_status(&self, update: &StatusUpdate) -> Result<(), WorkerError> { + let resp = self + .client + .post(self.url("status")) + .bearer_auth(&self.token) + .json(update) + .send() + .await + .map_err(|e| WorkerError::ConnectionFailed { + url: self.orchestrator_url.clone(), + reason: e.to_string(), + })?; + + if !resp.status().is_success() { + tracing::warn!( + "Status report failed with {}: {}", + resp.status(), + resp.text().await.unwrap_or_default() + ); + } + + Ok(()) + } + + /// Post a job event to the orchestrator (fire-and-forget style, logs on failure). + pub async fn post_event(&self, payload: &JobEventPayload) { + let resp = self + .client + .post(self.url("event")) + .bearer_auth(&self.token) + .json(payload) + .send() + .await; + + match resp { + Ok(r) if !r.status().is_success() => { + tracing::debug!( + job_id = %self.job_id, + event_type = %payload.event_type, + status = %r.status(), + "Job event POST rejected" + ); + } + Err(e) => { + tracing::debug!( + job_id = %self.job_id, + event_type = %payload.event_type, + "Job event POST failed: {}", e + ); + } + _ => {} + } + } + + /// Poll the orchestrator for a follow-up prompt. + /// + /// Returns `None` if no prompt is available (204 No Content). + pub async fn poll_prompt(&self) -> Result, WorkerError> { + let resp = self + .client + .get(self.url("prompt")) + .bearer_auth(&self.token) + .send() + .await + .map_err(|e| WorkerError::ConnectionFailed { + url: self.orchestrator_url.clone(), + reason: e.to_string(), + })?; + + if resp.status() == reqwest::StatusCode::NO_CONTENT { + return Ok(None); + } + + if !resp.status().is_success() { + return Err(WorkerError::OrchestratorRejected { + job_id: self.job_id, + reason: format!("prompt endpoint returned {}", resp.status()), + }); + } + + let prompt: PromptResponse = + resp.json().await.map_err(|e| WorkerError::LlmProxyFailed { + reason: format!("failed to parse prompt response: {}", e), + })?; + + Ok(Some(prompt)) + } + + /// Signal job completion to the orchestrator. + pub async fn report_complete(&self, report: &CompletionReport) -> Result<(), WorkerError> { + let resp = self + .client + .post(self.url("complete")) + .bearer_auth(&self.token) + .json(report) + .send() + .await + .map_err(|e| WorkerError::ConnectionFailed { + url: self.orchestrator_url.clone(), + reason: e.to_string(), + })?; + + if !resp.status().is_success() { + return Err(WorkerError::OrchestratorRejected { + job_id: self.job_id, + reason: format!("completion report rejected: {}", resp.status()), + }); + } + + Ok(()) + } +} + +fn parse_finish_reason(s: &str) -> FinishReason { + match s { + "stop" => FinishReason::Stop, + "length" => FinishReason::Length, + "tool_use" | "tool_calls" => FinishReason::ToolUse, + "content_filter" => FinishReason::ContentFilter, + _ => FinishReason::Unknown, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_url_construction() { + let client = WorkerHttpClient::new( + "http://host.docker.internal:50051".to_string(), + Uuid::nil(), + "test-token".to_string(), + ); + + assert_eq!( + client.url("llm/complete"), + format!( + "http://host.docker.internal:50051/worker/{}/llm/complete", + Uuid::nil() + ) + ); + } + + #[test] + fn test_parse_finish_reason() { + assert_eq!(parse_finish_reason("stop"), FinishReason::Stop); + assert_eq!(parse_finish_reason("tool_use"), FinishReason::ToolUse); + assert_eq!(parse_finish_reason("unknown"), FinishReason::Unknown); + } +} diff --git a/src/worker/claude_bridge.rs b/src/worker/claude_bridge.rs new file mode 100644 index 00000000..1f639281 --- /dev/null +++ b/src/worker/claude_bridge.rs @@ -0,0 +1,644 @@ +//! Claude Code bridge for sandboxed execution. +//! +//! Spawns the `claude` CLI inside a Docker container and streams its NDJSON +//! output back to the orchestrator via HTTP. Supports follow-up prompts via +//! `--resume`. +//! +//! ```text +//! ┌─────────────────────────────────────────────┐ +//! │ Docker Container │ +//! │ │ +//! │ ironclaw claude-bridge --job-id │ +//! │ └─ claude -p "task" --output-format │ +//! │ stream-json --dangerously-skip-perms │ +//! │ └─ reads stdout line-by-line │ +//! │ └─ POSTs events to orchestrator │ +//! │ └─ polls for follow-up prompts │ +//! │ └─ on follow-up: claude --resume │ +//! └─────────────────────────────────────────────┘ +//! ``` + +use std::sync::Arc; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::process::Command; +use uuid::Uuid; + +use crate::error::WorkerError; +use crate::worker::api::{CompletionReport, JobEventPayload, PromptResponse, WorkerHttpClient}; + +/// Configuration for the Claude bridge runtime. +pub struct ClaudeBridgeConfig { + pub job_id: Uuid, + pub orchestrator_url: String, + pub max_turns: u32, + pub model: String, + pub timeout: Duration, +} + +/// A Claude Code streaming event (NDJSON line from `--output-format stream-json`). +/// +/// Claude Code emits one JSON object per line. We capture the key fields +/// we need and forward the rest as opaque data. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ClaudeStreamEvent { + #[serde(rename = "type")] + pub event_type: String, + + /// For `system` events: the session ID. + #[serde(default)] + pub session_id: Option, + + /// For `assistant` events: the text content blocks. + #[serde(default)] + pub content: Option>, + + /// For `result` events: final status info. + #[serde(default)] + pub result: Option, + + /// For `tool_use`/`tool_result`: the tool name. + #[serde(default)] + pub tool_name: Option, + + /// For `tool_use`: the input parameters. + #[serde(default)] + pub input: Option, + + /// For `tool_result`: the output content. + #[serde(default)] + pub output: Option, + + /// Subtype discriminator (e.g. "text", "tool_use", "tool_result"). + #[serde(default)] + pub subtype: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ContentBlock { + #[serde(rename = "type")] + pub block_type: String, + #[serde(default)] + pub text: Option, + #[serde(default)] + pub name: Option, + #[serde(default)] + pub input: Option, + #[serde(default)] + pub content: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResultInfo { + #[serde(default)] + pub is_error: Option, + #[serde(default)] + pub duration_ms: Option, + #[serde(default)] + pub num_turns: Option, +} + +/// The Claude Code bridge runtime. +pub struct ClaudeBridgeRuntime { + config: ClaudeBridgeConfig, + client: Arc, +} + +impl ClaudeBridgeRuntime { + /// Create a new bridge runtime. + /// + /// Reads `IRONCLAW_WORKER_TOKEN` from the environment for auth. + pub fn new(config: ClaudeBridgeConfig) -> Result { + let client = Arc::new(WorkerHttpClient::from_env( + config.orchestrator_url.clone(), + config.job_id, + )?); + + Ok(Self { config, client }) + } + + /// Run the bridge: fetch job, spawn claude, stream events, handle follow-ups. + pub async fn run(&self) -> Result<(), WorkerError> { + // Fetch the job description from the orchestrator + let job = self.client.get_job().await?; + + tracing::info!( + job_id = %self.config.job_id, + "Starting Claude Code bridge for: {}", + truncate(&job.description, 100) + ); + + // Report that we're running + self.client + .report_status(&crate::worker::api::StatusUpdate { + state: "running".to_string(), + message: Some("Spawning Claude Code".to_string()), + iteration: 0, + }) + .await?; + + // Run the initial Claude session + let session_id = match self.run_claude_session(&job.description, None).await { + Ok(sid) => sid, + Err(e) => { + tracing::error!(job_id = %self.config.job_id, "Claude session failed: {}", e); + self.client + .report_complete(&CompletionReport { + success: false, + message: Some(format!("Claude Code failed: {}", e)), + iterations: 1, + }) + .await?; + return Ok(()); + } + }; + + // Follow-up loop: poll for prompts, resume Claude sessions + let mut iteration = 1u32; + loop { + // Poll for a follow-up prompt (2 second intervals) + match self.poll_for_prompt().await { + Ok(Some(prompt)) => { + if prompt.done { + tracing::info!(job_id = %self.config.job_id, "Orchestrator signaled done"); + break; + } + iteration += 1; + tracing::info!( + job_id = %self.config.job_id, + "Got follow-up prompt, resuming session" + ); + if let Err(e) = self + .run_claude_session(&prompt.content, session_id.as_deref()) + .await + { + tracing::error!( + job_id = %self.config.job_id, + "Follow-up Claude session failed: {}", e + ); + // Don't fail the whole job on a follow-up error, just report it + self.report_event( + "status", + &serde_json::json!({ + "message": format!("Follow-up session failed: {}", e), + }), + ) + .await; + } + } + Ok(None) => { + // No prompt available, wait and poll again + tokio::time::sleep(Duration::from_secs(2)).await; + } + Err(e) => { + tracing::warn!( + job_id = %self.config.job_id, + "Prompt polling error: {}", e + ); + tokio::time::sleep(Duration::from_secs(5)).await; + } + } + } + + self.client + .report_complete(&CompletionReport { + success: true, + message: Some("Claude Code session completed".to_string()), + iterations: iteration, + }) + .await?; + + Ok(()) + } + + /// Spawn a `claude` CLI process and stream its output. + /// + /// Returns the session_id if captured from the `system` init message. + async fn run_claude_session( + &self, + prompt: &str, + resume_session_id: Option<&str>, + ) -> Result, WorkerError> { + let mut cmd = Command::new("claude"); + cmd.arg("-p") + .arg(prompt) + .arg("--output-format") + .arg("stream-json") + .arg("--dangerously-skip-permissions") + .arg("--max-turns") + .arg(self.config.max_turns.to_string()) + .arg("--model") + .arg(&self.config.model); + + if let Some(sid) = resume_session_id { + cmd.arg("--resume").arg(sid); + } + + cmd.current_dir("/workspace") + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); + + let mut child = cmd.spawn().map_err(|e| WorkerError::ExecutionFailed { + reason: format!("failed to spawn claude: {}", e), + })?; + + let stdout = child + .stdout + .take() + .ok_or_else(|| WorkerError::ExecutionFailed { + reason: "failed to capture claude stdout".to_string(), + })?; + + let stderr = child + .stderr + .take() + .ok_or_else(|| WorkerError::ExecutionFailed { + reason: "failed to capture claude stderr".to_string(), + })?; + + // Spawn stderr reader that forwards lines as log events + let client_for_stderr = Arc::clone(&self.client); + let job_id = self.config.job_id; + let stderr_handle = tokio::spawn(async move { + let reader = BufReader::new(stderr); + let mut lines = reader.lines(); + while let Ok(Some(line)) = lines.next_line().await { + tracing::debug!(job_id = %job_id, "claude stderr: {}", line); + let payload = JobEventPayload { + event_type: "status".to_string(), + data: serde_json::json!({ "message": line }), + }; + client_for_stderr.post_event(&payload).await; + } + }); + + // Read stdout NDJSON line by line + let reader = BufReader::new(stdout); + let mut lines = reader.lines(); + let mut session_id: Option = None; + + while let Ok(Some(line)) = lines.next_line().await { + let line = line.trim().to_string(); + if line.is_empty() { + continue; + } + + match serde_json::from_str::(&line) { + Ok(event) => { + // Capture session_id from system init + if event.event_type == "system" { + if let Some(ref sid) = event.session_id { + session_id = Some(sid.clone()); + tracing::info!( + job_id = %self.config.job_id, + session_id = %sid, + "Captured Claude session ID" + ); + } + } + + // Convert to our event payload and forward + let payloads = stream_event_to_payloads(&event); + for payload in payloads { + self.report_event(&payload.event_type, &payload.data).await; + } + } + Err(e) => { + // Not valid JSON, forward as a status message + tracing::debug!( + job_id = %self.config.job_id, + "Non-JSON claude output: {} (parse error: {})", line, e + ); + self.report_event("status", &serde_json::json!({ "message": line })) + .await; + } + } + } + + // Wait for the process to exit + let status = child + .wait() + .await + .map_err(|e| WorkerError::ExecutionFailed { + reason: format!("failed waiting for claude: {}", e), + })?; + + // Wait for stderr reader to finish + let _ = stderr_handle.await; + + if !status.success() { + let code = status.code().unwrap_or(-1); + tracing::warn!( + job_id = %self.config.job_id, + exit_code = code, + "Claude process exited with non-zero status" + ); + + // Report result event + self.report_event( + "result", + &serde_json::json!({ + "status": "error", + "exit_code": code, + "session_id": session_id, + }), + ) + .await; + + return Err(WorkerError::ExecutionFailed { + reason: format!("claude exited with code {}", code), + }); + } + + // Report successful result + self.report_event( + "result", + &serde_json::json!({ + "status": "completed", + "session_id": session_id, + }), + ) + .await; + + Ok(session_id) + } + + /// Post a job event to the orchestrator. + async fn report_event(&self, event_type: &str, data: &serde_json::Value) { + let payload = JobEventPayload { + event_type: event_type.to_string(), + data: data.clone(), + }; + self.client.post_event(&payload).await; + } + + /// Poll the orchestrator for a follow-up prompt. + async fn poll_for_prompt(&self) -> Result, WorkerError> { + self.client.poll_prompt().await + } +} + +/// Convert a Claude stream event into one or more event payloads for the orchestrator. +fn stream_event_to_payloads(event: &ClaudeStreamEvent) -> Vec { + let mut payloads = Vec::new(); + + match event.event_type.as_str() { + "system" => { + payloads.push(JobEventPayload { + event_type: "status".to_string(), + data: serde_json::json!({ + "message": "Claude Code session started", + "session_id": event.session_id, + }), + }); + } + "assistant" => { + // Extract text content and tool_use blocks + if let Some(ref blocks) = event.content { + for block in blocks { + match block.block_type.as_str() { + "text" => { + if let Some(ref text) = block.text { + payloads.push(JobEventPayload { + event_type: "message".to_string(), + data: serde_json::json!({ + "role": "assistant", + "content": text, + }), + }); + } + } + "tool_use" => { + payloads.push(JobEventPayload { + event_type: "tool_use".to_string(), + data: serde_json::json!({ + "tool_name": block.name, + "input": block.input, + }), + }); + } + "tool_result" => { + payloads.push(JobEventPayload { + event_type: "tool_result".to_string(), + data: serde_json::json!({ + "tool_name": block.name.as_deref().unwrap_or("unknown"), + "output": block.content.as_deref().unwrap_or(""), + }), + }); + } + _ => {} + } + } + } + } + "result" => { + let is_error = event + .result + .as_ref() + .and_then(|r| r.is_error) + .unwrap_or(false); + payloads.push(JobEventPayload { + event_type: "result".to_string(), + data: serde_json::json!({ + "status": if is_error { "error" } else { "completed" }, + "session_id": event.session_id, + "duration_ms": event.result.as_ref().and_then(|r| r.duration_ms), + "num_turns": event.result.as_ref().and_then(|r| r.num_turns), + }), + }); + } + _ => { + // Forward unknown event types as status + payloads.push(JobEventPayload { + event_type: "status".to_string(), + data: serde_json::json!({ + "message": format!("Claude event: {}", event.event_type), + "raw_type": event.event_type, + }), + }); + } + } + + payloads +} + +fn truncate(s: &str, max_len: usize) -> &str { + if s.len() <= max_len { s } else { &s[..max_len] } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_system_event() { + let json = r#"{"type":"system","session_id":"abc-123","subtype":"init"}"#; + let event: ClaudeStreamEvent = serde_json::from_str(json).unwrap(); + assert_eq!(event.event_type, "system"); + assert_eq!(event.session_id.as_deref(), Some("abc-123")); + } + + #[test] + fn test_parse_assistant_text_event() { + let json = r#"{"type":"assistant","content":[{"type":"text","text":"Hello world"}]}"#; + let event: ClaudeStreamEvent = serde_json::from_str(json).unwrap(); + assert_eq!(event.event_type, "assistant"); + let blocks = event.content.unwrap(); + assert_eq!(blocks.len(), 1); + assert_eq!(blocks[0].block_type, "text"); + assert_eq!(blocks[0].text.as_deref(), Some("Hello world")); + } + + #[test] + fn test_parse_assistant_tool_use_event() { + let json = r#"{"type":"assistant","content":[{"type":"tool_use","name":"Bash","input":{"command":"ls"}}]}"#; + let event: ClaudeStreamEvent = serde_json::from_str(json).unwrap(); + let blocks = event.content.unwrap(); + assert_eq!(blocks[0].block_type, "tool_use"); + assert_eq!(blocks[0].name.as_deref(), Some("Bash")); + assert!(blocks[0].input.is_some()); + } + + #[test] + fn test_parse_result_event() { + let json = + r#"{"type":"result","result":{"is_error":false,"duration_ms":5000,"num_turns":3}}"#; + let event: ClaudeStreamEvent = serde_json::from_str(json).unwrap(); + assert_eq!(event.event_type, "result"); + let result = event.result.unwrap(); + assert_eq!(result.is_error, Some(false)); + assert_eq!(result.duration_ms, Some(5000)); + assert_eq!(result.num_turns, Some(3)); + } + + #[test] + fn test_parse_result_error_event() { + let json = r#"{"type":"result","result":{"is_error":true}}"#; + let event: ClaudeStreamEvent = serde_json::from_str(json).unwrap(); + let result = event.result.unwrap(); + assert_eq!(result.is_error, Some(true)); + } + + #[test] + fn test_stream_event_to_payloads_system() { + let event = ClaudeStreamEvent { + event_type: "system".to_string(), + session_id: Some("sid-123".to_string()), + content: None, + result: None, + tool_name: None, + input: None, + output: None, + subtype: None, + }; + let payloads = stream_event_to_payloads(&event); + assert_eq!(payloads.len(), 1); + assert_eq!(payloads[0].event_type, "status"); + assert_eq!(payloads[0].data["session_id"], "sid-123"); + } + + #[test] + fn test_stream_event_to_payloads_assistant_text() { + let event = ClaudeStreamEvent { + event_type: "assistant".to_string(), + session_id: None, + content: Some(vec![ContentBlock { + block_type: "text".to_string(), + text: Some("Here's the answer".to_string()), + name: None, + input: None, + content: None, + }]), + result: None, + tool_name: None, + input: None, + output: None, + subtype: None, + }; + let payloads = stream_event_to_payloads(&event); + assert_eq!(payloads.len(), 1); + assert_eq!(payloads[0].event_type, "message"); + assert_eq!(payloads[0].data["role"], "assistant"); + assert_eq!(payloads[0].data["content"], "Here's the answer"); + } + + #[test] + fn test_stream_event_to_payloads_result_success() { + let event = ClaudeStreamEvent { + event_type: "result".to_string(), + session_id: Some("s1".to_string()), + content: None, + result: Some(ResultInfo { + is_error: Some(false), + duration_ms: Some(12000), + num_turns: Some(5), + }), + tool_name: None, + input: None, + output: None, + subtype: None, + }; + let payloads = stream_event_to_payloads(&event); + assert_eq!(payloads.len(), 1); + assert_eq!(payloads[0].event_type, "result"); + assert_eq!(payloads[0].data["status"], "completed"); + } + + #[test] + fn test_stream_event_to_payloads_result_error() { + let event = ClaudeStreamEvent { + event_type: "result".to_string(), + session_id: None, + content: None, + result: Some(ResultInfo { + is_error: Some(true), + duration_ms: None, + num_turns: None, + }), + tool_name: None, + input: None, + output: None, + subtype: None, + }; + let payloads = stream_event_to_payloads(&event); + assert_eq!(payloads[0].data["status"], "error"); + } + + #[test] + fn test_stream_event_to_payloads_unknown_type() { + let event = ClaudeStreamEvent { + event_type: "fancy_new_thing".to_string(), + session_id: None, + content: None, + result: None, + tool_name: None, + input: None, + output: None, + subtype: None, + }; + let payloads = stream_event_to_payloads(&event); + assert_eq!(payloads.len(), 1); + assert_eq!(payloads[0].event_type, "status"); + } + + #[test] + fn test_claude_event_payload_serde() { + let payload = JobEventPayload { + event_type: "message".to_string(), + data: serde_json::json!({ "role": "assistant", "content": "hi" }), + }; + let json = serde_json::to_string(&payload).unwrap(); + let parsed: JobEventPayload = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.event_type, "message"); + assert_eq!(parsed.data["content"], "hi"); + } + + #[test] + fn test_truncate() { + assert_eq!(truncate("hello", 10), "hello"); + assert_eq!(truncate("hello world", 5), "hello"); + assert_eq!(truncate("", 5), ""); + } +} diff --git a/src/worker/mod.rs b/src/worker/mod.rs new file mode 100644 index 00000000..88dd7c56 --- /dev/null +++ b/src/worker/mod.rs @@ -0,0 +1,35 @@ +//! Worker mode for running inside Docker containers. +//! +//! When `ironclaw worker` is invoked, the binary starts in worker mode: +//! - Connects to the orchestrator over HTTP +//! - Uses a `ProxyLlmProvider` that routes LLM calls through the orchestrator +//! - Runs container-safe tools (shell, file ops, patch) +//! - Reports status and completion back to the orchestrator +//! +//! ```text +//! ┌────────────────────────────────┐ +//! │ Docker Container │ +//! │ │ +//! │ ironclaw worker │ +//! │ ├─ ProxyLlmProvider ─────────┼──▶ Orchestrator /worker/{id}/llm/complete +//! │ ├─ SafetyLayer │ +//! │ ├─ ToolRegistry │ +//! │ │ ├─ shell │ +//! │ │ ├─ read_file │ +//! │ │ ├─ write_file │ +//! │ │ ├─ list_dir │ +//! │ │ └─ apply_patch │ +//! │ └─ WorkerHttpClient ─────────┼──▶ Orchestrator /worker/{id}/status +//! │ │ +//! └────────────────────────────────┘ +//! ``` + +pub mod api; +pub mod claude_bridge; +pub mod proxy_llm; +pub mod runtime; + +pub use api::WorkerHttpClient; +pub use claude_bridge::ClaudeBridgeRuntime; +pub use proxy_llm::ProxyLlmProvider; +pub use runtime::WorkerRuntime; diff --git a/src/worker/proxy_llm.rs b/src/worker/proxy_llm.rs new file mode 100644 index 00000000..95dc38af --- /dev/null +++ b/src/worker/proxy_llm.rs @@ -0,0 +1,95 @@ +//! LLM provider that proxies all calls through the orchestrator HTTP API. +//! +//! The worker never has direct access to API keys or session tokens. +//! All LLM requests go through the orchestrator, which holds the real credentials. + +use std::sync::Arc; + +use async_trait::async_trait; +use rust_decimal::Decimal; + +use crate::error::LlmError; +use crate::llm::{ + CompletionRequest, CompletionResponse, LlmProvider, ToolCompletionRequest, + ToolCompletionResponse, +}; +use crate::worker::api::WorkerHttpClient; + +/// An LLM provider that routes all calls through the orchestrator's HTTP API. +/// +/// No API keys or secrets are needed in the container. The orchestrator +/// handles authentication and billing. +pub struct ProxyLlmProvider { + client: Arc, + model_name: String, +} + +impl ProxyLlmProvider { + pub fn new(client: Arc, model_name: String) -> Self { + Self { client, model_name } + } +} + +#[async_trait] +impl LlmProvider for ProxyLlmProvider { + fn model_name(&self) -> &str { + &self.model_name + } + + fn cost_per_token(&self) -> (Decimal, Decimal) { + // Cost tracking happens on the orchestrator side + (Decimal::ZERO, Decimal::ZERO) + } + + async fn complete(&self, request: CompletionRequest) -> Result { + self.client + .llm_complete(&request) + .await + .map_err(|e| LlmError::RequestFailed { + provider: "proxy".to_string(), + reason: e.to_string(), + }) + } + + async fn complete_with_tools( + &self, + request: ToolCompletionRequest, + ) -> Result { + self.client + .llm_complete_with_tools(&request) + .await + .map_err(|e| LlmError::RequestFailed { + provider: "proxy".to_string(), + reason: e.to_string(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_proxy_model_name() { + let client = Arc::new(WorkerHttpClient::new( + "http://localhost:50051".to_string(), + uuid::Uuid::nil(), + "test".to_string(), + )); + let provider = ProxyLlmProvider::new(client, "test-model".to_string()); + assert_eq!(provider.model_name(), "test-model"); + } + + #[test] + fn test_proxy_cost_is_zero() { + let client = Arc::new(WorkerHttpClient::new( + "http://localhost:50051".to_string(), + uuid::Uuid::nil(), + "test".to_string(), + )); + let provider = ProxyLlmProvider::new(client, "test-model".to_string()); + let (input, output) = provider.cost_per_token(); + assert_eq!(input, Decimal::ZERO); + assert_eq!(output, Decimal::ZERO); + } +} diff --git a/src/worker/runtime.rs b/src/worker/runtime.rs new file mode 100644 index 00000000..2d83d55f --- /dev/null +++ b/src/worker/runtime.rs @@ -0,0 +1,491 @@ +//! Worker runtime: the main execution loop inside a container. +//! +//! Reuses the existing `Reasoning` and `SafetyLayer` infrastructure but +//! connects to the orchestrator for LLM calls instead of calling APIs directly. +//! Streams real-time events (message, tool_use, tool_result, result) through +//! the orchestrator's job event pipeline for UI visibility. + +use std::sync::Arc; +use std::time::Duration; + +use uuid::Uuid; + +use crate::config::SafetyConfig; +use crate::context::JobContext; +use crate::error::WorkerError; +use crate::llm::{ + ChatMessage, LlmProvider, Reasoning, ReasoningContext, RespondResult, ToolSelection, +}; +use crate::safety::SafetyLayer; +use crate::tools::ToolRegistry; +use crate::worker::api::{CompletionReport, JobEventPayload, StatusUpdate, WorkerHttpClient}; +use crate::worker::proxy_llm::ProxyLlmProvider; + +/// Configuration for the worker runtime. +pub struct WorkerConfig { + pub job_id: Uuid, + pub orchestrator_url: String, + pub max_iterations: u32, + pub timeout: Duration, +} + +impl Default for WorkerConfig { + fn default() -> Self { + Self { + job_id: Uuid::nil(), + orchestrator_url: String::new(), + max_iterations: 50, + timeout: Duration::from_secs(600), + } + } +} + +/// The worker runtime runs inside a Docker container. +/// +/// It connects to the orchestrator over HTTP, fetches its job description, +/// then runs a tool execution loop until the job is complete. Events are +/// streamed to the orchestrator so the UI can show real-time progress. +pub struct WorkerRuntime { + config: WorkerConfig, + client: Arc, + llm: Arc, + safety: Arc, + tools: Arc, +} + +impl WorkerRuntime { + /// Create a new worker runtime. + /// + /// Reads `IRONCLAW_WORKER_TOKEN` from the environment for auth. + pub fn new(config: WorkerConfig) -> Result { + let client = Arc::new(WorkerHttpClient::from_env( + config.orchestrator_url.clone(), + config.job_id, + )?); + + let llm: Arc = Arc::new(ProxyLlmProvider::new( + Arc::clone(&client), + "proxied".to_string(), + )); + + let safety = Arc::new(SafetyLayer::new(&SafetyConfig { + max_output_length: 100_000, + injection_check_enabled: true, + })); + + let tools = Arc::new(ToolRegistry::new()); + // Register only container-safe tools + tools.register_container_tools(); + + Ok(Self { + config, + client, + llm, + safety, + tools, + }) + } + + /// Run the worker until the job is complete or an error occurs. + pub async fn run(self) -> Result<(), WorkerError> { + tracing::info!("Worker starting for job {}", self.config.job_id); + + // Fetch job description from orchestrator + let job = self.client.get_job().await?; + + tracing::info!( + "Received job: {} - {}", + job.title, + truncate(&job.description, 100) + ); + + // Report that we're starting + self.client + .report_status(&StatusUpdate { + state: "in_progress".to_string(), + message: Some("Worker started, beginning execution".to_string()), + iteration: 0, + }) + .await?; + + // Create reasoning engine + let reasoning = Reasoning::new(self.llm.clone(), self.safety.clone()); + + // Build initial context + let mut reason_ctx = ReasoningContext::new().with_job(&job.description); + + reason_ctx.messages.push(ChatMessage::system(format!( + r#"You are an autonomous agent running inside a Docker container. + +Job: {} +Description: {} + +You have tools for shell commands, file operations, and code editing. +Work independently to complete this job. Report when done."#, + job.title, job.description + ))); + + // Run with timeout + let result = tokio::time::timeout(self.config.timeout, async { + self.execution_loop(&reasoning, &mut reason_ctx).await + }) + .await; + + match result { + Ok(Ok(output)) => { + tracing::info!("Worker completed job {} successfully", self.config.job_id); + self.post_event( + "result", + serde_json::json!({ + "success": true, + "message": truncate(&output, 2000), + }), + ) + .await; + self.client + .report_complete(&CompletionReport { + success: true, + message: Some(output), + iterations: 0, + }) + .await?; + } + Ok(Err(e)) => { + tracing::error!("Worker failed for job {}: {}", self.config.job_id, e); + self.post_event( + "result", + serde_json::json!({ + "success": false, + "message": format!("Execution failed: {}", e), + }), + ) + .await; + self.client + .report_complete(&CompletionReport { + success: false, + message: Some(format!("Execution failed: {}", e)), + iterations: 0, + }) + .await?; + } + Err(_) => { + tracing::warn!("Worker timed out for job {}", self.config.job_id); + self.post_event( + "result", + serde_json::json!({ + "success": false, + "message": "Execution timed out", + }), + ) + .await; + self.client + .report_complete(&CompletionReport { + success: false, + message: Some("Execution timed out".to_string()), + iterations: 0, + }) + .await?; + } + } + + Ok(()) + } + + async fn execution_loop( + &self, + reasoning: &Reasoning, + reason_ctx: &mut ReasoningContext, + ) -> Result { + let max_iterations = self.config.max_iterations; + let mut last_output = String::new(); + + // Load tool definitions + reason_ctx.available_tools = self.tools.tool_definitions().await; + + for iteration in 1..=max_iterations { + // Report progress + if iteration % 5 == 1 { + let _ = self + .client + .report_status(&StatusUpdate { + state: "in_progress".to_string(), + message: Some(format!("Iteration {}", iteration)), + iteration, + }) + .await; + } + + // Poll for follow-up prompts from the user + self.poll_and_inject_prompt(reason_ctx).await; + + // Refresh tools (in case WASM tools were built) + reason_ctx.available_tools = self.tools.tool_definitions().await; + + // Ask the LLM what to do next + let selections = reasoning.select_tools(reason_ctx).await.map_err(|e| { + WorkerError::ExecutionFailed { + reason: format!("tool selection failed: {}", e), + } + })?; + + if selections.is_empty() { + // No tools selected, try direct response + let respond_result = + reasoning + .respond_with_tools(reason_ctx) + .await + .map_err(|e| WorkerError::ExecutionFailed { + reason: format!("respond_with_tools failed: {}", e), + })?; + + match respond_result { + RespondResult::Text(response) => { + self.post_event( + "message", + serde_json::json!({ + "role": "assistant", + "content": truncate(&response, 2000), + }), + ) + .await; + + let response_lower = response.to_lowercase(); + if response_lower.contains("complete") + || response_lower.contains("finished") + || response_lower.contains("done") + { + if last_output.is_empty() { + last_output = response.clone(); + } + return Ok(last_output); + } + reason_ctx.messages.push(ChatMessage::assistant(&response)); + } + RespondResult::ToolCalls { + tool_calls, + content, + } => { + if let Some(ref text) = content { + self.post_event( + "message", + serde_json::json!({ + "role": "assistant", + "content": truncate(text, 2000), + }), + ) + .await; + } + + // Add assistant message with tool_calls (OpenAI protocol) + reason_ctx + .messages + .push(ChatMessage::assistant_with_tool_calls( + content, + tool_calls.clone(), + )); + + for tc in tool_calls { + self.post_event( + "tool_use", + serde_json::json!({ + "tool_name": tc.name, + "input": truncate(&tc.arguments.to_string(), 500), + }), + ) + .await; + + let result = self.execute_tool(&tc.name, &tc.arguments).await; + + self.post_event( + "tool_result", + serde_json::json!({ + "tool_name": tc.name, + "output": match &result { + Ok(output) => truncate(output, 2000), + Err(e) => format!("Error: {}", truncate(e, 500)), + }, + "success": result.is_ok(), + }), + ) + .await; + + if let Ok(ref output) = result { + last_output = output.clone(); + } + let selection = ToolSelection { + tool_name: tc.name.clone(), + parameters: tc.arguments.clone(), + reasoning: String::new(), + alternatives: vec![], + }; + self.process_result(reason_ctx, &selection, result); + } + } + } + } else { + // Execute selected tools + for selection in &selections { + self.post_event( + "tool_use", + serde_json::json!({ + "tool_name": selection.tool_name, + "input": truncate(&selection.parameters.to_string(), 500), + }), + ) + .await; + + let result = self + .execute_tool(&selection.tool_name, &selection.parameters) + .await; + + self.post_event( + "tool_result", + serde_json::json!({ + "tool_name": selection.tool_name, + "output": match &result { + Ok(output) => truncate(output, 2000), + Err(e) => format!("Error: {}", truncate(e, 500)), + }, + "success": result.is_ok(), + }), + ) + .await; + + if let Ok(ref output) = result { + last_output = output.clone(); + } + + let completed = self.process_result(reason_ctx, selection, result); + if completed { + return Ok(last_output); + } + } + } + + // Brief pause between iterations + tokio::time::sleep(Duration::from_millis(100)).await; + } + + Err(WorkerError::ExecutionFailed { + reason: format!("max iterations ({}) exceeded", max_iterations), + }) + } + + async fn execute_tool( + &self, + tool_name: &str, + params: &serde_json::Value, + ) -> Result { + let tool = match self.tools.get(tool_name).await { + Some(t) => t, + None => return Err(format!("tool '{}' not found", tool_name)), + }; + + let ctx = JobContext::default(); + + // Validate params + let validation = self.safety.validator().validate_tool_params(params); + if !validation.is_valid { + let details = validation + .errors + .iter() + .map(|e| format!("{}: {}", e.field, e.message)) + .collect::>() + .join("; "); + return Err(format!("invalid parameters: {}", details)); + } + + // Execute with per-tool timeout + let tool_timeout = tool.execution_timeout(); + let result = tokio::time::timeout(tool_timeout, tool.execute(params.clone(), &ctx)).await; + + match result { + Ok(Ok(output)) => serde_json::to_string_pretty(&output.result) + .map_err(|e| format!("serialization error: {}", e)), + Ok(Err(e)) => Err(e.to_string()), + Err(_) => Err("tool execution timed out".to_string()), + } + } + + /// Process a tool result into the reasoning context. Returns true if the job is complete. + fn process_result( + &self, + reason_ctx: &mut ReasoningContext, + selection: &ToolSelection, + result: Result, + ) -> bool { + match result { + Ok(output) => { + let sanitized = self + .safety + .sanitize_tool_output(&selection.tool_name, &output); + let wrapped = self.safety.wrap_for_llm( + &selection.tool_name, + &sanitized.content, + sanitized.was_modified, + ); + + reason_ctx.messages.push(ChatMessage::tool_result( + "tool_call_id", + &selection.tool_name, + wrapped, + )); + + output.contains("TASK_COMPLETE") || output.contains("JOB_DONE") + } + Err(e) => { + tracing::warn!("Tool {} failed: {}", selection.tool_name, e); + reason_ctx.messages.push(ChatMessage::tool_result( + "tool_call_id", + &selection.tool_name, + format!("Error: {}", e), + )); + false + } + } + } + + /// Post a job event to the orchestrator (fire-and-forget). + async fn post_event(&self, event_type: &str, data: serde_json::Value) { + self.client + .post_event(&JobEventPayload { + event_type: event_type.to_string(), + data, + }) + .await; + } + + /// Poll the orchestrator for a follow-up prompt. If one is available, + /// inject it as a user message into the reasoning context. + async fn poll_and_inject_prompt(&self, reason_ctx: &mut ReasoningContext) { + match self.client.poll_prompt().await { + Ok(Some(prompt)) => { + tracing::info!( + "Received follow-up prompt: {}", + truncate(&prompt.content, 100) + ); + self.post_event( + "message", + serde_json::json!({ + "role": "user", + "content": truncate(&prompt.content, 2000), + }), + ) + .await; + reason_ctx.messages.push(ChatMessage::user(&prompt.content)); + } + Ok(None) => {} + Err(e) => { + tracing::debug!("Failed to poll for prompt: {}", e); + } + } + } +} + +fn truncate(s: &str, max: usize) -> String { + if s.len() <= max { + s.to_string() + } else { + format!("{}...", &s[..max]) + } +} diff --git a/src/workspace/mod.rs b/src/workspace/mod.rs index 24f77217..548b5718 100644 --- a/src/workspace/mod.rs +++ b/src/workspace/mod.rs @@ -442,6 +442,97 @@ impl Workspace { Ok(()) } + // ==================== Seeding ==================== + + /// Seed any missing core identity files in the workspace. + /// + /// Called on every boot. Only creates files that don't already exist, + /// so user edits are never overwritten. Returns the number of files + /// created (0 if all core files already existed). + pub async fn seed_if_empty(&self) -> Result { + let seed_files: &[(&str, &str)] = &[ + ( + paths::README, + "# Workspace\n\n\ + This is your agent's persistent memory. Files here are indexed for search\n\ + and used to build the agent's context.\n\n\ + ## Structure\n\n\ + - `MEMORY.md` - Long-term notes and facts worth remembering\n\ + - `IDENTITY.md` - Agent name, nature, personality\n\ + - `SOUL.md` - Core values and principles\n\ + - `AGENTS.md` - Behavior instructions for the agent\n\ + - `USER.md` - Information about you (the user)\n\ + - `HEARTBEAT.md` - Periodic background task checklist\n\ + - `daily/` - Automatic daily session logs\n\ + - `context/` - Additional context documents\n\n\ + Edit these files to shape how your agent thinks and acts.", + ), + ( + paths::MEMORY, + "# Memory\n\n\ + Long-term notes, decisions, and facts worth remembering.\n\ + The agent appends here during conversations.", + ), + ( + paths::IDENTITY, + "# Identity\n\n\ + Name: IronClaw\n\ + Nature: A secure personal AI assistant\n\n\ + Edit this file to give your agent a custom name and personality.", + ), + ( + paths::SOUL, + "# Core Values\n\n\ + - Protect user privacy and data security above all else\n\ + - Be honest about limitations and uncertainty\n\ + - Prefer action over lengthy deliberation\n\ + - Ask for clarification rather than guessing on important decisions\n\ + - Learn from mistakes and remember lessons", + ), + ( + paths::AGENTS, + "# Agent Instructions\n\n\ + You are a personal AI assistant with access to tools and persistent memory.\n\n\ + ## Guidelines\n\n\ + - Always search memory before answering questions about prior conversations\n\ + - Write important facts and decisions to memory for future reference\n\ + - Use the daily log for session-level notes\n\ + - Be concise but thorough", + ), + ( + paths::USER, + "# User Context\n\n\ + The agent will fill this in as it learns about you.\n\ + You can also edit this directly to provide context upfront.", + ), + (paths::HEARTBEAT, HEARTBEAT_SEED), + ]; + + let mut count = 0; + for (path, content) in seed_files { + // Skip files that already exist (never overwrite user edits) + match self.read(path).await { + Ok(_) => continue, + Err(WorkspaceError::DocumentNotFound { .. }) => {} + Err(e) => { + tracing::warn!("Failed to check {}: {}", path, e); + continue; + } + } + + if let Err(e) = self.write(path, content).await { + tracing::warn!("Failed to seed {}: {}", path, e); + } else { + count += 1; + } + } + + if count > 0 { + tracing::info!("Seeded {} workspace files", count); + } + Ok(count) + } + /// Generate embeddings for chunks that don't have them yet. /// /// This is useful for backfilling embeddings after enabling the provider. diff --git a/tests/ws_gateway_integration.rs b/tests/ws_gateway_integration.rs index a93bc49d..80bcc3c9 100644 --- a/tests/ws_gateway_integration.rs +++ b/tests/ws_gateway_integration.rs @@ -41,11 +41,13 @@ async fn start_test_server() -> ( msg_tx: tokio::sync::RwLock::new(Some(agent_tx)), sse: SseManager::new(), workspace: None, - context_manager: None, session_manager: None, log_broadcaster: None, extension_manager: None, tool_registry: None, + store: None, + job_manager: None, + prompt_queue: None, user_id: "test-user".to_string(), shutdown_tx: tokio::sync::RwLock::new(None), ws_tracker: Some(Arc::new(WsConnectionTracker::new())), @@ -162,6 +164,7 @@ async fn test_ws_thinking_event() { state.sse.broadcast(SseEvent::Thinking { message: "analyzing...".to_string(), + thread_id: None, }); let text = recv_text(&mut ws).await; @@ -286,13 +289,16 @@ async fn test_ws_multiple_events_in_sequence() { // Broadcast multiple events rapidly state.sse.broadcast(SseEvent::Thinking { message: "step 1".to_string(), + thread_id: None, }); state.sse.broadcast(SseEvent::ToolStarted { name: "shell".to_string(), + thread_id: None, }); state.sse.broadcast(SseEvent::ToolCompleted { name: "shell".to_string(), success: true, + thread_id: None, }); state.sse.broadcast(SseEvent::Response { content: "done".to_string(), diff --git a/tools-src/gmail/src/api.rs b/tools-src/gmail/src/api.rs index a84c1f81..f1bc9764 100644 --- a/tools-src/gmail/src/api.rs +++ b/tools-src/gmail/src/api.rs @@ -26,7 +26,7 @@ fn api_call(method: &str, path: &str, body: Option<&str>) -> Result= 300 { let body_text = String::from_utf8_lossy(&response.body); @@ -389,7 +389,7 @@ const BASE64URL_CHARS: &[u8; 64] = /// Base64url-encode bytes (no padding, URL-safe alphabet). fn base64url_encode(input: &[u8]) -> String { - let mut result = String::with_capacity((input.len() + 2) / 3 * 4); + let mut result = String::with_capacity(input.len().div_ceil(3) * 4); for chunk in input.chunks(3) { let b0 = chunk[0] as u32; diff --git a/tools-src/google-calendar/src/api.rs b/tools-src/google-calendar/src/api.rs index ead7f7ea..c826a7fd 100644 --- a/tools-src/google-calendar/src/api.rs +++ b/tools-src/google-calendar/src/api.rs @@ -26,7 +26,7 @@ fn api_call(method: &str, path: &str, body: Option<&str>) -> Result= 300 { let body_text = String::from_utf8_lossy(&response.body); @@ -144,64 +144,68 @@ pub fn get_event(calendar_id: &str, event_id: &str) -> Result { + pub calendar_id: &'a str, + pub summary: &'a str, + pub description: Option<&'a str>, + pub location: Option<&'a str>, + pub start_datetime: Option<&'a str>, + pub end_datetime: Option<&'a str>, + pub start_date: Option<&'a str>, + pub end_date: Option<&'a str>, + pub timezone: Option<&'a str>, + pub attendees: &'a [String], +} + /// Create a new event. -pub fn create_event( - calendar_id: &str, - summary: &str, - description: Option<&str>, - location: Option<&str>, - start_datetime: Option<&str>, - end_datetime: Option<&str>, - start_date: Option<&str>, - end_date: Option<&str>, - timezone: Option<&str>, - attendees: &[String], -) -> Result { +pub fn create_event(p: &CreateEventParams<'_>) -> Result { let mut event = serde_json::json!({ - "summary": summary, + "summary": p.summary, }); - if let Some(desc) = description { + if let Some(desc) = p.description { event["description"] = serde_json::Value::String(desc.to_string()); } - if let Some(loc) = location { + if let Some(loc) = p.location { event["location"] = serde_json::Value::String(loc.to_string()); } // Build start/end, preferring datetime over date - if let Some(dt) = start_datetime { + if let Some(dt) = p.start_datetime { let mut start = serde_json::json!({ "dateTime": dt }); - if let Some(tz) = timezone { + if let Some(tz) = p.timezone { start["timeZone"] = serde_json::Value::String(tz.to_string()); } event["start"] = start; - } else if let Some(d) = start_date { + } else if let Some(d) = p.start_date { event["start"] = serde_json::json!({ "date": d }); } else { return Err("Either start_datetime or start_date is required".to_string()); } - if let Some(dt) = end_datetime { + if let Some(dt) = p.end_datetime { let mut end = serde_json::json!({ "dateTime": dt }); - if let Some(tz) = timezone { + if let Some(tz) = p.timezone { end["timeZone"] = serde_json::Value::String(tz.to_string()); } event["end"] = end; - } else if let Some(d) = end_date { + } else if let Some(d) = p.end_date { event["end"] = serde_json::json!({ "date": d }); } else { return Err("Either end_datetime or end_date is required".to_string()); } - if !attendees.is_empty() { - event["attendees"] = serde_json::json!(attendees + if !p.attendees.is_empty() { + event["attendees"] = serde_json::json!(p + .attendees .iter() .map(|e| serde_json::json!({ "email": e })) .collect::>()); } let body = serde_json::to_string(&event).map_err(|e| e.to_string())?; - let path = format!("calendars/{}/events", url_encode(calendar_id)); + let path = format!("calendars/{}/events", url_encode(p.calendar_id)); let response = api_call("POST", &path, Some(&body))?; let parsed: serde_json::Value = @@ -212,53 +216,56 @@ pub fn create_event( }) } +/// Parameters for updating a calendar event. +pub struct UpdateEventParams<'a> { + pub calendar_id: &'a str, + pub event_id: &'a str, + pub summary: Option<&'a str>, + pub description: Option<&'a str>, + pub location: Option<&'a str>, + pub start_datetime: Option<&'a str>, + pub end_datetime: Option<&'a str>, + pub start_date: Option<&'a str>, + pub end_date: Option<&'a str>, + pub timezone: Option<&'a str>, + pub attendees: Option<&'a [String]>, +} + /// Update an existing event (PATCH for partial updates). -pub fn update_event( - calendar_id: &str, - event_id: &str, - summary: Option<&str>, - description: Option<&str>, - location: Option<&str>, - start_datetime: Option<&str>, - end_datetime: Option<&str>, - start_date: Option<&str>, - end_date: Option<&str>, - timezone: Option<&str>, - attendees: Option<&[String]>, -) -> Result { +pub fn update_event(p: &UpdateEventParams<'_>) -> Result { let mut patch = serde_json::json!({}); - if let Some(s) = summary { + if let Some(s) = p.summary { patch["summary"] = serde_json::Value::String(s.to_string()); } - if let Some(d) = description { + if let Some(d) = p.description { patch["description"] = serde_json::Value::String(d.to_string()); } - if let Some(l) = location { + if let Some(l) = p.location { patch["location"] = serde_json::Value::String(l.to_string()); } - if let Some(dt) = start_datetime { + if let Some(dt) = p.start_datetime { let mut start = serde_json::json!({ "dateTime": dt }); - if let Some(tz) = timezone { + if let Some(tz) = p.timezone { start["timeZone"] = serde_json::Value::String(tz.to_string()); } patch["start"] = start; - } else if let Some(d) = start_date { + } else if let Some(d) = p.start_date { patch["start"] = serde_json::json!({ "date": d }); } - if let Some(dt) = end_datetime { + if let Some(dt) = p.end_datetime { let mut end = serde_json::json!({ "dateTime": dt }); - if let Some(tz) = timezone { + if let Some(tz) = p.timezone { end["timeZone"] = serde_json::Value::String(tz.to_string()); } patch["end"] = end; - } else if let Some(d) = end_date { + } else if let Some(d) = p.end_date { patch["end"] = serde_json::json!({ "date": d }); } - if let Some(att) = attendees { + if let Some(att) = p.attendees { patch["attendees"] = serde_json::json!(att .iter() .map(|e| serde_json::json!({ "email": e })) @@ -268,8 +275,8 @@ pub fn update_event( let body = serde_json::to_string(&patch).map_err(|e| e.to_string())?; let path = format!( "calendars/{}/events/{}", - url_encode(calendar_id), - url_encode(event_id) + url_encode(p.calendar_id), + url_encode(p.event_id) ); let response = api_call("PATCH", &path, Some(&body))?; diff --git a/tools-src/google-calendar/src/lib.rs b/tools-src/google-calendar/src/lib.rs index bd13506c..70f62d63 100644 --- a/tools-src/google-calendar/src/lib.rs +++ b/tools-src/google-calendar/src/lib.rs @@ -279,18 +279,18 @@ fn execute_inner(params: &str) -> Result { timezone, attendees, } => { - let result = api::create_event( - &calendar_id, - &summary, - description.as_deref(), - location.as_deref(), - start_datetime.as_deref(), - end_datetime.as_deref(), - start_date.as_deref(), - end_date.as_deref(), - timezone.as_deref(), - &attendees, - )?; + let result = api::create_event(&api::CreateEventParams { + calendar_id: &calendar_id, + summary: &summary, + description: description.as_deref(), + location: location.as_deref(), + start_datetime: start_datetime.as_deref(), + end_datetime: end_datetime.as_deref(), + start_date: start_date.as_deref(), + end_date: end_date.as_deref(), + timezone: timezone.as_deref(), + attendees: &attendees, + })?; serde_json::to_string(&result).map_err(|e| e.to_string())? } @@ -307,19 +307,19 @@ fn execute_inner(params: &str) -> Result { timezone, attendees, } => { - let result = api::update_event( - &calendar_id, - &event_id, - summary.as_deref(), - description.as_deref(), - location.as_deref(), - start_datetime.as_deref(), - end_datetime.as_deref(), - start_date.as_deref(), - end_date.as_deref(), - timezone.as_deref(), - attendees.as_deref(), - )?; + let result = api::update_event(&api::UpdateEventParams { + calendar_id: &calendar_id, + event_id: &event_id, + summary: summary.as_deref(), + description: description.as_deref(), + location: location.as_deref(), + start_datetime: start_datetime.as_deref(), + end_datetime: end_datetime.as_deref(), + start_date: start_date.as_deref(), + end_date: end_date.as_deref(), + timezone: timezone.as_deref(), + attendees: attendees.as_deref(), + })?; serde_json::to_string(&result).map_err(|e| e.to_string())? } diff --git a/tools-src/google-docs/src/api.rs b/tools-src/google-docs/src/api.rs index 2e90c744..185ae979 100644 --- a/tools-src/google-docs/src/api.rs +++ b/tools-src/google-docs/src/api.rs @@ -30,7 +30,7 @@ fn api_call(method: &str, path: &str, body: Option<&str>) -> Result= 300 { let body_text = String::from_utf8_lossy(&response.body); diff --git a/tools-src/google-drive/src/api.rs b/tools-src/google-drive/src/api.rs index d8e2d3f3..5b812032 100644 --- a/tools-src/google-drive/src/api.rs +++ b/tools-src/google-drive/src/api.rs @@ -32,7 +32,7 @@ fn api_call(method: &str, path: &str, body: Option<&str>) -> Result= 300 { let body_text = String::from_utf8_lossy(&response.body); @@ -56,7 +56,7 @@ fn api_call_raw(method: &str, url: &str) -> Result, String> { &format!("Drive API raw: {} {}", method, url), ); - let response = host::http_request(method, url, "{}", None)?; + let response = host::http_request(method, url, "{}", None, None)?; if response.status < 200 || response.status >= 300 { let body_text = String::from_utf8_lossy(&response.body); @@ -262,7 +262,7 @@ pub fn upload_file( "Drive API: POST upload/files (multipart)", ); - let response = host::http_request("POST", &url, &headers, Some(body.as_bytes()))?; + let response = host::http_request("POST", &url, &headers, Some(body.as_bytes()), None)?; if response.status < 200 || response.status >= 300 { let body_text = String::from_utf8_lossy(&response.body); diff --git a/tools-src/google-sheets/src/api.rs b/tools-src/google-sheets/src/api.rs index 934388b2..4d7af5ca 100644 --- a/tools-src/google-sheets/src/api.rs +++ b/tools-src/google-sheets/src/api.rs @@ -30,7 +30,7 @@ fn api_call(method: &str, path: &str, body: Option<&str>) -> Result= 300 { let body_text = String::from_utf8_lossy(&response.body); diff --git a/tools-src/google-slides/src/api.rs b/tools-src/google-slides/src/api.rs index 4f85adf6..f0bd8bc4 100644 --- a/tools-src/google-slides/src/api.rs +++ b/tools-src/google-slides/src/api.rs @@ -30,7 +30,7 @@ fn api_call(method: &str, path: &str, body: Option<&str>) -> Result= 300 { let body_text = String::from_utf8_lossy(&response.body); diff --git a/tools-src/slack/src/api.rs b/tools-src/slack/src/api.rs index cae7506b..56eabf54 100644 --- a/tools-src/slack/src/api.rs +++ b/tools-src/slack/src/api.rs @@ -45,7 +45,7 @@ fn slack_api_call(method: &str, endpoint: &str, body: Option<&str>) -> Result= 300 { return Err(format!( diff --git a/tools-src/telegram/src/transport.rs b/tools-src/telegram/src/transport.rs index 48624ef9..54cfe575 100644 --- a/tools-src/telegram/src/transport.rs +++ b/tools-src/telegram/src/transport.rs @@ -89,7 +89,7 @@ pub fn post_encrypted( /// HTTP POST with raw binary body via the WASM host's http-request capability. fn http_post_binary(url: &str, body: &[u8]) -> Result, String> { - let resp = host::http_request("POST", url, "{}", Some(body))?; + let resp = host::http_request("POST", url, "{}", Some(body), None)?; if resp.status < 200 || resp.status >= 300 { let body_text = String::from_utf8_lossy(&resp.body); diff --git a/wit/channel.wit b/wit/channel.wit index 99ea8160..48fba6be 100644 --- a/wit/channel.wit +++ b/wit/channel.wit @@ -90,11 +90,17 @@ interface channel-host { /// - Credentials are injected by the host; WASM never sees them /// - Response is scanned for leaked secrets before returning /// - Rate-limited per channel + /// + /// The optional timeout-ms parameter controls the HTTP client timeout + /// in milliseconds. Defaults to 30000 (30s) when not provided. Use a + /// longer timeout for long-polling requests (e.g., Telegram getUpdates). + /// Capped at the channel's callback_timeout to prevent hangs. http-request: func( method: string, url: string, headers-json: string, - body: option> + body: option>, + timeout-ms: option, ) -> result; /// Check if a secret exists (if capability granted). diff --git a/wit/tool.wit b/wit/tool.wit index a6a45a3d..743a0121 100644 --- a/wit/tool.wit +++ b/wit/tool.wit @@ -67,11 +67,16 @@ interface host { /// - Network error /// - Timeout /// - Secret leak detected in response + /// + /// The optional timeout-ms parameter controls the HTTP client timeout + /// in milliseconds. Defaults to 30000 (30s) when not provided. + /// Capped at the callback timeout to prevent hangs. http-request: func( method: string, url: string, headers-json: string, - body: option> + body: option>, + timeout-ms: option, ) -> result; // ==================== Tool Invocation Capability ==================== From 45f547c7110864eb53fe0ae86e0ee6a459944943 Mon Sep 17 00:00:00 2001 From: bkutasi <47147160+bkutasi@users.noreply.github.com> Date: Thu, 12 Feb 2026 01:15:41 +0100 Subject: [PATCH 04/33] fix: resolve runtime panic in Linux keychain integration (#32) * fix: resolve runtime panic in Linux keychain integration - Convert Linux keychain functions from sync (rt.block_on) to async - Remove nested runtime panic when called from async context - Make keychain API consistent across platforms (macOS, Linux, fallback) - Propagate async through config loading and CLI commands Fixes panic on Linux during 'ironclaw onboard' at Step 2 (Security). * fix: await async Config::from_env in test_heartbeat example --- examples/test_heartbeat.rs | 2 +- src/cli/config.rs | 2 +- src/cli/mcp.rs | 4 +- src/cli/status.rs | 2 +- src/cli/tool.rs | 2 +- src/config.rs | 41 +++--- src/main.rs | 10 +- src/secrets/keychain.rs | 256 +++++++++++++++++-------------------- src/setup/wizard.rs | 12 +- 9 files changed, 158 insertions(+), 173 deletions(-) diff --git a/examples/test_heartbeat.rs b/examples/test_heartbeat.rs index c62d28c3..ca158f5a 100644 --- a/examples/test_heartbeat.rs +++ b/examples/test_heartbeat.rs @@ -28,7 +28,7 @@ async fn main() -> anyhow::Result<()> { println!("=== Heartbeat Integration Test ===\n"); // 1. Load config - let config = Config::from_env().map_err(|e| anyhow::anyhow!("Config: {}", e))?; + let config = Config::from_env().await.map_err(|e| anyhow::anyhow!("Config: {}", e))?; println!("[1/6] Config loaded"); println!(" heartbeat.enabled = {}", config.heartbeat.enabled); println!( diff --git a/src/cli/config.rs b/src/cli/config.rs index d248fcaf..7754c925 100644 --- a/src/cli/config.rs +++ b/src/cli/config.rs @@ -71,7 +71,7 @@ pub async fn run_config_command(cmd: ConfigCommand) -> anyhow::Result<()> { /// Bootstrap a DB connection for config commands. async fn connect_store() -> anyhow::Result { - let config = crate::config::Config::from_env().map_err(|e| anyhow::anyhow!("{}", e))?; + let config = crate::config::Config::from_env().await.map_err(|e| anyhow::anyhow!("{}", e))?; let store = crate::history::Store::new(&config.database).await?; store.run_migrations().await?; Ok(store) diff --git a/src/cli/mcp.rs b/src/cli/mcp.rs index 311aedc1..4600e302 100644 --- a/src/cli/mcp.rs +++ b/src/cli/mcp.rs @@ -467,7 +467,7 @@ const DEFAULT_USER_ID: &str = "default"; /// Try to connect to the database store for DB-backed config. async fn connect_store() -> Option { - let config = Config::from_env().ok()?; + let config = Config::from_env().await.ok()?; let store = Store::new(&config.database).await.ok()?; store.run_migrations().await.ok()?; Some(store) @@ -496,7 +496,7 @@ async fn save_servers( /// Initialize and return the secrets store. async fn get_secrets_store() -> anyhow::Result> { - let config = Config::from_env()?; + let config = Config::from_env().await?; let master_key = config.secrets.master_key().ok_or_else(|| { anyhow::anyhow!( diff --git a/src/cli/status.rs b/src/cli/status.rs index 53940472..0db48c90 100644 --- a/src/cli/status.rs +++ b/src/cli/status.rs @@ -47,7 +47,7 @@ pub async fn run_status_command() -> anyhow::Result<()> { print!(" Secrets: "); let secrets_configured = settings.secrets_master_key_source != crate::settings::KeySource::None || std::env::var("SECRETS_MASTER_KEY").is_ok() - || crate::secrets::keychain::has_master_key(); + || crate::secrets::keychain::has_master_key().await; if secrets_configured { println!("configured ({:?})", settings.secrets_master_key_source); } else { diff --git a/src/cli/tool.rs b/src/cli/tool.rs index 9a86bfd7..316ffa2c 100644 --- a/src/cli/tool.rs +++ b/src/cli/tool.rs @@ -715,7 +715,7 @@ async fn auth_tool(name: String, dir: Option, user_id: String) -> anyho println!(); // Initialize secrets store - let config = Config::from_env()?; + let config = Config::from_env().await?; let master_key = config.secrets.master_key().ok_or_else(|| { anyhow::anyhow!( "SECRETS_MASTER_KEY not set. Run 'ironclaw onboard' first or set it in .env" diff --git a/src/config.rs b/src/config.rs index fd60caa4..a1f93a8d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -53,7 +53,7 @@ impl Config { } }; - Self::build(bootstrap, &db_settings) + Self::build(bootstrap, &db_settings).await } /// Load configuration from environment variables only (no database). @@ -61,15 +61,15 @@ impl Config { /// Used during early startup before the database is connected, /// and by CLI commands that don't have DB access. /// Falls back to legacy `settings.json` on disk if present. - pub fn from_env() -> Result { + pub async fn from_env() -> Result { let _ = dotenvy::dotenv(); let bootstrap = crate::bootstrap::BootstrapConfig::load(); let settings = Settings::load(); - Self::build(&bootstrap, &settings) + Self::build(&bootstrap, &settings).await } /// Build config from bootstrap + settings (shared by from_env and from_db). - fn build( + async fn build( bootstrap: &crate::bootstrap::BootstrapConfig, settings: &Settings, ) -> Result { @@ -82,7 +82,7 @@ impl Config { agent: AgentConfig::resolve(settings)?, safety: SafetyConfig::resolve()?, wasm: WasmConfig::resolve()?, - secrets: SecretsConfig::resolve(bootstrap)?, + secrets: SecretsConfig::resolve(bootstrap).await?, builder: BuilderModeConfig::resolve()?, heartbeat: HeartbeatConfig::resolve(settings)?, routines: RoutineConfig::resolve()?, @@ -604,27 +604,32 @@ impl std::fmt::Debug for SecretsConfig { } impl SecretsConfig { - fn resolve(bootstrap: &crate::bootstrap::BootstrapConfig) -> Result { + async fn resolve(bootstrap: &crate::bootstrap::BootstrapConfig) -> Result { use crate::settings::KeySource; let (master_key, source) = if let Some(env_key) = optional_env("SECRETS_MASTER_KEY")? { (Some(SecretString::from(env_key)), KeySource::Env) } else { match bootstrap.secrets_master_key_source { - KeySource::Keychain => match crate::secrets::keychain::get_master_key() { - Ok(key_bytes) => { - let key_hex: String = - key_bytes.iter().map(|b| format!("{:02x}", b)).collect(); - (Some(SecretString::from(key_hex)), KeySource::Keychain) - } - Err(_) => { - tracing::warn!( - "Secrets configured for keychain but key not found. \ + KeySource::Keychain => { + // Try to load from OS keychain (async on Linux) + match crate::secrets::keychain::get_master_key().await { + Ok(key_bytes) => { + let key_hex: String = + key_bytes.iter().map(|b| format!("{:02x}", b)).collect(); + (Some(SecretString::from(key_hex)), KeySource::Keychain) + } + Err(_) => { + // Keychain configured but key not found + // This might happen if keychain was cleared + tracing::warn!( + "Secrets configured for keychain but key not found. \ Run 'ironclaw onboard' to reconfigure." - ); - (None, KeySource::None) + ); + (None, KeySource::None) + } } - }, + } KeySource::Env => { tracing::warn!( "Secrets configured for env var but SECRETS_MASTER_KEY not set." diff --git a/src/main.rs b/src/main.rs index 0247e163..de53c90a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -84,7 +84,7 @@ async fn main() -> anyhow::Result<()> { // Memory commands need database (and optionally embeddings) let _ = dotenvy::dotenv(); - let config = Config::from_env().map_err(|e| anyhow::anyhow!("{}", e))?; + let config = Config::from_env().await.map_err(|e| anyhow::anyhow!("{}", e))?; let store = ironclaw::history::Store::new(&config.database).await?; store.run_migrations().await?; @@ -240,7 +240,7 @@ async fn main() -> anyhow::Result<()> { // Enhanced first-run detection if !cli.no_onboard { - if let Some(reason) = check_onboard_needed() { + if let Some(reason) = check_onboard_needed().await { println!("Onboarding needed: {}", reason); println!(); let mut wizard = SetupWizard::new(); @@ -252,7 +252,7 @@ async fn main() -> anyhow::Result<()> { let bootstrap = ironclaw::bootstrap::BootstrapConfig::load(); // Load initial config from env + disk (before DB is available) - let mut config = match Config::from_env() { + let mut config = match Config::from_env().await { Ok(c) => c, Err(ironclaw::error::ConfigError::MissingRequired { key, hint }) => { eprintln!("Configuration error: Missing required setting '{}'", key); @@ -1043,7 +1043,7 @@ async fn main() -> anyhow::Result<()> { /// Check if onboarding is needed and return the reason. /// /// Returns `Some(reason)` if onboarding should be triggered, `None` otherwise. -fn check_onboard_needed() -> Option<&'static str> { +async fn check_onboard_needed() -> Option<&'static str> { let bootstrap = ironclaw::bootstrap::BootstrapConfig::load(); // Database not configured (and not in env) @@ -1054,7 +1054,7 @@ fn check_onboard_needed() -> Option<&'static str> { // Secrets not configured (and not in env) if bootstrap.secrets_master_key_source == ironclaw::settings::KeySource::None && std::env::var("SECRETS_MASTER_KEY").is_err() - && !ironclaw::secrets::keychain::has_master_key() + && !ironclaw::secrets::keychain::has_master_key().await { // Only require secrets setup if user hasn't explicitly disabled it // For now, we don't require it for first run diff --git a/src/secrets/keychain.rs b/src/secrets/keychain.rs index e81efcd0..0ab810ba 100644 --- a/src/secrets/keychain.rs +++ b/src/secrets/keychain.rs @@ -52,7 +52,7 @@ mod platform { use super::*; /// Store the master key in the macOS Keychain. - pub fn store_master_key(key: &[u8]) -> Result<(), SecretError> { + pub async fn store_master_key(key: &[u8]) -> Result<(), SecretError> { // Convert to hex for storage (keychain prefers strings) let key_hex: String = key.iter().map(|b| format!("{:02x}", b)).collect(); @@ -61,7 +61,7 @@ mod platform { } /// Retrieve the master key from the macOS Keychain. - pub fn get_master_key() -> Result, SecretError> { + pub async fn get_master_key() -> Result, SecretError> { let password = get_generic_password(SERVICE_NAME, MASTER_KEY_ACCOUNT).map_err(|e| { SecretError::KeychainError(format!("Failed to get from keychain: {}", e)) })?; @@ -74,14 +74,14 @@ mod platform { } /// Delete the master key from the macOS Keychain. - pub fn delete_master_key() -> Result<(), SecretError> { + pub async fn delete_master_key() -> Result<(), SecretError> { delete_generic_password(SERVICE_NAME, MASTER_KEY_ACCOUNT).map_err(|e| { SecretError::KeychainError(format!("Failed to delete from keychain: {}", e)) }) } /// Check if a master key exists in the keychain. - pub fn has_master_key() -> bool { + pub async fn has_master_key() -> bool { get_generic_password(SERVICE_NAME, MASTER_KEY_ACCOUNT).is_ok() } } @@ -97,163 +97,141 @@ mod platform { use super::*; /// Store the master key in the Linux secret service (GNOME Keyring, KWallet). - pub fn store_master_key(key: &[u8]) -> Result<(), SecretError> { - let rt = tokio::runtime::Handle::try_current() - .map_err(|_| SecretError::KeychainError("No tokio runtime available".to_string()))?; - - rt.block_on(async { - let ss = SecretService::connect(EncryptionType::Dh) - .await - .map_err(|e| { - SecretError::KeychainError(format!( - "Failed to connect to secret service: {}", - e - )) - })?; - - let collection = ss.get_default_collection().await.map_err(|e| { - SecretError::KeychainError(format!("Failed to get collection: {}", e)) + pub async fn store_master_key(key: &[u8]) -> Result<(), SecretError> { + let ss = SecretService::connect(EncryptionType::Dh) + .await + .map_err(|e| { + SecretError::KeychainError(format!( + "Failed to connect to secret service: {}", + e + )) })?; - // Unlock if needed - if collection.is_locked().await.unwrap_or(true) { - collection.unlock().await.map_err(|e| { - SecretError::KeychainError(format!("Failed to unlock collection: {}", e)) - })?; - } + let collection = ss.get_default_collection().await.map_err(|e| { + SecretError::KeychainError(format!("Failed to get collection: {}", e)) + })?; - // Convert to hex for storage - let key_hex: String = key.iter().map(|b| format!("{:02x}", b)).collect(); + // Unlock if needed + if collection.is_locked().await.unwrap_or(true) { + collection.unlock().await.map_err(|e| { + SecretError::KeychainError(format!("Failed to unlock collection: {}", e)) + })?; + } - collection - .create_item( - &format!("{} master key", SERVICE_NAME), - [("service", SERVICE_NAME), ("account", MASTER_KEY_ACCOUNT)] - .into_iter() - .collect(), - key_hex.as_bytes(), - true, // Replace if exists - "text/plain", - ) - .await - .map_err(|e| { - SecretError::KeychainError(format!("Failed to create secret: {}", e)) - })?; + // Convert to hex for storage + let key_hex: String = key.iter().map(|b| format!("{:02x}", b)).collect(); - Ok(()) - }) + collection + .create_item( + &format!("{} master key", SERVICE_NAME), + [("service", SERVICE_NAME), ("account", MASTER_KEY_ACCOUNT)] + .into_iter() + .collect(), + key_hex.as_bytes(), + true, // Replace if exists + "text/plain", + ) + .await + .map_err(|e| { + SecretError::KeychainError(format!("Failed to create secret: {}", e)) + })?; + + Ok(()) } /// Retrieve the master key from the Linux secret service. - pub fn get_master_key() -> Result, SecretError> { - let rt = tokio::runtime::Handle::try_current() - .map_err(|_| SecretError::KeychainError("No tokio runtime available".to_string()))?; + pub async fn get_master_key() -> Result, SecretError> { + let ss = SecretService::connect(EncryptionType::Dh) + .await + .map_err(|e| { + SecretError::KeychainError(format!( + "Failed to connect to secret service: {}", + e + )) + })?; - rt.block_on(async { - let ss = SecretService::connect(EncryptionType::Dh) + let items = ss + .search_items( + [("service", SERVICE_NAME), ("account", MASTER_KEY_ACCOUNT)] + .into_iter() + .collect(), + ) + .await + .map_err(|e| SecretError::KeychainError(format!("Failed to search: {}", e)))?; + + let item = items + .unlocked + .first() + .or(items.locked.first()) + .ok_or_else(|| SecretError::KeychainError("Master key not found".to_string()))?; + + // Unlock if needed + if item.is_locked().await.unwrap_or(true) { + item.unlock() .await - .map_err(|e| { - SecretError::KeychainError(format!( - "Failed to connect to secret service: {}", - e - )) - })?; + .map_err(|e| SecretError::KeychainError(format!("Failed to unlock: {}", e)))?; + } - let items = ss - .search_items( - [("service", SERVICE_NAME), ("account", MASTER_KEY_ACCOUNT)] - .into_iter() - .collect(), - ) - .await - .map_err(|e| SecretError::KeychainError(format!("Failed to search: {}", e)))?; + let secret = item + .get_secret() + .await + .map_err(|e| SecretError::KeychainError(format!("Failed to get secret: {}", e)))?; - let item = items - .unlocked - .first() - .or(items.locked.first()) - .ok_or_else(|| SecretError::KeychainError("Master key not found".to_string()))?; + let hex_str = String::from_utf8(secret) + .map_err(|_| SecretError::KeychainError("Invalid UTF-8 in secret".to_string()))?; - // Unlock if needed - if item.is_locked().await.unwrap_or(true) { - item.unlock() - .await - .map_err(|e| SecretError::KeychainError(format!("Failed to unlock: {}", e)))?; - } - - let secret = item - .get_secret() - .await - .map_err(|e| SecretError::KeychainError(format!("Failed to get secret: {}", e)))?; - - let hex_str = String::from_utf8(secret) - .map_err(|_| SecretError::KeychainError("Invalid UTF-8 in secret".to_string()))?; - - hex_to_bytes(&hex_str) - }) + hex_to_bytes(&hex_str) } /// Delete the master key from the Linux secret service. - pub fn delete_master_key() -> Result<(), SecretError> { - let rt = tokio::runtime::Handle::try_current() - .map_err(|_| SecretError::KeychainError("No tokio runtime available".to_string()))?; + pub async fn delete_master_key() -> Result<(), SecretError> { + let ss = SecretService::connect(EncryptionType::Dh) + .await + .map_err(|e| { + SecretError::KeychainError(format!( + "Failed to connect to secret service: {}", + e + )) + })?; - rt.block_on(async { - let ss = SecretService::connect(EncryptionType::Dh) + let items = ss + .search_items( + [("service", SERVICE_NAME), ("account", MASTER_KEY_ACCOUNT)] + .into_iter() + .collect(), + ) + .await + .map_err(|e| SecretError::KeychainError(format!("Failed to search: {}", e)))?; + + for item in items.unlocked.iter().chain(items.locked.iter()) { + item.delete() .await - .map_err(|e| { - SecretError::KeychainError(format!( - "Failed to connect to secret service: {}", - e - )) - })?; + .map_err(|e| SecretError::KeychainError(format!("Failed to delete: {}", e)))?; + } - let items = ss - .search_items( - [("service", SERVICE_NAME), ("account", MASTER_KEY_ACCOUNT)] - .into_iter() - .collect(), - ) - .await - .map_err(|e| SecretError::KeychainError(format!("Failed to search: {}", e)))?; - - for item in items.unlocked.iter().chain(items.locked.iter()) { - item.delete() - .await - .map_err(|e| SecretError::KeychainError(format!("Failed to delete: {}", e)))?; - } - - Ok(()) - }) + Ok(()) } /// Check if a master key exists in the secret service. - pub fn has_master_key() -> bool { - let rt = match tokio::runtime::Handle::try_current() { - Ok(rt) => rt, + pub async fn has_master_key() -> bool { + let ss = match SecretService::connect(EncryptionType::Dh).await { + Ok(ss) => ss, Err(_) => return false, }; - rt.block_on(async { - let ss = match SecretService::connect(EncryptionType::Dh).await { - Ok(ss) => ss, - Err(_) => return false, - }; + let items = match ss + .search_items( + [("service", SERVICE_NAME), ("account", MASTER_KEY_ACCOUNT)] + .into_iter() + .collect(), + ) + .await + { + Ok(items) => items, + Err(_) => return false, + }; - let items = match ss - .search_items( - [("service", SERVICE_NAME), ("account", MASTER_KEY_ACCOUNT)] - .into_iter() - .collect(), - ) - .await - { - Ok(items) => items, - Err(_) => return false, - }; - - !items.unlocked.is_empty() || !items.locked.is_empty() - }) + !items.unlocked.is_empty() || !items.locked.is_empty() } } @@ -265,25 +243,25 @@ mod platform { mod platform { use super::*; - pub fn store_master_key(_key: &[u8]) -> Result<(), SecretError> { + pub async fn store_master_key(_key: &[u8]) -> Result<(), SecretError> { Err(SecretError::KeychainError( "Keychain not supported on this platform. Use SECRETS_MASTER_KEY env var.".to_string(), )) } - pub fn get_master_key() -> Result, SecretError> { + pub async fn get_master_key() -> Result, SecretError> { Err(SecretError::KeychainError( "Keychain not supported on this platform. Use SECRETS_MASTER_KEY env var.".to_string(), )) } - pub fn delete_master_key() -> Result<(), SecretError> { + pub async fn delete_master_key() -> Result<(), SecretError> { Err(SecretError::KeychainError( "Keychain not supported on this platform".to_string(), )) } - pub fn has_master_key() -> bool { + pub async fn has_master_key() -> bool { false } } diff --git a/src/setup/wizard.rs b/src/setup/wizard.rs index adf76d25..db447369 100644 --- a/src/setup/wizard.rs +++ b/src/setup/wizard.rs @@ -266,7 +266,7 @@ impl SetupWizard { async fn step_security(&mut self) -> Result<(), SetupError> { // Check current configuration let env_key_exists = std::env::var("SECRETS_MASTER_KEY").is_ok(); - let keychain_key_exists = crate::secrets::keychain::has_master_key(); + let keychain_key_exists = crate::secrets::keychain::has_master_key().await; if env_key_exists { print_info("Secrets master key found in SECRETS_MASTER_KEY environment variable."); @@ -304,9 +304,11 @@ impl SetupWizard { print_info("Generating master key..."); let key = crate::secrets::keychain::generate_master_key(); - crate::secrets::keychain::store_master_key(&key).map_err(|e| { - SetupError::Config(format!("Failed to store in keychain: {}", e)) - })?; + crate::secrets::keychain::store_master_key(&key) + .await + .map_err(|e| { + SetupError::Config(format!("Failed to store in keychain: {}", e)) + })?; // Also create crypto instance let key_hex: String = key.iter().map(|b| format!("{:02x}", b)).collect(); @@ -550,7 +552,7 @@ impl SetupWizard { // Try to load master key from keychain or env let key = if let Ok(env_key) = std::env::var("SECRETS_MASTER_KEY") { env_key - } else if let Ok(keychain_key) = crate::secrets::keychain::get_master_key() { + } else if let Ok(keychain_key) = crate::secrets::keychain::get_master_key().await { keychain_key.iter().map(|b| format!("{:02x}", b)).collect() } else { return Err(SetupError::Config( From bb228f63159b14e38ab9e1cc16431871c406243c Mon Sep 17 00:00:00 2001 From: Zaki Manian Date: Wed, 11 Feb 2026 16:37:51 -0800 Subject: [PATCH 05/33] feat: Add multi-provider LLM support via rig-core adapter (#36) Add support for OpenAI, Anthropic, Ollama, and OpenAI-compatible endpoints alongside the existing NEAR AI backend. Users can now bring their own API keys via environment variables (LLM_BACKEND, OPENAI_API_KEY, ANTHROPIC_API_KEY, etc.) while NEAR AI remains the default. Co-authored-by: Claude Opus 4.6 --- Cargo.lock | 338 +++++++++++++++++++++++++++++- Cargo.toml | 3 + src/agent/agent_loop.rs | 2 +- src/config.rs | 204 ++++++++++++++++-- src/llm/costs.rs | 124 +++++++++++ src/llm/mod.rs | 140 ++++++++++++- src/llm/rig_adapter.rs | 451 ++++++++++++++++++++++++++++++++++++++++ src/main.rs | 8 +- src/setup/wizard.rs | 5 + 9 files changed, 1239 insertions(+), 36 deletions(-) create mode 100644 src/llm/costs.rs create mode 100644 src/llm/rig_adapter.rs diff --git a/Cargo.lock b/Cargo.lock index 0fe4dc8b..c9006498 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -182,6 +182,12 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "as-any" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0f477b951e452a0b6b4a10b53ccd569042d1d01729b519e02074a9c0958a063" + [[package]] name = "async-broadcast" version = "0.7.2" @@ -307,6 +313,28 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "async-task" version = "4.7.1" @@ -820,6 +848,16 @@ dependencies = [ "crossterm 0.29.0", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -1479,6 +1517,17 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "eventsource-stream" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74fef4569247a5f429d9156b9d0a2599914385dd189c539334c625d8099d90ab" +dependencies = [ + "futures-core", + "nom", + "pin-project-lite", +] + [[package]] name = "fallible-iterator" version = "0.2.0" @@ -1537,6 +1586,21 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1647,6 +1711,12 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +[[package]] +name = "futures-timer" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" + [[package]] name = "futures-util" version = "0.3.31" @@ -1745,6 +1815,12 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + [[package]] name = "h2" version = "0.4.13" @@ -1944,6 +2020,22 @@ dependencies = [ "webpki-roots", ] +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -1962,9 +2054,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -2219,12 +2313,13 @@ dependencies = [ "refinery", "regex", "reqwest", + "rig-core", "rust_decimal", "rust_decimal_macros", "rustyline", "secrecy", "secret-service", - "security-framework", + "security-framework 3.5.1", "serde", "serde_json", "sha2", @@ -2545,6 +2640,32 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nanoid" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ffa00dec017b5b1a8b7cf5e2c008bfda1aa7e0697ac1508b491fdf2622fb4d8" +dependencies = [ + "rand 0.8.5", +] + +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe 0.1.6", + "openssl-sys", + "schannel", + "security-framework 2.11.1", + "security-framework-sys", + "tempfile", +] + [[package]] name = "nibble_vec" version = "0.1.0" @@ -2737,18 +2858,71 @@ dependencies = [ "pathdiff", ] +[[package]] +name = "openssl" +version = "0.10.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + [[package]] name = "openssl-probe" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "openssl-sys" +version = "0.9.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "option-ext" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-float" +version = "5.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4779c6901a562440c3786d08192c6fbda7c1c2060edd10006b05ee35d10f2d" +dependencies = [ + "num-traits", +] + [[package]] name = "ordered-stream" version = "0.2.0" @@ -2860,6 +3034,26 @@ dependencies = [ "siphasher", ] +[[package]] +name = "pin-project" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -3392,16 +3586,22 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64 0.22.1", "bytes", + "encoding_rs", "futures-core", "futures-util", + "h2", "http", "http-body", "http-body-util", "hyper", "hyper-rustls", + "hyper-tls", "hyper-util", "js-sys", "log", + "mime", + "mime_guess", + "native-tls", "percent-encoding", "pin-project-lite", "quinn", @@ -3412,6 +3612,7 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", + "tokio-native-tls", "tokio-rustls", "tokio-util", "tower", @@ -3425,6 +3626,38 @@ dependencies = [ "webpki-roots", ] +[[package]] +name = "rig-core" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f7a3f0c7c00eaced15a68ee16e1bd6bb709ff598d11b9aedac8b628217dc09" +dependencies = [ + "as-any", + "async-stream", + "base64 0.22.1", + "bytes", + "eventsource-stream", + "fastrand", + "futures", + "futures-timer", + "glob", + "http", + "mime", + "mime_guess", + "nanoid", + "ordered-float", + "pin-project-lite", + "reqwest", + "schemars 1.2.1", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tracing", + "tracing-futures", + "url", +] + [[package]] name = "ring" version = "0.17.14" @@ -3572,10 +3805,10 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" dependencies = [ - "openssl-probe", + "openssl-probe 0.2.1", "rustls-pki-types", "schannel", - "security-framework", + "security-framework 3.5.1", ] [[package]] @@ -3692,10 +3925,23 @@ checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" dependencies = [ "dyn-clone", "ref-cast", + "schemars_derive", "serde", "serde_json", ] +[[package]] +name = "schemars_derive" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.114", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -3737,6 +3983,19 @@ dependencies = [ "zbus", ] +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.10.0", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + [[package]] name = "security-framework" version = "3.5.1" @@ -3744,7 +4003,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" dependencies = [ "bitflags 2.10.0", - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -3800,6 +4059,17 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "serde_json" version = "1.0.149" @@ -4113,6 +4383,27 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.10.0", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "system-interface" version = "0.27.3" @@ -4351,6 +4642,16 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + [[package]] name = "tokio-postgres" version = "0.7.16" @@ -4613,6 +4914,18 @@ dependencies = [ "valuable", ] +[[package]] +name = "tracing-futures" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97d095ae15e245a057c8e8451bab9b3ee1e1f68e9ba2b4fbc18d0ac5237835f2" +dependencies = [ + "futures", + "futures-task", + "pin-project", + "tracing", +] + [[package]] name = "tracing-log" version = "0.2.0" @@ -4840,6 +5153,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" @@ -5514,6 +5833,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + [[package]] name = "windows-result" version = "0.4.1" diff --git a/Cargo.toml b/Cargo.toml index 26a449fd..c8e3a4f4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -93,6 +93,9 @@ sha2 = "0.10" blake3 = "1" rand = "0.8" +# Multi-provider LLM support +rig-core = "0.30" + # Docker sandbox bollard = "0.18" diff --git a/src/agent/agent_loop.rs b/src/agent/agent_loop.rs index 462535e9..0a1d2b09 100644 --- a/src/agent/agent_loop.rs +++ b/src/agent/agent_loop.rs @@ -1049,7 +1049,7 @@ impl Agent { iteration += 1; if iteration > MAX_TOOL_ITERATIONS { return Err(crate::error::LlmError::InvalidResponse { - provider: "nearai".to_string(), + provider: "agent".to_string(), reason: format!("Exceeded maximum tool iterations ({})", MAX_TOOL_ITERATIONS), } .into()); diff --git a/src/config.rs b/src/config.rs index a1f93a8d..20ef580e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -172,10 +172,102 @@ impl DatabaseConfig { } } -/// LLM provider configuration (NEAR AI only). +/// Which LLM backend to use. +/// +/// Defaults to `NearAi` to keep IronClaw close to the NEAR ecosystem. +/// Users can override with `LLM_BACKEND` env var to use their own API keys. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum LlmBackend { + /// NEAR AI proxy (default) -- session or API key auth + #[default] + NearAi, + /// Direct OpenAI API + OpenAi, + /// Direct Anthropic API + Anthropic, + /// Local Ollama instance + Ollama, + /// Any OpenAI-compatible endpoint (e.g. vLLM, LiteLLM, Together) + OpenAiCompatible, +} + +impl std::str::FromStr for LlmBackend { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "nearai" | "near_ai" | "near" => Ok(Self::NearAi), + "openai" | "open_ai" => Ok(Self::OpenAi), + "anthropic" | "claude" => Ok(Self::Anthropic), + "ollama" => Ok(Self::Ollama), + "openai_compatible" | "openai-compatible" | "compatible" => Ok(Self::OpenAiCompatible), + _ => Err(format!( + "invalid LLM backend '{}', expected one of: nearai, openai, anthropic, ollama, openai_compatible", + s + )), + } + } +} + +impl std::fmt::Display for LlmBackend { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NearAi => write!(f, "nearai"), + Self::OpenAi => write!(f, "openai"), + Self::Anthropic => write!(f, "anthropic"), + Self::Ollama => write!(f, "ollama"), + Self::OpenAiCompatible => write!(f, "openai_compatible"), + } + } +} + +/// Configuration for direct OpenAI API access. +#[derive(Debug, Clone)] +pub struct OpenAiDirectConfig { + pub api_key: SecretString, + pub model: String, +} + +/// Configuration for direct Anthropic API access. +#[derive(Debug, Clone)] +pub struct AnthropicDirectConfig { + pub api_key: SecretString, + pub model: String, +} + +/// Configuration for local Ollama. +#[derive(Debug, Clone)] +pub struct OllamaConfig { + pub base_url: String, + pub model: String, +} + +/// Configuration for any OpenAI-compatible endpoint. +#[derive(Debug, Clone)] +pub struct OpenAiCompatibleConfig { + pub base_url: String, + pub api_key: Option, + pub model: String, +} + +/// LLM provider configuration. +/// +/// NEAR AI remains the default backend. Users can switch to other providers +/// by setting `LLM_BACKEND` (e.g. `openai`, `anthropic`, `ollama`). #[derive(Debug, Clone)] pub struct LlmConfig { + /// Which backend to use (default: NearAi) + pub backend: LlmBackend, + /// NEAR AI config (always populated for NEAR AI embeddings, etc.) pub nearai: NearAiConfig, + /// Direct OpenAI config (populated when backend=openai) + pub openai: Option, + /// Direct Anthropic config (populated when backend=anthropic) + pub anthropic: Option, + /// Ollama config (populated when backend=ollama) + pub ollama: Option, + /// OpenAI-compatible config (populated when backend=openai_compatible) + pub openai_compatible: Option, } /// API mode for NEAR AI. @@ -224,37 +316,109 @@ pub struct NearAiConfig { impl LlmConfig { fn resolve(settings: &Settings) -> Result { - let api_key = optional_env("NEARAI_API_KEY")?.map(SecretString::from); + // Determine backend (default: NearAi) + let backend: LlmBackend = if let Some(b) = optional_env("LLM_BACKEND")? { + b.parse().map_err(|e| ConfigError::InvalidValue { + key: "LLM_BACKEND".to_string(), + message: e, + })? + } else { + LlmBackend::NearAi + }; + + // Always resolve NEAR AI config (used as fallback and for embeddings) + let nearai_api_key = optional_env("NEARAI_API_KEY")?.map(SecretString::from); let api_mode = if let Some(mode_str) = optional_env("NEARAI_API_MODE")? { mode_str.parse().map_err(|e| ConfigError::InvalidValue { key: "NEARAI_API_MODE".to_string(), message: e, })? - } else if api_key.is_some() { + } else if nearai_api_key.is_some() { NearAiApiMode::ChatCompletions } else { NearAiApiMode::Responses }; - Ok(Self { - nearai: NearAiConfig { - model: optional_env("NEARAI_MODEL")? - .or_else(|| settings.selected_model.clone()) - .unwrap_or_else(|| { - "fireworks::accounts/fireworks/models/llama4-maverick-instruct-basic" - .to_string() - }), - base_url: optional_env("NEARAI_BASE_URL")? - .unwrap_or_else(|| "https://cloud-api.near.ai".to_string()), - auth_base_url: optional_env("NEARAI_AUTH_URL")? - .unwrap_or_else(|| "https://private.near.ai".to_string()), - session_path: optional_env("NEARAI_SESSION_PATH")? - .map(PathBuf::from) - .unwrap_or_else(default_session_path), - api_mode, + let nearai = NearAiConfig { + model: optional_env("NEARAI_MODEL")? + .or_else(|| settings.selected_model.clone()) + .unwrap_or_else(|| { + "fireworks::accounts/fireworks/models/llama4-maverick-instruct-basic" + .to_string() + }), + base_url: optional_env("NEARAI_BASE_URL")? + .unwrap_or_else(|| "https://cloud-api.near.ai".to_string()), + auth_base_url: optional_env("NEARAI_AUTH_URL")? + .unwrap_or_else(|| "https://private.near.ai".to_string()), + session_path: optional_env("NEARAI_SESSION_PATH")? + .map(PathBuf::from) + .unwrap_or_else(default_session_path), + api_mode, + api_key: nearai_api_key, + }; + + // Resolve provider-specific configs based on backend + let openai = if backend == LlmBackend::OpenAi { + let api_key = optional_env("OPENAI_API_KEY")? + .map(SecretString::from) + .ok_or_else(|| ConfigError::MissingRequired { + key: "OPENAI_API_KEY".to_string(), + hint: "Set OPENAI_API_KEY when LLM_BACKEND=openai".to_string(), + })?; + let model = optional_env("OPENAI_MODEL")?.unwrap_or_else(|| "gpt-4o".to_string()); + Some(OpenAiDirectConfig { api_key, model }) + } else { + None + }; + + let anthropic = if backend == LlmBackend::Anthropic { + let api_key = optional_env("ANTHROPIC_API_KEY")? + .map(SecretString::from) + .ok_or_else(|| ConfigError::MissingRequired { + key: "ANTHROPIC_API_KEY".to_string(), + hint: "Set ANTHROPIC_API_KEY when LLM_BACKEND=anthropic".to_string(), + })?; + let model = optional_env("ANTHROPIC_MODEL")? + .unwrap_or_else(|| "claude-sonnet-4-20250514".to_string()); + Some(AnthropicDirectConfig { api_key, model }) + } else { + None + }; + + let ollama = if backend == LlmBackend::Ollama { + let base_url = optional_env("OLLAMA_BASE_URL")? + .unwrap_or_else(|| "http://localhost:11434".to_string()); + let model = optional_env("OLLAMA_MODEL")?.unwrap_or_else(|| "llama3".to_string()); + Some(OllamaConfig { base_url, model }) + } else { + None + }; + + let openai_compatible = if backend == LlmBackend::OpenAiCompatible { + let base_url = + optional_env("LLM_BASE_URL")?.ok_or_else(|| ConfigError::MissingRequired { + key: "LLM_BASE_URL".to_string(), + hint: "Set LLM_BASE_URL when LLM_BACKEND=openai_compatible".to_string(), + })?; + let api_key = optional_env("LLM_API_KEY")?.map(SecretString::from); + let model = optional_env("LLM_MODEL")?.unwrap_or_else(|| "default".to_string()); + Some(OpenAiCompatibleConfig { + base_url, api_key, - }, + model, + }) + } else { + None + }; + + Ok(Self { + backend, + nearai, + openai, + anthropic, + ollama, + openai_compatible, }) } } diff --git a/src/llm/costs.rs b/src/llm/costs.rs new file mode 100644 index 00000000..89c5b529 --- /dev/null +++ b/src/llm/costs.rs @@ -0,0 +1,124 @@ +//! Per-model cost lookup table for multi-provider LLM support. +//! +//! Returns (input_cost_per_token, output_cost_per_token) as Decimal pairs. +//! Ollama and other local models return zero cost. + +use rust_decimal::Decimal; +use rust_decimal_macros::dec; + +/// Look up known per-token costs for a model by its identifier. +/// +/// Returns `Some((input_cost, output_cost))` for known models, `None` otherwise. +pub fn model_cost(model_id: &str) -> Option<(Decimal, Decimal)> { + // Normalize: strip provider prefixes (e.g., "openai/gpt-4o" -> "gpt-4o") + let id = model_id + .rsplit_once('/') + .map(|(_, name)| name) + .unwrap_or(model_id); + + match id { + // OpenAI models -- prices per token (USD) + "gpt-4o" | "gpt-4o-2024-11-20" | "gpt-4o-2024-08-06" => { + Some((dec!(0.0000025), dec!(0.00001))) + } + "gpt-4o-mini" | "gpt-4o-mini-2024-07-18" => Some((dec!(0.00000015), dec!(0.0000006))), + "gpt-4-turbo" | "gpt-4-turbo-2024-04-09" => Some((dec!(0.00001), dec!(0.00003))), + "gpt-4" | "gpt-4-0613" => Some((dec!(0.00003), dec!(0.00006))), + "gpt-3.5-turbo" | "gpt-3.5-turbo-0125" => Some((dec!(0.0000005), dec!(0.0000015))), + "o1" | "o1-2024-12-17" => Some((dec!(0.000015), dec!(0.00006))), + "o1-mini" | "o1-mini-2024-09-12" => Some((dec!(0.000003), dec!(0.000012))), + "o3-mini" | "o3-mini-2025-01-31" => Some((dec!(0.0000011), dec!(0.0000044))), + + // Anthropic models + "claude-3-5-sonnet-20241022" | "claude-3-5-sonnet-latest" | "claude-sonnet-4-20250514" => { + Some((dec!(0.000003), dec!(0.000015))) + } + "claude-3-5-haiku-20241022" | "claude-3-5-haiku-latest" => { + Some((dec!(0.0000008), dec!(0.000004))) + } + "claude-3-opus-20240229" | "claude-3-opus-latest" | "claude-opus-4-20250514" => { + Some((dec!(0.000015), dec!(0.000075))) + } + "claude-3-haiku-20240307" => Some((dec!(0.00000025), dec!(0.00000125))), + + // Ollama / local models -- free + _ if is_local_model(id) => Some((Decimal::ZERO, Decimal::ZERO)), + + _ => None, + } +} + +/// Default cost for unknown models. +pub fn default_cost() -> (Decimal, Decimal) { + // Conservative estimate: roughly GPT-4o pricing + (dec!(0.0000025), dec!(0.00001)) +} + +/// Heuristic to detect local/self-hosted models (Ollama, llama.cpp, etc.). +fn is_local_model(model_id: &str) -> bool { + let lower = model_id.to_lowercase(); + lower.starts_with("llama") + || lower.starts_with("mistral") + || lower.starts_with("mixtral") + || lower.starts_with("phi") + || lower.starts_with("gemma") + || lower.starts_with("qwen") + || lower.starts_with("codellama") + || lower.starts_with("deepseek") + || lower.starts_with("starcoder") + || lower.starts_with("vicuna") + || lower.starts_with("yi") + || lower.contains(":latest") + || lower.contains(":instruct") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_known_model_costs() { + let (input, output) = model_cost("gpt-4o").unwrap(); + assert!(input > Decimal::ZERO); + assert!(output > input); + } + + #[test] + fn test_claude_costs() { + let (input, output) = model_cost("claude-3-5-sonnet-20241022").unwrap(); + assert!(input > Decimal::ZERO); + assert!(output > input); + } + + #[test] + fn test_local_model_free() { + let (input, output) = model_cost("llama3").unwrap(); + assert_eq!(input, Decimal::ZERO); + assert_eq!(output, Decimal::ZERO); + } + + #[test] + fn test_ollama_tagged_model_free() { + let (input, output) = model_cost("mistral:latest").unwrap(); + assert_eq!(input, Decimal::ZERO); + assert_eq!(output, Decimal::ZERO); + } + + #[test] + fn test_unknown_model_returns_none() { + assert!(model_cost("some-totally-unknown-model-xyz").is_none()); + } + + #[test] + fn test_default_cost_nonzero() { + let (input, output) = default_cost(); + assert!(input > Decimal::ZERO); + assert!(output > Decimal::ZERO); + } + + #[test] + fn test_provider_prefix_stripped() { + // "openai/gpt-4o" should resolve to same as "gpt-4o" + assert_eq!(model_cost("openai/gpt-4o"), model_cost("gpt-4o")); + } +} diff --git a/src/llm/mod.rs b/src/llm/mod.rs index ee6063ad..1a3b7a47 100644 --- a/src/llm/mod.rs +++ b/src/llm/mod.rs @@ -1,13 +1,18 @@ //! LLM integration for the agent. //! -//! Supports two API modes: -//! - **Responses API** (chat-api): Session-based auth, uses `/v1/responses` endpoint -//! - **Chat Completions API** (cloud-api): API key auth, uses `/v1/chat/completions` endpoint +//! Supports multiple backends: +//! - **NEAR AI** (default): Session-based or API key auth via NEAR AI proxy +//! - **OpenAI**: Direct API access with your own key +//! - **Anthropic**: Direct API access with your own key +//! - **Ollama**: Local model inference +//! - **OpenAI-compatible**: Any endpoint that speaks the OpenAI API +mod costs; mod nearai; mod nearai_chat; mod provider; mod reasoning; +mod rig_adapter; pub mod session; pub use nearai::{ModelInfo, NearAiProvider}; @@ -17,32 +22,151 @@ pub use provider::{ Role, ToolCall, ToolCompletionRequest, ToolCompletionResponse, ToolDefinition, ToolResult, }; pub use reasoning::{ActionPlan, Reasoning, ReasoningContext, RespondResult, ToolSelection}; +pub use rig_adapter::RigAdapter; pub use session::{SessionConfig, SessionManager, create_session_manager}; use std::sync::Arc; -use crate::config::{LlmConfig, NearAiApiMode}; +use rig::client::CompletionClient; +use secrecy::ExposeSecret; + +use crate::config::{LlmBackend, LlmConfig, NearAiApiMode}; use crate::error::LlmError; /// Create an LLM provider based on configuration. /// -/// - For `Responses` mode: Requires a session manager for authentication -/// - For `ChatCompletions` mode: Uses API key from config (session not needed) +/// - `NearAi` backend: Uses session manager for authentication (Responses API) +/// or API key (Chat Completions API) +/// - Other backends: Use rig-core adapter with provider-specific clients pub fn create_llm_provider( config: &LlmConfig, session: Arc, +) -> Result, LlmError> { + match config.backend { + LlmBackend::NearAi => create_nearai_provider(config, session), + LlmBackend::OpenAi => create_openai_provider(config), + LlmBackend::Anthropic => create_anthropic_provider(config), + LlmBackend::Ollama => create_ollama_provider(config), + LlmBackend::OpenAiCompatible => create_openai_compatible_provider(config), + } +} + +fn create_nearai_provider( + config: &LlmConfig, + session: Arc, ) -> Result, LlmError> { match config.nearai.api_mode { NearAiApiMode::Responses => { - tracing::info!("Using Responses API (chat-api) with session auth"); + tracing::info!("Using NEAR AI Responses API (chat-api) with session auth"); Ok(Arc::new(NearAiProvider::new( config.nearai.clone(), session, ))) } NearAiApiMode::ChatCompletions => { - tracing::info!("Using Chat Completions API (cloud-api) with API key auth"); + tracing::info!("Using NEAR AI Chat Completions API (cloud-api) with API key auth"); Ok(Arc::new(NearAiChatProvider::new(config.nearai.clone())?)) } } } + +fn create_openai_provider(config: &LlmConfig) -> Result, LlmError> { + let oai = config.openai.as_ref().ok_or_else(|| LlmError::AuthFailed { + provider: "openai".to_string(), + })?; + + use rig::providers::openai; + + let client: openai::Client = + openai::Client::new(oai.api_key.expose_secret()).map_err(|e| LlmError::RequestFailed { + provider: "openai".to_string(), + reason: format!("Failed to create OpenAI client: {}", e), + })?; + + let model = client.completion_model(&oai.model); + tracing::info!("Using OpenAI direct API (model: {})", oai.model); + Ok(Arc::new(RigAdapter::new(model, &oai.model))) +} + +fn create_anthropic_provider(config: &LlmConfig) -> Result, LlmError> { + let anth = config + .anthropic + .as_ref() + .ok_or_else(|| LlmError::AuthFailed { + provider: "anthropic".to_string(), + })?; + + use rig::providers::anthropic; + + let client: anthropic::Client = + anthropic::Client::new(anth.api_key.expose_secret()).map_err(|e| { + LlmError::RequestFailed { + provider: "anthropic".to_string(), + reason: format!("Failed to create Anthropic client: {}", e), + } + })?; + + let model = client.completion_model(&anth.model); + tracing::info!("Using Anthropic direct API (model: {})", anth.model); + Ok(Arc::new(RigAdapter::new(model, &anth.model))) +} + +fn create_ollama_provider(config: &LlmConfig) -> Result, LlmError> { + let oll = config.ollama.as_ref().ok_or_else(|| LlmError::AuthFailed { + provider: "ollama".to_string(), + })?; + + use rig::client::Nothing; + use rig::providers::ollama; + + let client: ollama::Client = ollama::Client::builder() + .base_url(&oll.base_url) + .api_key(Nothing) + .build() + .map_err(|e| LlmError::RequestFailed { + provider: "ollama".to_string(), + reason: format!("Failed to create Ollama client: {}", e), + })?; + + let model = client.completion_model(&oll.model); + tracing::info!( + "Using Ollama (base_url: {}, model: {})", + oll.base_url, + oll.model + ); + Ok(Arc::new(RigAdapter::new(model, &oll.model))) +} + +fn create_openai_compatible_provider(config: &LlmConfig) -> Result, LlmError> { + let compat = config + .openai_compatible + .as_ref() + .ok_or_else(|| LlmError::AuthFailed { + provider: "openai_compatible".to_string(), + })?; + + use rig::providers::openai; + + let api_key = compat + .api_key + .as_ref() + .map(|k| k.expose_secret().to_string()) + .unwrap_or_else(|| "no-key".to_string()); + + let client: openai::Client = openai::Client::builder() + .base_url(&compat.base_url) + .api_key(api_key) + .build() + .map_err(|e| LlmError::RequestFailed { + provider: "openai_compatible".to_string(), + reason: format!("Failed to create OpenAI-compatible client: {}", e), + })?; + + let model = client.completion_model(&compat.model); + tracing::info!( + "Using OpenAI-compatible endpoint (base_url: {}, model: {})", + compat.base_url, + compat.model + ); + Ok(Arc::new(RigAdapter::new(model, &compat.model))) +} diff --git a/src/llm/rig_adapter.rs b/src/llm/rig_adapter.rs new file mode 100644 index 00000000..642cc895 --- /dev/null +++ b/src/llm/rig_adapter.rs @@ -0,0 +1,451 @@ +//! Generic adapter that bridges rig-core's `CompletionModel` trait to IronClaw's `LlmProvider`. +//! +//! This lets us use any rig-core provider (OpenAI, Anthropic, Ollama, etc.) as an +//! `Arc` without changing any of the agent, reasoning, or tool code. + +use async_trait::async_trait; +use rig::OneOrMany; +use rig::completion::{ + AssistantContent, CompletionModel, CompletionRequest as RigRequest, + ToolDefinition as RigToolDefinition, Usage as RigUsage, +}; +use rig::message::{ + Message as RigMessage, ToolChoice as RigToolChoice, ToolFunction, ToolResult as RigToolResult, + ToolResultContent, UserContent, +}; +use rust_decimal::Decimal; +use serde::Serialize; +use serde::de::DeserializeOwned; + +use crate::error::LlmError; +use crate::llm::costs; +use crate::llm::provider::{ + ChatMessage, CompletionRequest, CompletionResponse, FinishReason, LlmProvider, + ToolCall as IronToolCall, ToolCompletionRequest, ToolCompletionResponse, + ToolDefinition as IronToolDefinition, +}; + +/// Adapter that wraps a rig-core `CompletionModel` and implements `LlmProvider`. +pub struct RigAdapter { + model: M, + model_name: String, + input_cost: Decimal, + output_cost: Decimal, +} + +impl RigAdapter { + /// Create a new adapter wrapping the given rig-core model. + pub fn new(model: M, model_name: impl Into) -> Self { + let name = model_name.into(); + let (input_cost, output_cost) = + costs::model_cost(&name).unwrap_or_else(costs::default_cost); + Self { + model, + model_name: name, + input_cost, + output_cost, + } + } +} + +// -- Type conversion helpers -- + +/// Convert IronClaw messages to rig-core format. +/// +/// Returns `(preamble, chat_history)` where preamble is extracted from +/// any System message and chat_history contains the rest. +fn convert_messages(messages: &[ChatMessage]) -> (Option, Vec) { + let mut preamble: Option = None; + let mut history = Vec::new(); + + for msg in messages { + match msg.role { + crate::llm::Role::System => { + // Concatenate system messages into preamble + match preamble { + Some(ref mut p) => { + p.push('\n'); + p.push_str(&msg.content); + } + None => preamble = Some(msg.content.clone()), + } + } + crate::llm::Role::User => { + history.push(RigMessage::user(&msg.content)); + } + crate::llm::Role::Assistant => { + if let Some(ref tool_calls) = msg.tool_calls { + // Assistant message with tool calls + let mut contents: Vec = Vec::new(); + if !msg.content.is_empty() { + contents.push(AssistantContent::text(&msg.content)); + } + for tc in tool_calls { + contents.push(AssistantContent::ToolCall(rig::message::ToolCall::new( + tc.id.clone(), + ToolFunction::new(tc.name.clone(), tc.arguments.clone()), + ))); + } + if let Ok(many) = OneOrMany::many(contents) { + history.push(RigMessage::Assistant { + id: None, + content: many, + }); + } else { + // Shouldn't happen but fall back to text + history.push(RigMessage::assistant(&msg.content)); + } + } else { + history.push(RigMessage::assistant(&msg.content)); + } + } + crate::llm::Role::Tool => { + // Tool result message: wrap as User { ToolResult } + let tool_id = msg.tool_call_id.clone().unwrap_or_default(); + history.push(RigMessage::User { + content: OneOrMany::one(UserContent::ToolResult(RigToolResult { + id: tool_id, + call_id: None, + content: OneOrMany::one(ToolResultContent::text(&msg.content)), + })), + }); + } + } + } + + (preamble, history) +} + +/// Convert IronClaw tool definitions to rig-core format. +fn convert_tools(tools: &[IronToolDefinition]) -> Vec { + tools + .iter() + .map(|t| RigToolDefinition { + name: t.name.clone(), + description: t.description.clone(), + parameters: t.parameters.clone(), + }) + .collect() +} + +/// Convert IronClaw tool_choice string to rig-core ToolChoice. +fn convert_tool_choice(choice: Option<&str>) -> Option { + match choice.map(|s| s.to_lowercase()).as_deref() { + Some("auto") => Some(RigToolChoice::Auto), + Some("required") => Some(RigToolChoice::Required), + Some("none") => Some(RigToolChoice::None), + _ => None, + } +} + +/// Extract text and tool calls from a rig-core completion response. +fn extract_response( + choice: &OneOrMany, + _usage: &RigUsage, +) -> (Option, Vec, FinishReason) { + let mut text_parts: Vec = Vec::new(); + let mut tool_calls: Vec = Vec::new(); + + for content in choice.iter() { + match content { + AssistantContent::Text(t) => { + if !t.text.is_empty() { + text_parts.push(t.text.clone()); + } + } + AssistantContent::ToolCall(tc) => { + tool_calls.push(IronToolCall { + id: tc.id.clone(), + name: tc.function.name.clone(), + arguments: tc.function.arguments.clone(), + }); + } + // Reasoning and Image variants are not mapped to IronClaw types + _ => {} + } + } + + let text = if text_parts.is_empty() { + None + } else { + Some(text_parts.join("")) + }; + + let finish = if !tool_calls.is_empty() { + FinishReason::ToolUse + } else { + FinishReason::Stop + }; + + (text, tool_calls, finish) +} + +/// Saturate u64 to u32 for token counts. +fn saturate_u32(val: u64) -> u32 { + val.min(u32::MAX as u64) as u32 +} + +/// Build a rig-core CompletionRequest from our internal types. +fn build_rig_request( + preamble: Option, + mut history: Vec, + tools: Vec, + tool_choice: Option, + temperature: Option, + max_tokens: Option, +) -> Result { + // rig-core requires at least one message in chat_history + if history.is_empty() { + history.push(RigMessage::user("Hello")); + } + + let chat_history = OneOrMany::many(history).map_err(|e| LlmError::RequestFailed { + provider: "rig".to_string(), + reason: format!("Failed to build chat history: {}", e), + })?; + + Ok(RigRequest { + preamble, + chat_history, + documents: Vec::new(), + tools, + temperature: temperature.map(|t| t as f64), + max_tokens: max_tokens.map(|t| t as u64), + tool_choice, + additional_params: None, + }) +} + +#[async_trait] +impl LlmProvider for RigAdapter +where + M: CompletionModel + Send + Sync + 'static, + M::Response: Send + Sync + Serialize + DeserializeOwned, +{ + fn model_name(&self) -> &str { + &self.model_name + } + + fn cost_per_token(&self) -> (Decimal, Decimal) { + (self.input_cost, self.output_cost) + } + + async fn complete(&self, request: CompletionRequest) -> Result { + let (preamble, history) = convert_messages(&request.messages); + + let rig_req = build_rig_request( + preamble, + history, + Vec::new(), + None, + request.temperature, + request.max_tokens, + )?; + + let response = + self.model + .completion(rig_req) + .await + .map_err(|e| LlmError::RequestFailed { + provider: self.model_name.clone(), + reason: e.to_string(), + })?; + + let (text, _tool_calls, finish) = extract_response(&response.choice, &response.usage); + + Ok(CompletionResponse { + content: text.unwrap_or_default(), + input_tokens: saturate_u32(response.usage.input_tokens), + output_tokens: saturate_u32(response.usage.output_tokens), + finish_reason: finish, + response_id: None, + }) + } + + async fn complete_with_tools( + &self, + request: ToolCompletionRequest, + ) -> Result { + let (preamble, history) = convert_messages(&request.messages); + let tools = convert_tools(&request.tools); + let tool_choice = convert_tool_choice(request.tool_choice.as_deref()); + + let rig_req = build_rig_request( + preamble, + history, + tools, + tool_choice, + request.temperature, + request.max_tokens, + )?; + + let response = + self.model + .completion(rig_req) + .await + .map_err(|e| LlmError::RequestFailed { + provider: self.model_name.clone(), + reason: e.to_string(), + })?; + + let (text, tool_calls, finish) = extract_response(&response.choice, &response.usage); + + Ok(ToolCompletionResponse { + content: text, + tool_calls, + input_tokens: saturate_u32(response.usage.input_tokens), + output_tokens: saturate_u32(response.usage.output_tokens), + finish_reason: finish, + response_id: None, + }) + } + + fn active_model_name(&self) -> String { + self.model_name.clone() + } + + fn set_model(&self, _model: &str) -> Result<(), LlmError> { + // rig-core models are baked at construction time. + // Switching requires creating a new adapter. + Err(LlmError::RequestFailed { + provider: self.model_name.clone(), + reason: "Runtime model switching not supported for rig-core providers. \ + Restart with a different model configured." + .to_string(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_convert_messages_system_to_preamble() { + let messages = vec![ + ChatMessage::system("You are a helpful assistant."), + ChatMessage::user("Hello"), + ]; + let (preamble, history) = convert_messages(&messages); + assert_eq!(preamble, Some("You are a helpful assistant.".to_string())); + assert_eq!(history.len(), 1); + } + + #[test] + fn test_convert_messages_multiple_systems_concatenated() { + let messages = vec![ + ChatMessage::system("System 1"), + ChatMessage::system("System 2"), + ChatMessage::user("Hi"), + ]; + let (preamble, history) = convert_messages(&messages); + assert_eq!(preamble, Some("System 1\nSystem 2".to_string())); + assert_eq!(history.len(), 1); + } + + #[test] + fn test_convert_messages_tool_result() { + let messages = vec![ChatMessage::tool_result( + "call_123", + "search", + "result text", + )]; + let (preamble, history) = convert_messages(&messages); + assert!(preamble.is_none()); + assert_eq!(history.len(), 1); + // Tool results become User messages in rig-core + match &history[0] { + RigMessage::User { .. } => {} + other => panic!("Expected User message, got: {:?}", other), + } + } + + #[test] + fn test_convert_messages_assistant_with_tool_calls() { + let tc = IronToolCall { + id: "call_1".to_string(), + name: "search".to_string(), + arguments: serde_json::json!({"query": "test"}), + }; + let msg = ChatMessage::assistant_with_tool_calls(Some("thinking".to_string()), vec![tc]); + let messages = vec![msg]; + let (_preamble, history) = convert_messages(&messages); + assert_eq!(history.len(), 1); + match &history[0] { + RigMessage::Assistant { content, .. } => { + // Should have both text and tool call + assert!(content.iter().count() >= 2); + } + other => panic!("Expected Assistant message, got: {:?}", other), + } + } + + #[test] + fn test_convert_tools() { + let tools = vec![IronToolDefinition { + name: "search".to_string(), + description: "Search the web".to_string(), + parameters: serde_json::json!({ + "type": "object", + "properties": { + "query": {"type": "string"} + } + }), + }]; + let rig_tools = convert_tools(&tools); + assert_eq!(rig_tools.len(), 1); + assert_eq!(rig_tools[0].name, "search"); + assert_eq!(rig_tools[0].description, "Search the web"); + } + + #[test] + fn test_convert_tool_choice() { + assert!(matches!( + convert_tool_choice(Some("auto")), + Some(RigToolChoice::Auto) + )); + assert!(matches!( + convert_tool_choice(Some("required")), + Some(RigToolChoice::Required) + )); + assert!(matches!( + convert_tool_choice(Some("none")), + Some(RigToolChoice::None) + )); + assert!(matches!( + convert_tool_choice(Some("AUTO")), + Some(RigToolChoice::Auto) + )); + assert!(convert_tool_choice(None).is_none()); + assert!(convert_tool_choice(Some("unknown")).is_none()); + } + + #[test] + fn test_extract_response_text_only() { + let content = OneOrMany::one(AssistantContent::text("Hello world")); + let usage = RigUsage::new(); + let (text, calls, finish) = extract_response(&content, &usage); + assert_eq!(text, Some("Hello world".to_string())); + assert!(calls.is_empty()); + assert_eq!(finish, FinishReason::Stop); + } + + #[test] + fn test_extract_response_tool_call() { + let tc = AssistantContent::tool_call("call_1", "search", serde_json::json!({"q": "test"})); + let content = OneOrMany::one(tc); + let usage = RigUsage::new(); + let (text, calls, finish) = extract_response(&content, &usage); + assert!(text.is_none()); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].name, "search"); + assert_eq!(finish, FinishReason::ToolUse); + } + + #[test] + fn test_saturate_u32() { + assert_eq!(saturate_u32(100), 100); + assert_eq!(saturate_u32(u64::MAX), u32::MAX); + assert_eq!(saturate_u32(u32::MAX as u64), u32::MAX); + } +} diff --git a/src/main.rs b/src/main.rs index de53c90a..8dcd69ed 100644 --- a/src/main.rs +++ b/src/main.rs @@ -274,8 +274,10 @@ async fn main() -> anyhow::Result<()> { }; let session = create_session_manager(session_config).await; - // Ensure we're authenticated before proceeding (may trigger login flow) - session.ensure_authenticated().await?; + // Ensure we're authenticated before proceeding (only needed for NEAR AI backend) + if config.llm.backend == ironclaw::config::LlmBackend::NearAi { + session.ensure_authenticated().await?; + } // Initialize tracing let env_filter = EnvFilter::try_from_default_env() @@ -302,7 +304,7 @@ async fn main() -> anyhow::Result<()> { tracing::info!("Starting IronClaw..."); tracing::info!("Loaded configuration for agent: {}", config.agent.name); - tracing::info!("NEAR AI session authenticated"); + tracing::info!("LLM backend: {}", config.llm.backend); // Initialize database store (optional for testing) let store = if cli.no_db { diff --git a/src/setup/wizard.rs b/src/setup/wizard.rs index db447369..021938c5 100644 --- a/src/setup/wizard.rs +++ b/src/setup/wizard.rs @@ -455,6 +455,7 @@ impl SetupWizard { .unwrap_or_else(|_| "https://private.near.ai".to_string()); let config = LlmConfig { + backend: crate::config::LlmBackend::NearAi, nearai: crate::config::NearAiConfig { model: "dummy".to_string(), base_url, @@ -463,6 +464,10 @@ impl SetupWizard { api_mode: crate::config::NearAiApiMode::Responses, api_key: None, }, + openai: None, + anthropic: None, + ollama: None, + openai_compatible: None, }; match create_llm_provider(&config, Arc::clone(session)) { From 115b7f38fe5ea4c4fb3f36be43bf5fa7f31b555f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ilg=C4=B1n=20Kanat?= <48878763+nightfullstar@users.noreply.github.com> Date: Thu, 12 Feb 2026 04:46:47 +0400 Subject: [PATCH 06/33] DM pairing + Telegram channel improvements (#17) * feat: Implement DM pairing for channels - Introduced a new pairing system to manage direct messages from unknown senders. - Added `PairingStore` to handle pending requests and allowlist management. - Implemented CLI commands for listing and approving pairing requests. - Updated Telegram channel to utilize the new pairing logic, including workspace paths for storing pairing data. - Enhanced WASM channel integration to support pairing functionality. This feature enhances security by requiring approval for unknown senders before they can interact with the agent. * Enhance Telegram channel support with media captioning and DM pairing features - Added support for media captions in Telegram messages, allowing for richer content handling. - Updated message processing to utilize either text or caption, improving message flexibility. - Enhanced DM pairing functionality to include approval and listing capabilities for direct messages. - Updated feature parity documentation to reflect new capabilities and improvements in Telegram integration. * Update README and BUILDING_CHANNELS documentation for Telegram channel integration - Enhanced README with instructions for building and running the Telegram channel, including a note on running `./scripts/build-all.sh` for full releases. - Added detailed steps in BUILDING_CHANNELS.md for building and deploying the Telegram channel, emphasizing the need to run `./channels-src/telegram/build.sh` before building the main crate to ensure updated WASM is included. - Updated CLI module to expose a new command for pairing with store functionality. * Implement build script for Telegram channel WASM and enhance pairing error handling - Added a new `build.rs` script to automate the compilation of the Telegram channel's WASM binary from source, ensuring reproducible builds and emphasizing supply chain security by preventing committed binaries. - Updated `BUILDING_CHANNELS.md` to reflect the new build process and the importance of not committing compiled binaries. - Enhanced error handling in the pairing approval process to include rate limiting for failed attempts, improving security and user feedback. * Remove Telegram channel WASM binary file as part of the build process cleanup, ensuring no committed binaries are present in the repository. --- Cargo.lock | 11 + Cargo.toml | 1 + FEATURE_PARITY.md | 25 +- README.md | 5 + build.rs | 105 +++ channels-src/telegram/src/lib.rs | 317 +++++++-- .../telegram/telegram.capabilities.json | 45 +- docs/BUILDING_CHANNELS.md | 39 +- docs/TELEGRAM_SETUP.md | 135 ++++ scripts/build-all.sh | 21 + src/channels/wasm/loader.rs | 22 +- src/channels/wasm/router.rs | 4 +- src/channels/wasm/wrapper.rs | 141 +++- src/cli/mod.rs | 6 + src/cli/pairing.rs | 183 +++++ src/lib.rs | 1 + src/main.rs | 16 +- src/pairing/mod.rs | 10 + src/pairing/store.rs | 669 ++++++++++++++++++ tests/pairing_integration.rs | 112 +++ tests/wasm_channel_integration.rs | 9 +- wit/channel.wit | 26 + 22 files changed, 1774 insertions(+), 129 deletions(-) create mode 100644 build.rs create mode 100644 docs/TELEGRAM_SETUP.md create mode 100755 scripts/build-all.sh create mode 100644 src/cli/pairing.rs create mode 100644 src/pairing/mod.rs create mode 100644 src/pairing/store.rs create mode 100644 tests/pairing_integration.rs diff --git a/Cargo.lock b/Cargo.lock index c9006498..3e26cae9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1621,6 +1621,16 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "fs4" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eeb4ed9e12f43b7fa0baae3f9cdda28352770132ef2e09a23760c29cae8bd47" +dependencies = [ + "rustix 0.38.44", + "windows-sys 0.48.0", +] + [[package]] name = "funty" version = "2.0.0" @@ -2299,6 +2309,7 @@ dependencies = [ "deadpool-postgres", "dirs 6.0.0", "dotenvy", + "fs4", "futures", "hkdf", "http-body-util", diff --git a/Cargo.toml b/Cargo.toml index c8e3a4f4..6b3c0711 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -67,6 +67,7 @@ aho-corasick = "1" # Filesystem paths dirs = "6" +fs4 = "0.6" # Secrecy for sensitive values secrecy = { version = "0.10", features = ["serde"] } diff --git a/FEATURE_PARITY.md b/FEATURE_PARITY.md index ce126dca..a5ce7092 100644 --- a/FEATURE_PARITY.md +++ b/FEATURE_PARITY.md @@ -59,7 +59,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O | REPL (simple) | ✅ | ✅ | - | For testing | | WASM channels | ❌ | ✅ | - | IronClaw innovation | | WhatsApp | ✅ | ❌ | P1 | Baileys (Web) | -| Telegram | ✅ | ✅ | - | WASM tool (MTProto) | +| Telegram | ✅ | ✅ | - | WASM channel(MTProto), DM pairing, caption, /start, bot_username | | Discord | ✅ | ❌ | P2 | discord.js | | Signal | ✅ | ❌ | P2 | signal-cli | | Slack | ✅ | ✅ | - | WASM tool | @@ -79,13 +79,13 @@ This document tracks feature parity between IronClaw (Rust implementation) and O | Feature | OpenClaw | IronClaw | Notes | |---------|----------|----------|-------| -| DM pairing codes | ✅ | ❌ | Verification for unknown senders | -| Allowlist/blocklist | ✅ | ❌ | Per-channel access control | +| DM pairing codes | ✅ | ✅ | `ironclaw pairing list/approve`, host APIs | +| Allowlist/blocklist | ✅ | 🚧 | allow_from + pairing store | | Self-message bypass | ✅ | ❌ | Own messages skip pairing | -| Mention-based activation | ✅ | ❌ | Configurable patterns | +| Mention-based activation | ✅ | ✅ | bot_username + respond_to_all_group_messages | | Per-group tool policies | ✅ | ❌ | Allow/deny specific tools | | Thread isolation | ✅ | ✅ | Separate sessions per thread | -| Per-channel media limits | ✅ | ❌ | | +| Per-channel media limits | ✅ | 🚧 | Caption support for media; no size limits | | Typing indicators | ✅ | 🚧 | TUI shows status | ### Owner: _Unassigned_ @@ -109,7 +109,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O | `sessions` | ✅ | ❌ | P3 | Session listing | | `memory` | ✅ | ✅ | - | Memory search CLI | | `skills` | ✅ | ❌ | P3 | Agent skills | -| `pairing` | ✅ | ❌ | P3 | Node pairing | +| `pairing` | ✅ | ✅ | - | list/approve for channel DM pairing | | `nodes` | ✅ | ❌ | P3 | Device management | | `plugins` | ✅ | ❌ | P3 | Plugin management | | `hooks` | ✅ | ❌ | P2 | Lifecycle hooks | @@ -350,8 +350,8 @@ This document tracks feature parity between IronClaw (Rust implementation) and O | Device pairing | ✅ | ❌ | | | Tailscale identity | ✅ | ❌ | | | OAuth flows | ✅ | 🚧 | NEAR AI OAuth | -| DM pairing verification | ✅ | ❌ | | -| Allowlist/blocklist | ✅ | ❌ | | +| DM pairing verification | ✅ | ✅ | ironclaw pairing approve, host APIs | +| Allowlist/blocklist | ✅ | 🚧 | allow_from + pairing store | | Per-group tool policies | ✅ | ❌ | | | Exec approvals | ✅ | ✅ | TUI overlay | | TLS 1.3 minimum | ✅ | ✅ | reqwest rustls | @@ -397,6 +397,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O ### P0 - Core (Already Done) - ✅ TUI channel with approval overlays - ✅ HTTP webhook channel +- ✅ DM pairing (ironclaw pairing list/approve, host APIs) - ✅ WASM tool sandbox - ✅ Workspace/memory with hybrid search - ✅ Prompt injection defense @@ -415,12 +416,18 @@ This document tracks feature parity between IronClaw (Rust implementation) and O - ✅ Gateway token auth ### P1 - High Priority +- ❌ Slack channel (real implementation) +- ✅ Telegram channel (WASM, DM pairing, caption, /start) - ❌ WhatsApp channel - ❌ Multi-provider failover - ❌ Hooks system (beforeInbound, beforeToolCall, etc.) ### P2 - Medium Priority -- ❌ Media handling (images, PDFs) +- ❌ Cron job scheduling +- ❌ Web Control UI +- ❌ WebChat channel +- 🚧 Media handling (caption support; no image/PDF processing) +- ❌ CLI subcommands (config, status, memory, doctor) - ❌ Ollama/local model support - ❌ Configuration hot-reload - ❌ Webhook trigger endpoint in web gateway diff --git a/README.md b/README.md index 80c16302..d98da3da 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,8 @@ cargo build --release cargo test ``` +For **full release** (after modifying channel sources), run `./scripts/build-all.sh` to rebuild channels first. + ### Database Setup ```bash @@ -228,6 +230,9 @@ cargo test cargo test test_name ``` +- **Telegram channel**: See [docs/TELEGRAM_SETUP.md](docs/TELEGRAM_SETUP.md) for setup and DM pairing. +- **Changing channel sources**: Run `./channels-src/telegram/build.sh` before `cargo build` so the updated WASM is bundled. + ## OpenClaw Heritage IronClaw is a Rust reimplementation inspired by [OpenClaw](https://github.com/openclaw/openclaw). See [FEATURE_PARITY.md](FEATURE_PARITY.md) for the complete tracking matrix. diff --git a/build.rs b/build.rs new file mode 100644 index 00000000..01ea2da8 --- /dev/null +++ b/build.rs @@ -0,0 +1,105 @@ +//! Build script: compile Telegram channel WASM from source. +//! +//! Do not commit compiled WASM binaries — they are a supply chain risk. +//! This script builds telegram.wasm from channels-src/telegram before the main crate compiles. +//! +//! Reproducible build: +//! cargo build --release +//! (build.rs invokes the channel build automatically) +//! +//! Prerequisites: rustup target add wasm32-wasip2, cargo install wasm-tools + +use std::env; +use std::path::PathBuf; +use std::process::Command; + +fn main() { + let manifest_dir = env::var("CARGO_MANIFEST_DIR").unwrap(); + let root = PathBuf::from(&manifest_dir); + let channel_dir = root.join("channels-src/telegram"); + let wasm_out = channel_dir.join("telegram.wasm"); + + // Rerun when channel source or build script changes + println!("cargo:rerun-if-changed=channels-src/telegram/src"); + println!("cargo:rerun-if-changed=channels-src/telegram/Cargo.toml"); + println!("cargo:rerun-if-changed=wit/channel.wit"); + + if !channel_dir.is_dir() { + return; + } + + // Build WASM module + let status = match Command::new("cargo") + .args([ + "build", + "--release", + "--target", + "wasm32-wasip2", + "--manifest-path", + channel_dir.join("Cargo.toml").to_str().unwrap(), + ]) + .current_dir(&root) + .status() + { + Ok(s) => s, + Err(_) => { + eprintln!( + "cargo:warning=Telegram channel build failed. Run: ./channels-src/telegram/build.sh" + ); + return; + } + }; + + if !status.success() { + eprintln!( + "cargo:warning=Telegram channel build failed. Run: ./channels-src/telegram/build.sh" + ); + return; + } + + let raw_wasm = channel_dir.join("target/wasm32-wasip2/release/telegram_channel.wasm"); + if !raw_wasm.exists() { + eprintln!( + "cargo:warning=Telegram WASM output not found at {:?}", + raw_wasm + ); + return; + } + + // Convert to component and strip (wasm-tools) + let component_ok = Command::new("wasm-tools") + .args([ + "component", + "new", + raw_wasm.to_str().unwrap(), + "-o", + wasm_out.to_str().unwrap(), + ]) + .current_dir(&root) + .status() + .map(|s| s.success()) + .unwrap_or(false); + + if !component_ok + { + // Fallback: copy raw module if wasm-tools unavailable + if std::fs::copy(&raw_wasm, &wasm_out).is_err() { + eprintln!( + "cargo:warning=wasm-tools not found. Run: cargo install wasm-tools" + ); + } + } else { + // Strip debug info (use temp file to avoid clobbering) + let stripped = wasm_out.with_extension("wasm.stripped"); + let strip_ok = Command::new("wasm-tools") + .args(["strip", wasm_out.to_str().unwrap(), "-o", stripped.to_str().unwrap()]) + .current_dir(&root) + .status() + .map(|s| s.success()) + .unwrap_or(false); + if strip_ok + { + let _ = std::fs::rename(&stripped, &wasm_out); + } + } +} diff --git a/channels-src/telegram/src/lib.rs b/channels-src/telegram/src/lib.rs index 5a1591bc..09a99df3 100644 --- a/channels-src/telegram/src/lib.rs +++ b/channels-src/telegram/src/lib.rs @@ -72,6 +72,10 @@ struct TelegramMessage { /// Message text. text: Option, + /// Caption for media (photo, video, document, etc.). + #[serde(default)] + caption: Option, + /// Original message if this is a reply. reply_to_message: Option>, @@ -160,6 +164,21 @@ const POLLING_STATE_PATH: &str = "state/last_update_id"; /// Workspace path for persisting owner_id across WASM callbacks. const OWNER_ID_PATH: &str = "state/owner_id"; +/// Workspace path for persisting dm_policy across WASM callbacks. +const DM_POLICY_PATH: &str = "state/dm_policy"; + +/// Workspace path for persisting allow_from (JSON array) across WASM callbacks. +const ALLOW_FROM_PATH: &str = "state/allow_from"; + +/// Channel name for pairing store (used by pairing host APIs). +const CHANNEL_NAME: &str = "telegram"; + +/// Workspace path for persisting bot_username for mention detection in groups. +const BOT_USERNAME_PATH: &str = "state/bot_username"; + +/// Workspace path for persisting respond_to_all_group_messages flag. +const RESPOND_TO_ALL_GROUP_PATH: &str = "state/respond_to_all_group_messages"; + // ============================================================================ // Channel Metadata // ============================================================================ @@ -196,6 +215,14 @@ struct TelegramConfig { #[serde(default)] owner_id: Option, + /// DM policy: "pairing" (default), "allowlist", or "open". + #[serde(default)] + dm_policy: Option, + + /// Allowed sender IDs/usernames from config (merged with pairing-approved store). + #[serde(default)] + allow_from: Option>, + /// Whether to respond to all group messages (not just mentions). #[serde(default)] respond_to_all_group_messages: bool, @@ -257,6 +284,28 @@ impl Guest for TelegramChannel { ); } + // Persist dm_policy and allow_from for DM pairing in handle_message + let dm_policy = config + .dm_policy + .as_deref() + .unwrap_or("pairing") + .to_string(); + let _ = channel_host::workspace_write(DM_POLICY_PATH, &dm_policy); + + let allow_from_json = serde_json::to_string(&config.allow_from.unwrap_or_default()) + .unwrap_or_else(|_| "[]".to_string()); + let _ = channel_host::workspace_write(ALLOW_FROM_PATH, &allow_from_json); + + // Persist bot_username and respond_to_all_group_messages for group handling + let _ = channel_host::workspace_write( + BOT_USERNAME_PATH, + &config.bot_username.unwrap_or_default(), + ); + let _ = channel_host::workspace_write( + RESPOND_TO_ALL_GROUP_PATH, + &config.respond_to_all_group_messages.to_string(), + ); + // Mode is determined by whether the host injected a tunnel_url // If tunnel is configured, use webhooks. Otherwise, use polling. let webhook_mode = config.tunnel_url.is_some(); @@ -780,6 +829,47 @@ fn register_webhook(tunnel_url: &str, webhook_secret: Option<&str>) -> Result<() } } +// ============================================================================ +// Pairing Reply +// ============================================================================ + +/// Send a pairing code message to a chat. Used when an unknown user DMs the bot. +fn send_pairing_reply(chat_id: i64, code: &str) -> Result<(), String> { + let payload = serde_json::json!({ + "chat_id": chat_id, + "text": format!( + "To pair with this bot, run: `ironclaw pairing approve telegram {}`", + code + ), + "parse_mode": "Markdown", + }); + + let payload_bytes = serde_json::to_vec(&payload) + .map_err(|e| format!("Failed to serialize payload: {}", e))?; + + let headers = serde_json::json!({ + "Content-Type": "application/json" + }); + + let result = channel_host::http_request( + "POST", + "https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage", + &headers.to_string(), + Some(&payload_bytes), + ); + + match result { + Ok(response) => { + if response.status != 200 { + let body_str = String::from_utf8_lossy(&response.body); + return Err(format!("HTTP {}: {}", response.status, body_str)); + } + Ok(()) + } + Err(e) => Err(format!("HTTP request failed: {}", e)), + } +} + // ============================================================================ // Update Handling // ============================================================================ @@ -799,11 +889,16 @@ fn handle_update(update: TelegramUpdate) { /// Process a single message. fn handle_message(message: TelegramMessage) { - // Skip messages without text - let text = match message.text { - Some(t) if !t.is_empty() => t, - _ => return, - }; + // Use text or caption (for media messages) + let content = message + .text + .filter(|t| !t.is_empty()) + .or_else(|| message.caption.filter(|c| !c.is_empty())) + .unwrap_or_default(); + + if content.is_empty() { + return; + } // Skip messages without a sender (channel posts) let from = match message.from { @@ -816,41 +911,111 @@ fn handle_message(message: TelegramMessage) { return; } - // Owner validation: silently drop messages from non-owner users - if let Some(owner_id_str) = channel_host::workspace_read(OWNER_ID_PATH) { - if !owner_id_str.is_empty() { - if let Ok(owner_id) = owner_id_str.parse::() { - if from.id != owner_id { - channel_host::log( - channel_host::LogLevel::Debug, - &format!( - "Dropping message from non-owner user {} (owner: {})", - from.id, owner_id - ), - ); - return; + let is_private = message.chat.chat_type == "private"; + + // Owner validation: when owner_id is set, only that user can message + let owner_configured = channel_host::workspace_read(OWNER_ID_PATH) + .map(|s| !s.is_empty()) + .unwrap_or(false); + + if owner_configured { + if let Ok(owner_id) = channel_host::workspace_read(OWNER_ID_PATH) + .unwrap() + .parse::() + { + if from.id != owner_id { + channel_host::log( + channel_host::LogLevel::Debug, + &format!( + "Dropping message from non-owner user {} (owner: {})", + from.id, owner_id + ), + ); + return; + } + } + } else if is_private { + // No owner_id: apply dm_policy for private chats + let dm_policy = channel_host::workspace_read(DM_POLICY_PATH) + .unwrap_or_else(|| "pairing".to_string()); + + if dm_policy != "open" { + // Build effective allow list: config allow_from + pairing store + let mut allowed: Vec = channel_host::workspace_read(ALLOW_FROM_PATH) + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default(); + + if let Ok(store_allowed) = channel_host::pairing_read_allow_from(CHANNEL_NAME) { + allowed.extend(store_allowed); + } + + let id_str = from.id.to_string(); + let username_opt = from.username.as_deref(); + let is_allowed = allowed.contains(&"*".to_string()) + || allowed.contains(&id_str) + || username_opt.map_or(false, |u| allowed.contains(&u.to_string())); + + if !is_allowed { + if dm_policy == "pairing" { + // Upsert pairing request and send reply + let meta = serde_json::json!({ + "chat_id": message.chat.id, + "user_id": from.id, + "username": username_opt, + }) + .to_string(); + + match channel_host::pairing_upsert_request(CHANNEL_NAME, &id_str, &meta) { + Ok(result) => { + channel_host::log( + channel_host::LogLevel::Info, + &format!( + "Pairing request for user {} (chat {}): code {}", + from.id, message.chat.id, result.code + ), + ); + if result.created { + let _ = send_pairing_reply(message.chat.id, &result.code); + } + } + Err(e) => { + channel_host::log( + channel_host::LogLevel::Error, + &format!("Pairing upsert failed: {}", e), + ); + } + } } + return; } } } - let is_private = message.chat.chat_type == "private"; - - // For group chats, check if the bot was mentioned - // TODO: Read bot_username from config and check mentions - // For now, process all messages in private chats and groups + // For group chats, only respond if bot was mentioned or respond_to_all is enabled if !is_private { - // In groups, only respond if there's a bot mention or command - // This is a simplified check - proper implementation would use entities - let has_command = text.starts_with('/'); - let has_mention = text.contains('@'); + let respond_to_all = channel_host::workspace_read(RESPOND_TO_ALL_GROUP_PATH) + .as_deref() + .unwrap_or("false") + == "true"; - if !has_command && !has_mention { - channel_host::log( - channel_host::LogLevel::Debug, - &format!("Ignoring group message without mention: {}", text), - ); - return; + if !respond_to_all { + let has_command = content.starts_with('/'); + let bot_username = channel_host::workspace_read(BOT_USERNAME_PATH) + .unwrap_or_default(); + let has_bot_mention = if bot_username.is_empty() { + content.contains('@') + } else { + let mention = format!("@{}", bot_username); + content.to_lowercase().contains(&mention.to_lowercase()) + }; + + if !has_command && !has_bot_mention { + channel_host::log( + channel_host::LogLevel::Debug, + &format!("Ignoring group message without mention: {}", content), + ); + return; + } } } @@ -872,17 +1037,30 @@ fn handle_message(message: TelegramMessage) { let metadata_json = serde_json::to_string(&metadata).unwrap_or_else(|_| "{}".to_string()); // Clean the message text (strip bot mentions and commands) - let cleaned_text = clean_message_text(&text); + let bot_username = channel_host::workspace_read(BOT_USERNAME_PATH).unwrap_or_default(); + let cleaned_text = clean_message_text( + &content, + if bot_username.is_empty() { + None + } else { + Some(bot_username.as_str()) + }, + ); - if cleaned_text.is_empty() { + // For /start with no args, emit placeholder so agent can respond with welcome + let content_to_emit = if cleaned_text.is_empty() && content.trim().starts_with('/') { + "[User started the bot]".to_string() + } else if cleaned_text.is_empty() { return; - } + } else { + cleaned_text + }; // Emit the message to the agent channel_host::emit_message(&EmittedMessage { user_id: from.id.to_string(), user_name: Some(user_name), - content: cleaned_text, + content: content_to_emit, thread_id: None, // Telegram doesn't have threads in the same way metadata_json, }); @@ -897,7 +1075,8 @@ fn handle_message(message: TelegramMessage) { } /// Clean message text by removing bot commands and @mentions at the start. -fn clean_message_text(text: &str) -> String { +/// When bot_username is set, only strips that specific mention; otherwise strips any leading @mention. +fn clean_message_text(text: &str, bot_username: Option<&str>) -> String { let mut result = text.trim().to_string(); // Remove leading /command @@ -912,11 +1091,30 @@ fn clean_message_text(text: &str) -> String { // Remove leading @mention if result.starts_with('@') { - if let Some(space_idx) = result.find(' ') { - result = result[space_idx..].trim_start().to_string(); + if let Some(bot) = bot_username { + let mention = format!("@{}", bot); + let mention_lower = mention.to_lowercase(); + let result_lower = result.to_lowercase(); + if result_lower.starts_with(&mention_lower) { + let rest = result[mention.len()..].trim_start(); + if rest.is_empty() { + return String::new(); + } + result = rest.to_string(); + } else if let Some(space_idx) = result.find(' ') { + // Different leading @mention - only strip if it's the bot + let first_word = &result[..space_idx]; + if first_word.eq_ignore_ascii_case(&mention) { + result = result[space_idx..].trim_start().to_string(); + } + } } else { - // Just a mention with no text - return String::new(); + // No bot_username: strip any leading @mention + if let Some(space_idx) = result.find(' ') { + result = result[space_idx..].trim_start().to_string(); + } else { + return String::new(); + } } } @@ -952,12 +1150,22 @@ mod tests { #[test] fn test_clean_message_text() { - assert_eq!(clean_message_text("/start hello"), "hello"); - assert_eq!(clean_message_text("@bot hello world"), "hello world"); - assert_eq!(clean_message_text("/start"), ""); - assert_eq!(clean_message_text("@botname"), ""); - assert_eq!(clean_message_text("just text"), "just text"); - assert_eq!(clean_message_text(" spaced "), "spaced"); + // Without bot_username: strips any leading @mention + assert_eq!(clean_message_text("/start hello", None), "hello"); + assert_eq!(clean_message_text("@bot hello world", None), "hello world"); + assert_eq!(clean_message_text("/start", None), ""); + assert_eq!(clean_message_text("@botname", None), ""); + assert_eq!(clean_message_text("just text", None), "just text"); + assert_eq!(clean_message_text(" spaced ", None), "spaced"); + + // With bot_username: only strips @MyBot, not @alice + assert_eq!(clean_message_text("@MyBot hello", Some("MyBot")), "hello"); + assert_eq!(clean_message_text("@mybot hi", Some("MyBot")), "hi"); + assert_eq!( + clean_message_text("@alice hello", Some("MyBot")), + "@alice hello" + ); + assert_eq!(clean_message_text("@MyBot", Some("MyBot")), ""); } #[test] @@ -1025,4 +1233,17 @@ mod tests { assert_eq!(from.id, 789); assert_eq!(from.first_name, "John"); } + + #[test] + fn test_parse_message_with_caption() { + let json = r#"{ + "message_id": 1, + "from": {"id": 1, "is_bot": false, "first_name": "A"}, + "chat": {"id": 1, "type": "private"}, + "caption": "What's in this image?" + }"#; + let msg: TelegramMessage = serde_json::from_str(json).unwrap(); + assert_eq!(msg.text, None); + assert_eq!(msg.caption.as_deref(), Some("What's in this image?")); + } } diff --git a/channels-src/telegram/telegram.capabilities.json b/channels-src/telegram/telegram.capabilities.json index f9335ad0..41735b52 100644 --- a/channels-src/telegram/telegram.capabilities.json +++ b/channels-src/telegram/telegram.capabilities.json @@ -1,44 +1 @@ -{ - "type": "channel", - "name": "telegram", - "description": "Telegram Bot API channel for receiving and responding to Telegram messages", - "capabilities": { - "http": { - "allowlist": [ - { "host": "api.telegram.org", "path_prefix": "/bot" } - ], - "credentials": { - "telegram_bot": { - "secret_name": "telegram_bot_token", - "location": { "type": "url_path", "placeholder": "{TELEGRAM_BOT_TOKEN}" }, - "host_patterns": ["api.telegram.org"] - } - }, - "rate_limit": { - "requests_per_minute": 30, - "requests_per_hour": 1000 - } - }, - "secrets": { - "allowed_names": ["telegram_*"] - }, - "channel": { - "allowed_paths": ["/webhook/telegram"], - "allow_polling": true, - "min_poll_interval_ms": 30000, - "callback_timeout_secs": 45, - "workspace_prefix": "channels/telegram/", - "emit_rate_limit": { - "messages_per_minute": 100, - "messages_per_hour": 5000 - } - } - }, - "config": { - "bot_username": null, - "owner_id": null, - "respond_to_all_group_messages": false, - "polling_enabled": false, - "poll_interval_ms": 30000 - } -} +{"type":"channel","name":"telegram","description":"Telegram Bot API channel for receiving and responding to Telegram messages","capabilities":{"http":{"allowlist":[{"host":"api.telegram.org","path_prefix":"/bot"}],"credentials":{"telegram_bot":{"secret_name":"telegram_bot_token","location":{"type":"url_path","placeholder":"{TELEGRAM_BOT_TOKEN}"},"host_patterns":["api.telegram.org"]}},"rate_limit":{"requests_per_minute":30,"requests_per_hour":1000}},"secrets":{"allowed_names":["telegram_*"]},"channel":{"allowed_paths":["/webhook/telegram"],"allow_polling":true,"min_poll_interval_ms":30000,"workspace_prefix":"channels/telegram/","emit_rate_limit":{"messages_per_minute":100,"messages_per_hour":5000}}},"config":{"bot_username":null,"owner_id":null,"respond_to_all_group_messages":false,"polling_enabled":false,"poll_interval_ms":30000,"dm_policy":"pairing","allow_from":[]}} diff --git a/docs/BUILDING_CHANNELS.md b/docs/BUILDING_CHANNELS.md index a819bc01..4fad5756 100644 --- a/docs/BUILDING_CHANNELS.md +++ b/docs/BUILDING_CHANNELS.md @@ -246,13 +246,46 @@ Create `my-channel.capabilities.json`: ## Building and Deploying +### Supply Chain Security: No Committed Binaries + +**Do not commit compiled WASM binaries.** They are a supply chain risk — the binary in a PR may not match the source. IronClaw builds channels from source: + +- `cargo build` automatically builds `telegram.wasm` via `build.rs` +- The built binary is in `.gitignore` and is not committed +- CI should run `cargo build` (or `./scripts/build-all.sh`) to produce releases + +**Reproducible build:** +```bash +cargo build --release +``` + +Prerequisites: `rustup target add wasm32-wasip2`, `cargo install wasm-tools` (optional; fallback copies raw WASM if unavailable). + +### Telegram Channel (Manual Build) + +```bash +# Add WASM target if needed +rustup target add wasm32-wasip2 + +# Build Telegram channel +./channels-src/telegram/build.sh + +# Install (or use ironclaw onboard to install bundled channel) +mkdir -p ~/.ironclaw/channels +cp channels-src/telegram/telegram.wasm channels-src/telegram/telegram.capabilities.json ~/.ironclaw/channels/ +``` + +**Note**: The main IronClaw binary bundles `telegram.wasm` via `include_bytes!`. When modifying the Telegram channel source, run `./channels-src/telegram/build.sh` **before** building the main crate, so the updated WASM is included. + +### Other Channels + ```bash # Build the WASM component -cd channels/my-channel -cargo component build --release +cd channels-src/my-channel +cargo build --release --target wasm32-wasip2 # Deploy to ~/.ironclaw/channels/ -cp target/wasm32-wasip1/release/my_channel.wasm ~/.ironclaw/channels/my-channel.wasm +cp target/wasm32-wasip2/release/my_channel.wasm ~/.ironclaw/channels/my-channel.wasm cp my-channel.capabilities.json ~/.ironclaw/channels/ ``` diff --git a/docs/TELEGRAM_SETUP.md b/docs/TELEGRAM_SETUP.md new file mode 100644 index 00000000..f9ec24eb --- /dev/null +++ b/docs/TELEGRAM_SETUP.md @@ -0,0 +1,135 @@ +# Telegram Channel Setup + +This guide covers configuring the Telegram channel for IronClaw, including DM pairing for access control. + +## Overview + +The Telegram channel lets you interact with IronClaw via Telegram DMs and groups. It supports: + +- **Webhook mode** (recommended): Instant delivery via tunnel +- **Polling mode**: No tunnel required; ~30s delay +- **DM pairing**: Approve unknown users before they can message the agent +- **Group mentions**: `@YourBot` or `/command` to trigger in groups + +## Prerequisites + +- IronClaw installed and configured (`ironclaw onboard`) +- A Telegram bot token from [@BotFather](https://t.me/BotFather) + +## Quick Start + +### 1. Create a Bot + +1. Message [@BotFather](https://t.me/BotFather) on Telegram +2. Send `/newbot` and follow the prompts +3. Copy the bot token (e.g., `123456789:ABCdefGHIjklMNOpqrsTUVwxyz`) + +### 2. Configure via Setup Wizard + +```bash +ironclaw onboard +``` + +When prompted, enable the Telegram channel and paste your bot token. The wizard will: + +- Validate the token +- Optionally configure a webhook secret +- Set up tunnel (if you want webhook mode) + +### 3. (Optional) Configure Tunnel for Webhooks + +For instant message delivery, expose your agent via a tunnel: + +```bash +# ngrok +ngrok http 8080 + +# Cloudflare +cloudflared tunnel --url http://localhost:8080 +``` + +Set the tunnel URL in settings or via `TUNNEL_URL` env var. Without a tunnel, the channel uses polling (~30s delay). + +## DM Pairing + +When an unknown user DMs your bot, they receive a pairing code. You must approve them before they can message the agent. + +### Flow + +1. Unknown user sends a message to your bot +2. Bot replies: `To pair with this bot, run: ironclaw pairing approve telegram ABC12345` +3. You run: `ironclaw pairing approve telegram ABC12345` +4. User is added to the allow list; future messages are delivered + +### Commands + +```bash +# List pending pairing requests +ironclaw pairing list telegram + +# List as JSON +ironclaw pairing list telegram --json + +# Approve a user by code +ironclaw pairing approve telegram ABC12345 +``` + +### Configuration + +Edit `~/.ironclaw/channels/telegram.capabilities.json` (or the config injected by the host): + +| Option | Values | Default | Description | +|--------|--------|---------|-------------| +| `dm_policy` | `open`, `allowlist`, `pairing` | `pairing` | `open` = allow all; `allowlist` = config + approved only; `pairing` = allowlist + send pairing reply to unknown | +| `allow_from` | `["user_id", "username", "*"]` | `[]` | Pre-approved IDs/usernames. `*` allows everyone. | +| `owner_id` | Telegram user ID | `null` | When set, only this user can message (overrides dm_policy) | +| `bot_username` | Bot username (no @) | `null` | Used for mention detection in groups; when set, only strips this mention from messages | +| `respond_to_all_group_messages` | `true`/`false` | `false` | When true, respond to all group messages; when false, only @mentions and /commands | + +## Manual Installation + +If the channel isn't installed via the wizard: + +```bash +# Build the Telegram channel (requires wasm32-wasip2 target) +rustup target add wasm32-wasip2 +./channels-src/telegram/build.sh + +# Install +mkdir -p ~/.ironclaw/channels +cp channels-src/telegram/telegram.wasm channels-src/telegram/telegram.capabilities.json ~/.ironclaw/channels/ +``` + +## Secrets + +The channel expects a secret named `telegram_bot_token`. Configure via: + +- **Setup wizard**: Saves to encrypted secrets store +- **Environment**: `TELEGRAM_BOT_TOKEN=your_token` +- **Secrets store**: `ironclaw` CLI (if available) + +## Webhook Secret (Optional) + +For webhook validation, set `telegram_webhook_secret` in secrets. Telegram will send `X-Telegram-Bot-Api-Secret-Token` with each request; the host validates it before forwarding. + +## Troubleshooting + +### Messages not delivered + +- **Polling mode**: Check logs for `getUpdates` errors. Ensure the bot token is valid. +- **Webhook mode**: Verify tunnel is running and `TUNNEL_URL` is correct. Telegram requires HTTPS. + +### Pairing code not received + +- Verify the channel can send messages (HTTP allowlist includes `api.telegram.org`) +- Check `dm_policy` is `pairing` (not `allowlist` which blocks without reply) + +### Group mentions not working + +- Set `bot_username` in config to your bot's username (e.g., `MyIronClawBot`) +- Ensure the message contains `@YourBot` or starts with `/` + +### "Connection refused" when starting + +- For webhook mode: Start your tunnel before `ironclaw run` +- For polling only: No tunnel needed; ignore tunnel-related warnings diff --git a/scripts/build-all.sh b/scripts/build-all.sh new file mode 100755 index 00000000..713940a1 --- /dev/null +++ b/scripts/build-all.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Build IronClaw and all bundled channels. +# +# Run this before release or when channel sources have changed. +# The main binary bundles telegram.wasm via include_bytes!; it must exist. + +set -euo pipefail + +cd "$(dirname "$0")/.." + +echo "Building bundled channels..." +if [ -d "channels-src/telegram" ]; then + ./channels-src/telegram/build.sh +fi + +echo "" +echo "Building IronClaw..." +cargo build --release + +echo "" +echo "Done. Binary: target/release/ironclaw" diff --git a/src/channels/wasm/loader.rs b/src/channels/wasm/loader.rs index 00dfcd80..3f7fdb63 100644 --- a/src/channels/wasm/loader.rs +++ b/src/channels/wasm/loader.rs @@ -16,16 +16,21 @@ use crate::channels::wasm::error::WasmChannelError; use crate::channels::wasm::runtime::WasmChannelRuntime; use crate::channels::wasm::schema::ChannelCapabilitiesFile; use crate::channels::wasm::wrapper::WasmChannel; +use crate::pairing::PairingStore; /// Loads WASM channels from the filesystem. pub struct WasmChannelLoader { runtime: Arc, + pairing_store: Arc, } impl WasmChannelLoader { - /// Create a new loader with the given runtime. - pub fn new(runtime: Arc) -> Self { - Self { runtime } + /// Create a new loader with the given runtime and pairing store. + pub fn new(runtime: Arc, pairing_store: Arc) -> Self { + Self { + runtime, + pairing_store, + } } /// Load a single WASM channel from a file pair. @@ -114,7 +119,13 @@ impl WasmChannelLoader { .await?; // Create the channel - let channel = WasmChannel::new(self.runtime.clone(), prepared, capabilities, config_json); + let channel = WasmChannel::new( + self.runtime.clone(), + prepared, + capabilities, + config_json, + self.pairing_store.clone(), + ); tracing::info!( name = name, @@ -352,6 +363,7 @@ mod tests { use crate::channels::wasm::loader::{WasmChannelLoader, discover_channels}; use crate::channels::wasm::runtime::{WasmChannelRuntime, WasmChannelRuntimeConfig}; + use crate::pairing::PairingStore; use std::sync::Arc; #[tokio::test] @@ -408,7 +420,7 @@ mod tests { async fn test_loader_invalid_name() { let config = WasmChannelRuntimeConfig::for_testing(); let runtime = Arc::new(WasmChannelRuntime::new(config).unwrap()); - let loader = WasmChannelLoader::new(runtime); + let loader = WasmChannelLoader::new(runtime, Arc::new(PairingStore::new())); let dir = TempDir::new().unwrap(); let wasm_path = dir.path().join("test.wasm"); diff --git a/src/channels/wasm/router.rs b/src/channels/wasm/router.rs index 0bd3182f..5ede9a13 100644 --- a/src/channels/wasm/router.rs +++ b/src/channels/wasm/router.rs @@ -469,7 +469,7 @@ pub fn create_wasm_channel_router( } #[cfg(test)] -mod tests { + mod tests { use std::sync::Arc; use crate::channels::wasm::capabilities::ChannelCapabilities; @@ -478,6 +478,7 @@ mod tests { PreparedChannelModule, WasmChannelRuntime, WasmChannelRuntimeConfig, }; use crate::channels::wasm::wrapper::WasmChannel; + use crate::pairing::PairingStore; use crate::tools::wasm::ResourceLimits; fn create_test_channel(name: &str) -> Arc { @@ -499,6 +500,7 @@ mod tests { prepared, capabilities, "{}".to_string(), + Arc::new(PairingStore::new()), )) } diff --git a/src/channels/wasm/wrapper.rs b/src/channels/wasm/wrapper.rs index 127fa372..bc7f0ac8 100644 --- a/src/channels/wasm/wrapper.rs +++ b/src/channels/wasm/wrapper.rs @@ -43,6 +43,7 @@ use wasmtime_wasi::{ResourceTable, WasiCtx, WasiCtxBuilder, WasiView}; use crate::channels::wasm::capabilities::ChannelCapabilities; use crate::channels::wasm::error::WasmChannelError; use crate::channels::wasm::host::{ChannelEmitRateLimiter, ChannelHostState, EmittedMessage}; +use crate::pairing::PairingStore; use crate::channels::wasm::router::RegisteredEndpoint; use crate::channels::wasm::runtime::{PreparedChannelModule, WasmChannelRuntime}; use crate::channels::wasm::schema::ChannelConfig; @@ -73,6 +74,8 @@ struct ChannelStoreData { /// Injected credentials for URL substitution (e.g., bot tokens). /// Keys are placeholder names like "TELEGRAM_BOT_TOKEN". credentials: HashMap, + /// Pairing store for DM pairing (guest access control). + pairing_store: Arc, } impl ChannelStoreData { @@ -81,6 +84,7 @@ impl ChannelStoreData { channel_name: &str, capabilities: ChannelCapabilities, credentials: HashMap, + pairing_store: Arc, ) -> Self { // Create a minimal WASI context (no filesystem, no env vars for security) let wasi = WasiCtxBuilder::new().build(); @@ -91,6 +95,7 @@ impl ChannelStoreData { wasi, table: ResourceTable::new(), credentials, + pairing_store, } } @@ -403,6 +408,43 @@ impl near::agent::channel_host::Host for ChannelStoreData { } } } + + fn pairing_upsert_request( + &mut self, + channel: String, + id: String, + meta_json: String, + ) -> Result { + let meta = if meta_json.is_empty() { + None + } else { + serde_json::from_str(&meta_json).ok() + }; + match self.pairing_store.upsert_request(&channel, &id, meta) { + Ok(r) => Ok(near::agent::channel_host::PairingUpsertResult { + code: r.code, + created: r.created, + }), + Err(e) => Err(e.to_string()), + } + } + + fn pairing_is_allowed( + &mut self, + channel: String, + id: String, + username: Option, + ) -> Result { + self.pairing_store + .is_sender_allowed(&channel, &id, username.as_deref()) + .map_err(|e| e.to_string()) + } + + fn pairing_read_allow_from(&mut self, channel: String) -> Result, String> { + self.pairing_store + .read_allow_from(&channel) + .map_err(|e| e.to_string()) + } } /// A WASM-based channel implementing the Channel trait. @@ -455,6 +497,9 @@ pub struct WasmChannel { /// Background task that repeats typing indicators every 4 seconds. /// Telegram's "typing..." indicator expires after ~5s, so we refresh it. typing_task: RwLock>>, + + /// Pairing store for DM pairing (guest access control). + pairing_store: Arc, } impl WasmChannel { @@ -464,6 +509,7 @@ impl WasmChannel { prepared: Arc, capabilities: ChannelCapabilities, config_json: String, + pairing_store: Arc, ) -> Self { let name = prepared.name.clone(); let rate_limiter = ChannelEmitRateLimiter::new(capabilities.emit_rate_limit.clone()); @@ -483,6 +529,7 @@ impl WasmChannel { endpoints: RwLock::new(Vec::new()), credentials: Arc::new(RwLock::new(HashMap::new())), typing_task: RwLock::new(None), + pairing_store, } } @@ -564,6 +611,7 @@ impl WasmChannel { prepared: &PreparedChannelModule, capabilities: &ChannelCapabilities, credentials: HashMap, + pairing_store: Arc, ) -> Result, WasmChannelError> { let engine = runtime.engine(); let limits = &prepared.limits; @@ -574,6 +622,7 @@ impl WasmChannel { &prepared.name, capabilities.clone(), credentials, + pairing_store, ); let mut store = Store::new(engine, store_data); @@ -674,12 +723,18 @@ impl WasmChannel { let timeout = self.runtime.config().callback_timeout; let channel_name = self.name.clone(); let credentials = self.get_credentials().await; + let pairing_store = self.pairing_store.clone(); // Execute in blocking task with timeout let result = tokio::time::timeout(timeout, async move { tokio::task::spawn_blocking(move || { - let mut store = - Self::create_store(&runtime, &prepared, &capabilities, credentials)?; + let mut store = Self::create_store( + &runtime, + &prepared, + &capabilities, + credentials, + pairing_store, + )?; let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?; // Call on_start using the generated typed interface @@ -784,6 +839,7 @@ impl WasmChannel { let capabilities = self.capabilities.clone(); let timeout = self.runtime.config().callback_timeout; let credentials = self.get_credentials().await; + let pairing_store = self.pairing_store.clone(); // Prepare request data let method = method.to_string(); @@ -797,8 +853,13 @@ impl WasmChannel { // Execute in blocking task with timeout let result = tokio::time::timeout(timeout, async move { tokio::task::spawn_blocking(move || { - let mut store = - Self::create_store(&runtime, &prepared, &capabilities, credentials)?; + let mut store = Self::create_store( + &runtime, + &prepared, + &capabilities, + credentials, + pairing_store, + )?; let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?; // Build the WIT request type @@ -871,12 +932,18 @@ impl WasmChannel { let timeout = self.runtime.config().callback_timeout; let channel_name = self.name.clone(); let credentials = self.get_credentials().await; + let pairing_store = self.pairing_store.clone(); // Execute in blocking task with timeout let result = tokio::time::timeout(timeout, async move { tokio::task::spawn_blocking(move || { - let mut store = - Self::create_store(&runtime, &prepared, &capabilities, credentials)?; + let mut store = Self::create_store( + &runtime, + &prepared, + &capabilities, + credentials, + pairing_store, + )?; let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?; // Call on_poll using the generated typed interface @@ -960,6 +1027,7 @@ impl WasmChannel { let timeout = self.runtime.config().callback_timeout; let channel_name = self.name.clone(); let credentials = self.get_credentials().await; + let pairing_store = self.pairing_store.clone(); // Prepare response data let message_id_str = message_id.to_string(); @@ -973,8 +1041,13 @@ impl WasmChannel { let result = tokio::time::timeout(timeout, async move { tokio::task::spawn_blocking(move || { tracing::info!("Creating WASM store for on_respond"); - let mut store = - Self::create_store(&runtime, &prepared, &capabilities, credentials)?; + let mut store = Self::create_store( + &runtime, + &prepared, + &capabilities, + credentials, + pairing_store, + )?; tracing::info!("Instantiating WASM component for on_respond"); let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?; @@ -1067,13 +1140,19 @@ impl WasmChannel { let timeout = self.runtime.config().callback_timeout; let channel_name = self.name.clone(); let credentials = self.get_credentials().await; + let pairing_store = self.pairing_store.clone(); let wit_update = status_to_wit(status, metadata); let result = tokio::time::timeout(timeout, async move { tokio::task::spawn_blocking(move || { - let mut store = - Self::create_store(&runtime, &prepared, &capabilities, credentials)?; + let mut store = Self::create_store( + &runtime, + &prepared, + &capabilities, + credentials, + pairing_store, + )?; let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?; let channel_iface = instance.near_agent_channel(); @@ -1117,6 +1196,7 @@ impl WasmChannel { prepared: &Arc, capabilities: &ChannelCapabilities, credentials: &RwLock>, + pairing_store: Arc, timeout: Duration, wit_update: wit_channel::StatusUpdate, ) -> Result<(), WasmChannelError> { @@ -1132,8 +1212,13 @@ impl WasmChannel { let result = tokio::time::timeout(timeout, async move { tokio::task::spawn_blocking(move || { - let mut store = - Self::create_store(&runtime, &prepared, &capabilities, credentials_snapshot)?; + let mut store = Self::create_store( + &runtime, + &prepared, + &capabilities, + credentials_snapshot, + pairing_store, + )?; let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?; let channel_iface = instance.near_agent_channel(); @@ -1201,6 +1286,7 @@ impl WasmChannel { let prepared = Arc::clone(&self.prepared); let capabilities = self.capabilities.clone(); let credentials = self.credentials.clone(); + let pairing_store = self.pairing_store.clone(); let callback_timeout = self.runtime.config().callback_timeout; let wit_update = status_to_wit(&status, metadata); @@ -1220,6 +1306,7 @@ impl WasmChannel { &prepared, &capabilities, &credentials, + pairing_store.clone(), callback_timeout, wit_update_clone, ) @@ -1350,6 +1437,7 @@ impl WasmChannel { let message_tx = self.message_tx.clone(); let rate_limiter = self.rate_limiter.clone(); let credentials = self.credentials.clone(); + let pairing_store = self.pairing_store.clone(); let callback_timeout = self.runtime.config().callback_timeout; tokio::spawn(async move { @@ -1371,6 +1459,7 @@ impl WasmChannel { &prepared, &capabilities, &credentials, + pairing_store.clone(), callback_timeout, ).await; @@ -1422,6 +1511,7 @@ impl WasmChannel { prepared: &Arc, capabilities: &ChannelCapabilities, credentials: &RwLock>, + pairing_store: Arc, timeout: Duration, ) -> Result, WasmChannelError> { // Skip if no WASM bytes (testing mode) @@ -1442,8 +1532,13 @@ impl WasmChannel { // Execute in blocking task with timeout let result = tokio::time::timeout(timeout, async move { tokio::task::spawn_blocking(move || { - let mut store = - Self::create_store(&runtime, &prepared, &capabilities, credentials_snapshot)?; + let mut store = Self::create_store( + &runtime, + &prepared, + &capabilities, + credentials_snapshot, + pairing_store, + )?; let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?; // Call on_poll using the generated typed interface @@ -1978,6 +2073,7 @@ mod tests { use std::sync::Arc; use crate::channels::Channel; + use crate::pairing::PairingStore; use crate::channels::wasm::capabilities::ChannelCapabilities; use crate::channels::wasm::runtime::{ PreparedChannelModule, WasmChannelRuntime, WasmChannelRuntimeConfig, @@ -1998,7 +2094,13 @@ mod tests { let capabilities = ChannelCapabilities::for_channel("test").with_path("/webhook/test"); - WasmChannel::new(runtime, prepared, capabilities, "{}".to_string()) + WasmChannel::new( + runtime, + prepared, + capabilities, + "{}".to_string(), + Arc::new(PairingStore::new()), + ) } #[test] @@ -2073,6 +2175,7 @@ mod tests { &prepared, &capabilities, &credentials, + Arc::new(PairingStore::new()), timeout, ) .await; @@ -2166,7 +2269,13 @@ mod tests { .with_path("/webhook/poll") .with_polling(1000); - let channel = WasmChannel::new(runtime, prepared, capabilities, "{}".to_string()); + let channel = WasmChannel::new( + runtime, + prepared, + capabilities, + "{}".to_string(), + Arc::new(PairingStore::new()), + ); // Start the channel let _stream = channel.start().await.expect("Channel should start"); diff --git a/src/cli/mod.rs b/src/cli/mod.rs index a8a0de3c..5823ab68 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -12,12 +12,14 @@ mod config; mod mcp; pub mod memory; +mod pairing; pub mod status; mod tool; pub use config::{ConfigCommand, run_config_command}; pub use mcp::{McpCommand, run_mcp_command}; pub use memory::{MemoryCommand, run_memory_command}; +pub use pairing::{PairingCommand, run_pairing_command, run_pairing_command_with_store}; pub use status::run_status_command; pub use tool::{ToolCommand, run_tool_command}; @@ -86,6 +88,10 @@ pub enum Command { #[command(subcommand)] Memory(MemoryCommand), + /// DM pairing (approve inbound requests from unknown senders) + #[command(subcommand)] + Pairing(PairingCommand), + /// Show system health and diagnostics Status, diff --git a/src/cli/pairing.rs b/src/cli/pairing.rs new file mode 100644 index 00000000..ef00ec52 --- /dev/null +++ b/src/cli/pairing.rs @@ -0,0 +1,183 @@ +//! DM pairing CLI commands. +//! +//! Manage pairing requests for channels (Telegram, Slack, etc.). + +use clap::Subcommand; + +use crate::pairing::PairingStore; + +/// Pairing subcommands. +#[derive(Subcommand, Debug, Clone)] +pub enum PairingCommand { + /// List pending pairing requests + List { + /// Channel name (e.g., telegram, slack) + #[arg(required = true)] + channel: String, + + /// Output as JSON + #[arg(long)] + json: bool, + }, + + /// Approve a pairing request by code + Approve { + /// Channel name (e.g., telegram, slack) + #[arg(required = true)] + channel: String, + + /// Pairing code (e.g., ABC12345) + #[arg(required = true)] + code: String, + }, +} + +/// Run pairing CLI command. +pub fn run_pairing_command(cmd: PairingCommand) -> Result<(), String> { + run_pairing_command_with_store(&PairingStore::new(), cmd) +} + +/// Run pairing CLI command with a given store (for testing). +pub fn run_pairing_command_with_store( + store: &PairingStore, + cmd: PairingCommand, +) -> Result<(), String> { + match cmd { + PairingCommand::List { channel, json } => run_list(store, &channel, json), + PairingCommand::Approve { channel, code } => run_approve(store, &channel, &code), + } +} + +fn run_list(store: &PairingStore, channel: &str, json: bool) -> Result<(), String> { + let requests = store.list_pending(channel).map_err(|e| e.to_string())?; + + if json { + println!("{}", serde_json::to_string_pretty(&requests).map_err(|e| e.to_string())?); + return Ok(()); + } + + if requests.is_empty() { + println!("No pending {} pairing requests.", channel); + return Ok(()); + } + + println!("Pairing requests ({}):", requests.len()); + for r in &requests { + let meta = r + .meta + .as_ref() + .and_then(|m| m.as_object()) + .map(|o| { + o.iter() + .filter_map(|(k, v)| { + v.as_str().map(|s| format!("{}={}", k, s)) + }) + .collect::>() + .join(", ") + }) + .unwrap_or_default(); + println!(" {} {} {} {}", r.code, r.id, meta, r.created_at); + } + + Ok(()) +} + +fn run_approve(store: &PairingStore, channel: &str, code: &str) -> Result<(), String> { + match store.approve(channel, code) { + Ok(Some(entry)) => { + println!("Approved {} sender {}.", channel, entry.id); + Ok(()) + } + Ok(None) => Err(format!("No pending pairing request found for code: {}", code)), + Err(crate::pairing::PairingStoreError::ApproveRateLimited) => Err( + "Too many failed approve attempts. Wait a few minutes before trying again.".to_string(), + ), + Err(e) => Err(e.to_string()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn test_store() -> (PairingStore, TempDir) { + let dir = TempDir::new().unwrap(); + let store = PairingStore::with_base_dir(dir.path().to_path_buf()); + (store, dir) + } + + #[test] + fn test_list_empty_returns_ok() { + let (store, _) = test_store(); + let result = run_pairing_command_with_store( + &store, + PairingCommand::List { + channel: "telegram".to_string(), + json: false, + }, + ); + assert!(result.is_ok()); + } + + #[test] + fn test_list_json_empty_returns_ok() { + let (store, _) = test_store(); + let result = run_pairing_command_with_store( + &store, + PairingCommand::List { + channel: "telegram".to_string(), + json: true, + }, + ); + assert!(result.is_ok()); + } + + #[test] + fn test_approve_invalid_code_returns_err() { + let (store, _) = test_store(); + // Create a pending request so the pairing file exists, then approve with wrong code + store.upsert_request("telegram", "user1", None).unwrap(); + + let result = run_pairing_command_with_store( + &store, + PairingCommand::Approve { + channel: "telegram".to_string(), + code: "BADCODE1".to_string(), + }, + ); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("No pending pairing request")); + } + + #[test] + fn test_approve_valid_code_returns_ok() { + let (store, _) = test_store(); + let r = store.upsert_request("telegram", "user1", None).unwrap(); + assert!(r.created); + + let result = run_pairing_command_with_store( + &store, + PairingCommand::Approve { + channel: "telegram".to_string(), + code: r.code, + }, + ); + assert!(result.is_ok()); + } + + #[test] + fn test_list_with_pending_returns_ok() { + let (store, _) = test_store(); + store.upsert_request("telegram", "user1", None).unwrap(); + + let result = run_pairing_command_with_store( + &store, + PairingCommand::List { + channel: "telegram".to_string(), + json: false, + }, + ); + assert!(result.is_ok()); + } +} diff --git a/src/lib.rs b/src/lib.rs index 6f3c5911..af5f0a1a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -43,6 +43,7 @@ pub mod bootstrap; pub mod channels; pub mod cli; pub mod config; +pub mod pairing; pub mod context; pub mod error; pub mod estimation; diff --git a/src/main.rs b/src/main.rs index 8dcd69ed..d2ab5740 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,6 +7,7 @@ use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitEx use ironclaw::{ agent::{Agent, AgentDeps, SessionManager}, + pairing::PairingStore, channels::{ ChannelManager, GatewayChannel, HttpChannel, ReplChannel, WebhookServer, WebhookServerConfig, @@ -17,7 +18,8 @@ use ironclaw::{ web::log_layer::{LogBroadcaster, WebLogLayer}, }, cli::{ - Cli, Command, run_mcp_command, run_memory_command, run_status_command, run_tool_command, + Cli, Command, run_mcp_command, run_memory_command, run_pairing_command, run_status_command, + run_tool_command, }, config::Config, context::ContextManager, @@ -128,6 +130,15 @@ async fn main() -> anyhow::Result<()> { return run_memory_command(mem_cmd.clone(), store.pool(), embeddings).await; } + Some(Command::Pairing(pairing_cmd)) => { + tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("warn")), + ) + .init(); + + return run_pairing_command(pairing_cmd.clone()).map_err(|e| anyhow::anyhow!("{}", e)); + } Some(Command::Status) => { let _ = dotenvy::dotenv(); tracing_subscriber::fmt() @@ -725,7 +736,8 @@ async fn main() -> anyhow::Result<()> { match WasmChannelRuntime::new(WasmChannelRuntimeConfig::default()) { Ok(runtime) => { let runtime = Arc::new(runtime); - let loader = WasmChannelLoader::new(Arc::clone(&runtime)); + let pairing_store = Arc::new(PairingStore::new()); + let loader = WasmChannelLoader::new(Arc::clone(&runtime), pairing_store); match loader .load_from_dir(&config.channels.wasm_channels_dir) diff --git a/src/pairing/mod.rs b/src/pairing/mod.rs new file mode 100644 index 00000000..6468524f --- /dev/null +++ b/src/pairing/mod.rs @@ -0,0 +1,10 @@ +//! DM pairing for channels. +//! +//! Gates DMs from unknown senders. Only approved senders can message the agent. +//! Unknown senders receive a pairing code and must be approved via `ironclaw pairing approve`. +//! +//! OpenClaw reference: src/pairing/pairing-store.ts + +mod store; + +pub use store::{PairingRequest, PairingStore, PairingStoreError}; diff --git a/src/pairing/store.rs b/src/pairing/store.rs new file mode 100644 index 00000000..941509d5 --- /dev/null +++ b/src/pairing/store.rs @@ -0,0 +1,669 @@ +//! Pairing store: pending requests and allowFrom list. +//! +//! Stored in ~/.ironclaw/{channel}-pairing.json and {channel}-allowFrom.json. + +use std::collections::HashSet; +use std::fs; +use std::io::{Seek, SeekFrom, Write}; +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; + +use fs4::FileExt; +use rand::Rng; +use serde::{Deserialize, Serialize}; + +const PAIRING_CODE_LENGTH: usize = 8; +const PAIRING_ALPHABET: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; +/// TTL for pending pairing requests (minutes, not hours — reduces brute-force window). +const PAIRING_PENDING_TTL_SECS: u64 = 15 * 60; +const PAIRING_PENDING_MAX: usize = 3; +/// Max failed approve attempts per channel before rate limit kicks in. +const PAIRING_APPROVE_RATE_LIMIT: usize = 10; +/// Time window for rate limit (seconds). +const PAIRING_APPROVE_RATE_WINDOW_SECS: u64 = 5 * 60; + +/// Error from pairing store operations. +#[derive(Debug, thiserror::Error)] +pub enum PairingStoreError { + #[error("Invalid channel: {0}")] + InvalidChannel(String), + + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + #[error("JSON error: {0}")] + Json(#[from] serde_json::Error), + + #[error("Rate limit: too many failed approve attempts; try again later")] + ApproveRateLimited, +} + +/// Result of upserting a pairing request. +#[derive(Debug)] +pub struct UpsertResult { + pub code: String, + pub created: bool, +} + +/// A pending pairing request. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PairingRequest { + pub id: String, + pub code: String, + pub created_at: String, + pub last_seen_at: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub meta: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +struct PairingStoreFile { + version: u8, + requests: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +struct AllowFromStoreFile { + version: u8, + #[serde(rename = "allowFrom")] + allow_from: Vec, +} + +fn default_pairing_dir() -> PathBuf { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".ironclaw") +} + +fn safe_channel_key(channel: &str) -> Result { + let raw = channel.trim().to_lowercase(); + if raw.is_empty() { + return Err(PairingStoreError::InvalidChannel("empty".to_string())); + } + let safe = raw + .chars() + .map(|c| match c { + '\\' | '/' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_', + _ => c, + }) + .collect::() + .replace("..", "_"); + if safe.is_empty() || safe == "_" { + return Err(PairingStoreError::InvalidChannel(channel.to_string())); + } + Ok(safe) +} + +fn pairing_path(base_dir: &PathBuf, channel: &str) -> Result { + let key = safe_channel_key(channel)?; + Ok(base_dir.join(format!("{}-pairing.json", key))) +} + +fn allow_from_path(base_dir: &PathBuf, channel: &str) -> Result { + let key = safe_channel_key(channel)?; + Ok(base_dir.join(format!("{}-allowFrom.json", key))) +} + +fn approve_attempts_path(base_dir: &PathBuf, channel: &str) -> Result { + let key = safe_channel_key(channel)?; + Ok(base_dir.join(format!("{}-approve-attempts.json", key))) +} + +#[derive(Debug, Default, Serialize, Deserialize)] +struct ApproveAttemptsFile { + failed_at: Vec, +} + +fn now_iso() -> String { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default(); + #[allow(clippy::cast_possible_wrap)] + chrono::DateTime::from_timestamp(now.as_secs() as i64, 0) + .map(|dt| dt.to_rfc3339()) + .unwrap_or_else(|| now.as_secs().to_string()) +} + +fn now_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +fn parse_timestamp(value: &str) -> Option { + chrono::DateTime::parse_from_rfc3339(value) + .ok() + .map(|dt| dt.timestamp() as u64) + .or_else(|| value.parse::().ok()) +} + +fn is_expired(req: &PairingRequest, now_secs: u64) -> bool { + let created = parse_timestamp(&req.created_at).unwrap_or(0); + now_secs.saturating_sub(created) > PAIRING_PENDING_TTL_SECS +} + +fn random_code() -> String { + let mut rng = rand::thread_rng(); + (0..PAIRING_CODE_LENGTH) + .map(|_| { + let idx = rng.gen_range(0..PAIRING_ALPHABET.len()); + PAIRING_ALPHABET[idx] as char + }) + .collect() +} + +fn generate_unique_code(existing: &HashSet) -> String { + let mut rng = rand::thread_rng(); + for _ in 0..500 { + let code = random_code(); + if !existing.contains(&code) { + return code; + } + } + // Fallback: add suffix + format!("{}{:04}", random_code(), rng.gen_range(0..10000)) +} + +/// Pairing store for a channel. +#[derive(Debug, Clone)] +pub struct PairingStore { + base_dir: PathBuf, +} + +impl PairingStore { + /// Create a new pairing store using default directory (~/.ironclaw). + pub fn new() -> Self { + Self { + base_dir: default_pairing_dir(), + } + } + + /// Create a pairing store with a custom base directory (for testing). + pub fn with_base_dir(base_dir: PathBuf) -> Self { + Self { base_dir } + } + + /// List pending pairing requests for a channel. + pub fn list_pending(&self, channel: &str) -> Result, PairingStoreError> { + let path = pairing_path(&self.base_dir, channel)?; + let content = match fs::read_to_string(&path) { + Ok(c) => c, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + return Ok(Vec::new()); + } + Err(e) => return Err(e.into()), + }; + + let file: PairingStoreFile = serde_json::from_str(&content).unwrap_or(PairingStoreFile { + version: 1, + requests: Vec::new(), + }); + + let now = now_secs(); + let original_len = file.requests.len(); + let mut requests: Vec<_> = file + .requests + .into_iter() + .filter(|r| !is_expired(r, now)) + .collect(); + + if requests.len() != original_len { + self.write_pairing_file(channel, &requests)?; + } + + requests.sort_by(|a, b| a.created_at.cmp(&b.created_at)); + Ok(requests) + } + + /// Upsert a pairing request. Returns (code, created). + pub fn upsert_request( + &self, + channel: &str, + id: &str, + meta: Option, + ) -> Result { + let path = pairing_path(&self.base_dir, channel)?; + fs::create_dir_all(path.parent().unwrap())?; + + let mut file = fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(&path)?; + + file.lock_exclusive()?; + + let content = fs::read_to_string(&path).unwrap_or_default(); + let mut store: PairingStoreFile = serde_json::from_str(&content).unwrap_or(PairingStoreFile { + version: 1, + requests: Vec::new(), + }); + + let now = now_iso(); + let now_secs = now_secs(); + let id = id.trim().to_string(); + if id.is_empty() { + fs4::FileExt::unlock(&file)?; + return Err(PairingStoreError::InvalidChannel("empty id".to_string())); + } + + store.requests.retain(|r| !is_expired(r, now_secs)); + let existing_codes: HashSet = store + .requests + .iter() + .map(|r| r.code.to_uppercase()) + .collect(); + + if let Some(idx) = store.requests.iter().position(|r| r.id == id) { + let req = &mut store.requests[idx]; + let code = if req.code.is_empty() { + generate_unique_code(&existing_codes) + } else { + req.code.clone() + }; + req.last_seen_at = now.clone(); + req.code = code.clone(); + if let Some(m) = meta { + req.meta = Some(m); + } + self.write_pairing_file_locked(&mut file, channel, &store.requests)?; + fs4::FileExt::unlock(&file)?; + return Ok(UpsertResult { + code, + created: false, + }); + } + + if store.requests.len() >= PAIRING_PENDING_MAX { + fs4::FileExt::unlock(&file)?; + return Ok(UpsertResult { + code: String::new(), + created: false, + }); + } + + let code = generate_unique_code(&existing_codes); + store.requests.push(PairingRequest { + id: id.clone(), + code: code.clone(), + created_at: now.clone(), + last_seen_at: now, + meta, + }); + + self.write_pairing_file_locked(&mut file, channel, &store.requests)?; + fs4::FileExt::unlock(&file)?; + + Ok(UpsertResult { code, created: true }) + } + + fn is_approve_rate_limited(&self, channel: &str) -> Result { + let path = approve_attempts_path(&self.base_dir, channel)?; + let content = match fs::read_to_string(&path) { + Ok(c) => c, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(e) => return Err(e.into()), + }; + let mut data: ApproveAttemptsFile = + serde_json::from_str(&content).unwrap_or_default(); + let now = now_secs(); + let cutoff = now.saturating_sub(PAIRING_APPROVE_RATE_WINDOW_SECS); + data.failed_at.retain(|&t| t >= cutoff); + Ok(data.failed_at.len() >= PAIRING_APPROVE_RATE_LIMIT) + } + + fn record_failed_approve(&self, channel: &str) -> Result<(), PairingStoreError> { + let path = approve_attempts_path(&self.base_dir, channel)?; + fs::create_dir_all(path.parent().unwrap())?; + let file = fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .open(&path)?; + file.lock_exclusive()?; + let content = fs::read_to_string(&path).unwrap_or_default(); + let mut data: ApproveAttemptsFile = + serde_json::from_str(&content).unwrap_or_default(); + let now = now_secs(); + data.failed_at.push(now); + let cutoff = now.saturating_sub(PAIRING_APPROVE_RATE_WINDOW_SECS); + data.failed_at.retain(|&t| t >= cutoff); + let json = serde_json::to_string_pretty(&data)?; + fs::write(&path, json)?; + fs4::FileExt::unlock(&file)?; + Ok(()) + } + + /// Approve a pairing code and add the sender to allowFrom. + pub fn approve( + &self, + channel: &str, + code: &str, + ) -> Result, PairingStoreError> { + let code = code.trim().to_uppercase(); + if code.is_empty() { + return Ok(None); + } + + if self.is_approve_rate_limited(channel)? { + return Err(PairingStoreError::ApproveRateLimited); + } + + let path = pairing_path(&self.base_dir, channel)?; + let mut file = fs::OpenOptions::new() + .read(true) + .write(true) + .create(false) + .open(&path) + .map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + PairingStoreError::InvalidChannel("no pairing file".to_string()) + } else { + PairingStoreError::Io(e) + } + })?; + + file.lock_exclusive()?; + + let content = fs::read_to_string(&path).unwrap_or_default(); + let mut store: PairingStoreFile = serde_json::from_str(&content).unwrap_or(PairingStoreFile { + version: 1, + requests: Vec::new(), + }); + + let now_secs = now_secs(); + store.requests.retain(|r| !is_expired(r, now_secs)); + + let idx = store + .requests + .iter() + .position(|r| r.code.to_uppercase() == code); + + let entry = match idx { + Some(i) => store.requests.remove(i), + None => { + fs4::FileExt::unlock(&file)?; + self.record_failed_approve(channel)?; + return Ok(None); + } + }; + + self.write_pairing_file_locked(&mut file, channel, &store.requests)?; + fs4::FileExt::unlock(&file)?; + + self.add_allow_from(channel, &entry.id)?; + + Ok(Some(entry)) + } + + /// Read the allowFrom list for a channel. + pub fn read_allow_from(&self, channel: &str) -> Result, PairingStoreError> { + let path = allow_from_path(&self.base_dir, channel)?; + let content = match fs::read_to_string(&path) { + Ok(c) => c, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + return Ok(Vec::new()); + } + Err(e) => return Err(e.into()), + }; + + let file: AllowFromStoreFile = serde_json::from_str(&content).unwrap_or(AllowFromStoreFile { + version: 1, + allow_from: Vec::new(), + }); + + Ok(file.allow_from) + } + + /// Check if a sender is allowed (by id or username). + pub fn is_sender_allowed( + &self, + channel: &str, + id: &str, + username: Option<&str>, + ) -> Result { + let allow = self.read_allow_from(channel)?; + let id = id.trim(); + let id_ok = allow.iter().any(|e| e.trim() == id); + if id_ok { + return Ok(true); + } + if let Some(u) = username { + let u = u.trim().to_lowercase(); + let u_norm = u.strip_prefix('@').unwrap_or(&u); + if allow + .iter() + .any(|e| e.trim().to_lowercase() == u || e.trim().to_lowercase() == format!("@{}", u_norm)) + { + return Ok(true); + } + } + Ok(false) + } + + fn add_allow_from(&self, channel: &str, entry: &str) -> Result<(), PairingStoreError> { + let entry = entry.trim().to_string(); + if entry.is_empty() { + return Ok(()); + } + + let path = allow_from_path(&self.base_dir, channel)?; + fs::create_dir_all(path.parent().unwrap())?; + + let file = fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .open(&path)?; + + file.lock_exclusive()?; + + let content = fs::read_to_string(&path).unwrap_or_default(); + let mut store: AllowFromStoreFile = + serde_json::from_str(&content).unwrap_or(AllowFromStoreFile { + version: 1, + allow_from: Vec::new(), + }); + + let normalized = entry.to_lowercase(); + if store + .allow_from + .iter() + .any(|e| e.to_lowercase() == normalized) + { + fs4::FileExt::unlock(&file)?; + return Ok(()); + } + + store.allow_from.push(entry); + let json = serde_json::to_string_pretty(&store)?; + fs::write(&path, json)?; + + fs4::FileExt::unlock(&file)?; + Ok(()) + } + + fn write_pairing_file( + &self, + channel: &str, + requests: &[PairingRequest], + ) -> Result<(), PairingStoreError> { + let path = pairing_path(&self.base_dir, channel)?; + let mut file = fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(&path)?; + file.lock_exclusive()?; + self.write_pairing_file_locked(&mut file, channel, requests)?; + fs4::FileExt::unlock(&file)?; + Ok(()) + } + + fn write_pairing_file_locked( + &self, + file: &mut fs::File, + _channel: &str, + requests: &[PairingRequest], + ) -> Result<(), PairingStoreError> { + let store = PairingStoreFile { + version: 1, + requests: requests.to_vec(), + }; + let json = serde_json::to_string_pretty(&store)?; + file.set_len(0)?; + file.seek(SeekFrom::Start(0))?; + file.write_all(json.as_bytes())?; + file.sync_all()?; + Ok(()) + } +} + +impl Default for PairingStore { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn test_safe_channel_key() { + assert_eq!(safe_channel_key("telegram").unwrap(), "telegram"); + assert_eq!(safe_channel_key("Telegram").unwrap(), "telegram"); + safe_channel_key("").unwrap_err(); + } + + #[test] + fn test_random_code() { + let c = random_code(); + assert_eq!(c.len(), PAIRING_CODE_LENGTH); + assert!(c.chars().all(|c| PAIRING_ALPHABET.contains(&(c as u8)))); + } + + fn test_store() -> (PairingStore, TempDir) { + let dir = TempDir::new().unwrap(); + let store = PairingStore::with_base_dir(dir.path().to_path_buf()); + (store, dir) + } + + #[test] + fn test_list_pending_empty() { + let (store, _) = test_store(); + let requests = store.list_pending("telegram").unwrap(); + assert!(requests.is_empty()); + } + + #[test] + fn test_upsert_request_creates_new() { + let (store, _) = test_store(); + let result = store + .upsert_request("telegram", "user123", Some(serde_json::json!({"chat_id": 456}))) + .unwrap(); + assert!(result.created); + assert_eq!(result.code.len(), PAIRING_CODE_LENGTH); + assert!(result.code.chars().all(|c| PAIRING_ALPHABET.contains(&(c as u8)))); + } + + #[test] + fn test_upsert_request_updates_existing() { + let (store, _) = test_store(); + let r1 = store.upsert_request("telegram", "user123", None).unwrap(); + assert!(r1.created); + let r2 = store.upsert_request("telegram", "user123", Some(serde_json::json!({"x": 1}))).unwrap(); + assert!(!r2.created); + assert_eq!(r1.code, r2.code); + + let pending = store.list_pending("telegram").unwrap(); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].id, "user123"); + assert_eq!(pending[0].meta, Some(serde_json::json!({"x": 1}))); + } + + #[test] + fn test_approve_adds_to_allow_from() { + let (store, _) = test_store(); + let r = store.upsert_request("telegram", "user456", None).unwrap(); + assert!(r.created); + + let approved = store.approve("telegram", &r.code).unwrap(); + assert!(approved.is_some()); + assert_eq!(approved.unwrap().id, "user456"); + + let allow = store.read_allow_from("telegram").unwrap(); + assert_eq!(allow, vec!["user456"]); + } + + #[test] + fn test_approve_case_insensitive_code() { + let (store, _) = test_store(); + let r = store.upsert_request("telegram", "user789", None).unwrap(); + let code_lower = r.code.to_lowercase(); + let approved = store.approve("telegram", &code_lower).unwrap(); + assert!(approved.is_some()); + } + + #[test] + fn test_approve_invalid_code_returns_none() { + let (store, _) = test_store(); + store.upsert_request("telegram", "user123", None).unwrap(); + let approved = store.approve("telegram", "BADCODE1").unwrap(); + assert!(approved.is_none()); + } + + #[test] + fn test_approve_rate_limited_after_many_failures() { + let (store, _) = test_store(); + store.upsert_request("telegram", "user123", None).unwrap(); + for _ in 0..PAIRING_APPROVE_RATE_LIMIT { + let _ = store.approve("telegram", "WRONG01"); + } + let err = store.approve("telegram", "WRONG02").unwrap_err(); + assert!(matches!(err, PairingStoreError::ApproveRateLimited)); + } + + #[test] + fn test_is_sender_allowed_by_id() { + let (store, _) = test_store(); + let r = store.upsert_request("telegram", "user999", None).unwrap(); + store.approve("telegram", &r.code).unwrap(); + + assert!(store.is_sender_allowed("telegram", "user999", None).unwrap()); + assert!(!store.is_sender_allowed("telegram", "other", None).unwrap()); + } + + #[test] + fn test_is_sender_allowed_by_username() { + let (store, _) = test_store(); + store.upsert_request("telegram", "alice", Some(serde_json::json!({"username": "alice"}))).unwrap(); + let pending = store.list_pending("telegram").unwrap(); + store.approve("telegram", &pending[0].code).unwrap(); + + // approve adds id to allow_from. For username we need to add it manually. + // Actually approve adds entry.id which is "alice". So is_sender_allowed("telegram", "alice", None) would work. + assert!(store.is_sender_allowed("telegram", "alice", None).unwrap()); + assert!(store.is_sender_allowed("telegram", "alice", Some("alice")).unwrap()); + } + + #[test] + fn test_channel_normalization() { + let (store, _) = test_store(); + store.upsert_request("Telegram", "u1", None).unwrap(); + let pending = store.list_pending("telegram").unwrap(); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].id, "u1"); + } + + #[test] + fn test_invalid_channel_rejected() { + let (store, _) = test_store(); + store.upsert_request("telegram", "u1", None).unwrap(); + store.list_pending("").unwrap_err(); + store.upsert_request("", "u1", None).unwrap_err(); + } +} diff --git a/tests/pairing_integration.rs b/tests/pairing_integration.rs new file mode 100644 index 00000000..041f7e97 --- /dev/null +++ b/tests/pairing_integration.rs @@ -0,0 +1,112 @@ +//! Integration tests for the DM pairing flow. +//! +//! Verifies the full pairing lifecycle: upsert → list → approve → allowFrom → is_sender_allowed. +//! Uses temp directory for isolation. + +use ironclaw::cli::{run_pairing_command_with_store, PairingCommand}; +use ironclaw::pairing::PairingStore; +use tempfile::TempDir; + +fn test_store() -> (PairingStore, TempDir) { + let dir = TempDir::new().unwrap(); + let store = PairingStore::with_base_dir(dir.path().to_path_buf()); + (store, dir) +} + +#[test] +fn test_pairing_flow_unknown_user_to_approved() { + let (store, _) = test_store(); + let channel = "telegram"; + + // 1. Unknown user sends first message -> upsert creates request + let r1 = store.upsert_request(channel, "user_12345", Some(serde_json::json!({ + "chat_id": 999, + "username": "alice" + }))).unwrap(); + assert!(r1.created); + assert!(!r1.code.is_empty()); + assert_eq!(r1.code.len(), 8); + + // 2. List pending shows the request + let pending = store.list_pending(channel).unwrap(); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].id, "user_12345"); + assert_eq!(pending[0].code, r1.code); + + // 3. User is not allowed yet + assert!(!store.is_sender_allowed(channel, "user_12345", Some("alice")).unwrap()); + + // 4. Approve via code + let approved = store.approve(channel, &r1.code).unwrap(); + assert!(approved.is_some()); + assert_eq!(approved.unwrap().id, "user_12345"); + + // 5. User is now allowed + assert!(store.is_sender_allowed(channel, "user_12345", None).unwrap()); + assert!(store.is_sender_allowed(channel, "user_12345", Some("alice")).unwrap()); + + // 6. Pending list is empty + let pending_after = store.list_pending(channel).unwrap(); + assert!(pending_after.is_empty()); + + // 7. allowFrom contains the user + let allow = store.read_allow_from(channel).unwrap(); + assert_eq!(allow, vec!["user_12345"]); +} + +#[test] +fn test_pairing_flow_cli_approve() { + let (store, _) = test_store(); + store.upsert_request("telegram", "user_999", None).unwrap(); + let pending = store.list_pending("telegram").unwrap(); + let code = pending[0].code.clone(); + + let result = run_pairing_command_with_store( + &store, + PairingCommand::Approve { + channel: "telegram".to_string(), + code, + }, + ); + assert!(result.is_ok()); + assert!(store.is_sender_allowed("telegram", "user_999", None).unwrap()); +} + +#[test] +fn test_pairing_reject_invalid_code() { + let (store, _) = test_store(); + store.upsert_request("telegram", "user_1", None).unwrap(); + + let result = store.approve("telegram", "INVALID1"); + assert!(result.unwrap().is_none()); + + let result = run_pairing_command_with_store( + &store, + PairingCommand::Approve { + channel: "telegram".to_string(), + code: "BADCODE1".to_string(), + }, + ); + assert!(result.is_err()); +} + +#[test] +fn test_pairing_multiple_channels_isolated() { + let (store, _) = test_store(); + + let r_telegram = store.upsert_request("telegram", "user_a", None).unwrap(); + let r_slack = store.upsert_request("slack", "user_b", None).unwrap(); + + // Each channel has its own pending + assert_eq!(store.list_pending("telegram").unwrap().len(), 1); + assert_eq!(store.list_pending("slack").unwrap().len(), 1); + + // Approve in one channel doesn't affect the other + store.approve("telegram", &r_telegram.code).unwrap(); + assert!(store.is_sender_allowed("telegram", "user_a", None).unwrap()); + assert!(!store.is_sender_allowed("slack", "user_a", None).unwrap()); + + store.approve("slack", &r_slack.code).unwrap(); + assert!(store.is_sender_allowed("slack", "user_b", None).unwrap()); +} + diff --git a/tests/wasm_channel_integration.rs b/tests/wasm_channel_integration.rs index 7ac0b909..ca636e1f 100644 --- a/tests/wasm_channel_integration.rs +++ b/tests/wasm_channel_integration.rs @@ -10,6 +10,7 @@ use std::collections::HashMap; use std::sync::Arc; use ironclaw::channels::Channel; +use ironclaw::pairing::PairingStore; use ironclaw::channels::wasm::{ ChannelCapabilities, EmitRateLimitConfig, PreparedChannelModule, RegisteredEndpoint, WasmChannel, WasmChannelRouter, WasmChannelRuntime, WasmChannelRuntimeConfig, @@ -38,7 +39,13 @@ fn create_test_channel( capabilities = capabilities.with_path(path.to_string()); } - WasmChannel::new(runtime, prepared, capabilities, "{}".to_string()) + WasmChannel::new( + runtime, + prepared, + capabilities, + "{}".to_string(), + Arc::new(PairingStore::new()), + ) } mod router_tests { diff --git a/wit/channel.wit b/wit/channel.wit index 48fba6be..c716bc58 100644 --- a/wit/channel.wit +++ b/wit/channel.wit @@ -147,6 +147,32 @@ interface channel-host { /// - Path validation fails (traversal attempt, absolute path) /// - Write operation fails workspace-write: func(path: string, content: string) -> result<_, string>; + + // ==================== DM Pairing ==================== + + /// Result of upserting a pairing request. + record pairing-upsert-result { + code: string, + created: bool, + } + + /// Upsert a pairing request for an unknown sender. + /// Returns (code, created). When created is true, the channel should send a pairing reply. + pairing-upsert-request: func( + channel: string, + id: string, + meta-json: string + ) -> result; + + /// Check if a sender is allowed (in allowFrom store). + pairing-is-allowed: func( + channel: string, + id: string, + username: option + ) -> result; + + /// Read the allowFrom list (for merging with config allowFrom). + pairing-read-allow-from: func(channel: string) -> result, string>; } /// Channel interface that sandboxed channels must implement. From 09198c68ab048c4e953934842c017b8fcc82349e Mon Sep 17 00:00:00 2001 From: Vlad Frolov Date: Thu, 12 Feb 2026 12:25:36 +0100 Subject: [PATCH 07/33] ci: Added CI/CD and release pipelines (#45) --- .github/workflows/code_style.yml | 21 ++ .github/workflows/release-plz.yml | 29 +++ .github/workflows/release.yml | 324 ++++++++++++++++++++++++++++++ .github/workflows/test.yml | 20 ++ Cargo.toml | 53 +++++ build.rs | 17 +- examples/test_heartbeat.rs | 4 +- src/channels/wasm/router.rs | 2 +- src/channels/wasm/wrapper.rs | 24 ++- src/cli/config.rs | 4 +- src/cli/pairing.rs | 14 +- src/lib.rs | 2 +- src/main.rs | 6 +- src/pairing/store.rs | 92 ++++++--- src/secrets/keychain.rs | 26 +-- src/secrets/types.rs | 9 +- src/setup/wizard.rs | 9 +- src/tools/builtin/file.rs | 2 +- src/tools/mcp/config.rs | 7 +- src/tools/registry.rs | 2 +- src/tools/tool.rs | 94 ++++----- src/workspace/repository.rs | 15 +- tests/pairing_integration.rs | 41 +++- tests/wasm_channel_integration.rs | 2 +- wix/main.wxs | 228 +++++++++++++++++++++ 25 files changed, 886 insertions(+), 161 deletions(-) create mode 100644 .github/workflows/code_style.yml create mode 100644 .github/workflows/release-plz.yml create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/test.yml create mode 100644 wix/main.wxs diff --git a/.github/workflows/code_style.yml b/.github/workflows/code_style.yml new file mode 100644 index 00000000..c013e031 --- /dev/null +++ b/.github/workflows/code_style.yml @@ -0,0 +1,21 @@ +name: Code Style +on: + pull_request: + +jobs: + codestyle: + name: Code Style (fmt + clippy) + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + profile: minimal + components: rustfmt, clippy + - name: Check formatting + run: | + cargo fmt --all -- --check + - name: Check lints (cargo clippy) + run: cargo clippy -- -D warnings diff --git a/.github/workflows/release-plz.yml b/.github/workflows/release-plz.yml new file mode 100644 index 00000000..3feeb17b --- /dev/null +++ b/.github/workflows/release-plz.yml @@ -0,0 +1,29 @@ +name: Release-plz + +permissions: + pull-requests: write + contents: write + +on: + push: + branches: + - main + +jobs: + release-plz: + name: Release-plz + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v3 + with: + fetch-depth: 0 + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + - name: Install packages (Linux) + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install --assume-yes libudev-dev + - name: Run release-plz + uses: MarcoIeni/release-plz-action@v0.5 + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..118f02d0 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,324 @@ +# This file was autogenerated by dist: https://opensource.axo.dev/cargo-dist/ +# +# Copyright 2022-2024, axodotdev +# SPDX-License-Identifier: MIT or Apache-2.0 +# +# CI that: +# +# * checks for a Git Tag that looks like a release +# * builds artifacts with dist (archives, installers, hashes) +# * uploads those artifacts to temporary workflow zip +# * on success, uploads the artifacts to a GitHub Release +# +# Note that the GitHub Release will be created with a generated +# title/body based on your changelogs. + +name: Release +permissions: + "contents": "write" + +# This task will run whenever you push a git tag that looks like a version +# like "1.0.0", "v0.1.0-prerelease.1", "my-app/0.1.0", "releases/v1.0.0", etc. +# Various formats will be parsed into a VERSION and an optional PACKAGE_NAME, where +# PACKAGE_NAME must be the name of a Cargo package in your workspace, and VERSION +# must be a Cargo-style SemVer Version (must have at least major.minor.patch). +# +# If PACKAGE_NAME is specified, then the announcement will be for that +# package (erroring out if it doesn't have the given version or isn't dist-able). +# +# If PACKAGE_NAME isn't specified, then the announcement will be for all +# (dist-able) packages in the workspace with that version (this mode is +# intended for workspaces with only one dist-able package, or with all dist-able +# packages versioned/released in lockstep). +# +# If you push multiple tags at once, separate instances of this workflow will +# spin up, creating an independent announcement for each one. However, GitHub +# will hard limit this to 3 tags per commit, as it will assume more tags is a +# mistake. +# +# If there's a prerelease-style suffix to the version, then the release(s) +# will be marked as a prerelease. +on: + pull_request: + push: + tags: + - '**[0-9]+.[0-9]+.[0-9]+*' + +jobs: + # Run 'dist plan' (or host) to determine what tasks we need to do + plan: + runs-on: "ubuntu-22.04" + outputs: + val: ${{ steps.plan.outputs.manifest }} + tag: ${{ !github.event.pull_request && github.ref_name || '' }} + tag-flag: ${{ !github.event.pull_request && format('--tag={0}', github.ref_name) || '' }} + publishing: ${{ !github.event.pull_request }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + - name: Install dist + # we specify bash to get pipefail; it guards against the `curl` command + # failing. otherwise `sh` won't catch that `curl` returned non-0 + shell: bash + run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.30.3/cargo-dist-installer.sh | sh" + - name: Cache dist + uses: actions/upload-artifact@v4 + with: + name: cargo-dist-cache + path: ~/.cargo/bin/dist + # sure would be cool if github gave us proper conditionals... + # so here's a doubly-nested ternary-via-truthiness to try to provide the best possible + # functionality based on whether this is a pull_request, and whether it's from a fork. + # (PRs run on the *source* but secrets are usually on the *target* -- that's *good* + # but also really annoying to build CI around when it needs secrets to work right.) + - id: plan + run: | + dist ${{ (!github.event.pull_request && format('host --steps=create --tag={0}', github.ref_name)) || 'plan' }} --output-format=json > plan-dist-manifest.json + echo "dist ran successfully" + cat plan-dist-manifest.json + echo "manifest=$(jq -c "." plan-dist-manifest.json)" >> "$GITHUB_OUTPUT" + - name: "Upload dist-manifest.json" + uses: actions/upload-artifact@v4 + with: + name: artifacts-plan-dist-manifest + path: plan-dist-manifest.json + + # Build and packages all the platform-specific things + build-local-artifacts: + name: build-local-artifacts (${{ join(matrix.targets, ', ') }}) + # Let the initial task tell us to not run (currently very blunt) + needs: + - plan + if: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix.include != null && (needs.plan.outputs.publishing == 'true' || fromJson(needs.plan.outputs.val).ci.github.pr_run_mode == 'upload') }} + strategy: + fail-fast: false + # Target platforms/runners are computed by dist in create-release. + # Each member of the matrix has the following arguments: + # + # - runner: the github runner + # - dist-args: cli flags to pass to dist + # - install-dist: expression to run to install dist on the runner + # + # Typically there will be: + # - 1 "global" task that builds universal installers + # - N "local" tasks that build each platform's binaries and platform-specific installers + matrix: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix }} + runs-on: ${{ matrix.runner }} + container: ${{ matrix.container && matrix.container.image || null }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BUILD_MANIFEST_NAME: target/distrib/${{ join(matrix.targets, '-') }}-dist-manifest.json + steps: + - name: enable windows longpaths + run: | + git config --global core.longpaths true + - uses: actions/checkout@v4 + with: + submodules: recursive + - name: Install Rust non-interactively if not already installed + if: ${{ matrix.container }} + run: | + if ! command -v cargo > /dev/null 2>&1; then + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y + echo "$HOME/.cargo/bin" >> $GITHUB_PATH + fi + - uses: swatinem/rust-cache@v2 + with: + key: ${{ join(matrix.targets, '-') }} + cache-provider: ${{ matrix.cache_provider }} + - name: Install dist + run: ${{ matrix.install_dist.run }} + # Get the dist-manifest + - name: Fetch local artifacts + uses: actions/download-artifact@v4 + with: + pattern: artifacts-* + path: target/distrib/ + merge-multiple: true + - name: Install dependencies + run: | + ${{ matrix.packages_install }} + - name: Build artifacts + run: | + # Actually do builds and make zips and whatnot + dist build ${{ needs.plan.outputs.tag-flag }} --print=linkage --output-format=json ${{ matrix.dist_args }} > dist-manifest.json + echo "dist ran successfully" + - id: cargo-dist + name: Post-build + # We force bash here just because github makes it really hard to get values up + # to "real" actions without writing to env-vars, and writing to env-vars has + # inconsistent syntax between shell and powershell. + shell: bash + run: | + # Parse out what we just built and upload it to scratch storage + echo "paths<> "$GITHUB_OUTPUT" + dist print-upload-files-from-manifest --manifest dist-manifest.json >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + + cp dist-manifest.json "$BUILD_MANIFEST_NAME" + - name: "Upload artifacts" + uses: actions/upload-artifact@v4 + with: + name: artifacts-build-local-${{ join(matrix.targets, '_') }} + path: | + ${{ steps.cargo-dist.outputs.paths }} + ${{ env.BUILD_MANIFEST_NAME }} + + # Build and package all the platform-agnostic(ish) things + build-global-artifacts: + needs: + - plan + - build-local-artifacts + runs-on: "ubuntu-22.04" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BUILD_MANIFEST_NAME: target/distrib/global-dist-manifest.json + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + - name: Install cached dist + uses: actions/download-artifact@v4 + with: + name: cargo-dist-cache + path: ~/.cargo/bin/ + - run: chmod +x ~/.cargo/bin/dist + # Get all the local artifacts for the global tasks to use (for e.g. checksums) + - name: Fetch local artifacts + uses: actions/download-artifact@v4 + with: + pattern: artifacts-* + path: target/distrib/ + merge-multiple: true + - id: cargo-dist + shell: bash + run: | + dist build ${{ needs.plan.outputs.tag-flag }} --output-format=json "--artifacts=global" > dist-manifest.json + echo "dist ran successfully" + + # Parse out what we just built and upload it to scratch storage + echo "paths<> "$GITHUB_OUTPUT" + jq --raw-output ".upload_files[]" dist-manifest.json >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + + cp dist-manifest.json "$BUILD_MANIFEST_NAME" + - name: "Upload artifacts" + uses: actions/upload-artifact@v4 + with: + name: artifacts-build-global + path: | + ${{ steps.cargo-dist.outputs.paths }} + ${{ env.BUILD_MANIFEST_NAME }} + # Determines if we should publish/announce + host: + needs: + - plan + - build-local-artifacts + - build-global-artifacts + # Only run if we're "publishing", and only if local and global didn't fail (skipped is fine) + if: ${{ always() && needs.plan.outputs.publishing == 'true' && (needs.build-global-artifacts.result == 'skipped' || needs.build-global-artifacts.result == 'success') && (needs.build-local-artifacts.result == 'skipped' || needs.build-local-artifacts.result == 'success') }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + runs-on: "ubuntu-22.04" + outputs: + val: ${{ steps.host.outputs.manifest }} + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + - name: Install cached dist + uses: actions/download-artifact@v4 + with: + name: cargo-dist-cache + path: ~/.cargo/bin/ + - run: chmod +x ~/.cargo/bin/dist + # Fetch artifacts from scratch-storage + - name: Fetch artifacts + uses: actions/download-artifact@v4 + with: + pattern: artifacts-* + path: target/distrib/ + merge-multiple: true + - id: host + shell: bash + run: | + dist host ${{ needs.plan.outputs.tag-flag }} --steps=upload --steps=release --output-format=json > dist-manifest.json + echo "artifacts uploaded and released successfully" + cat dist-manifest.json + echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT" + - name: "Upload dist-manifest.json" + uses: actions/upload-artifact@v4 + with: + # Overwrite the previous copy + name: artifacts-dist-manifest + path: dist-manifest.json + # Create a GitHub Release while uploading all files to it + - name: "Download GitHub Artifacts" + uses: actions/download-artifact@v4 + with: + pattern: artifacts-* + path: artifacts + merge-multiple: true + - name: Cleanup + run: | + # Remove the granular manifests + rm -f artifacts/*-dist-manifest.json + - name: Create GitHub Release + env: + PRERELEASE_FLAG: "${{ fromJson(steps.host.outputs.manifest).announcement_is_prerelease && '--prerelease' || '' }}" + ANNOUNCEMENT_TITLE: "${{ fromJson(steps.host.outputs.manifest).announcement_title }}" + ANNOUNCEMENT_BODY: "${{ fromJson(steps.host.outputs.manifest).announcement_github_body }}" + RELEASE_COMMIT: "${{ github.sha }}" + run: | + # Write and read notes from a file to avoid quoting breaking things + echo "$ANNOUNCEMENT_BODY" > $RUNNER_TEMP/notes.txt + + gh release create "${{ needs.plan.outputs.tag }}" --target "$RELEASE_COMMIT" $PRERELEASE_FLAG --title "$ANNOUNCEMENT_TITLE" --notes-file "$RUNNER_TEMP/notes.txt" artifacts/* + + publish-npm: + needs: + - plan + - host + runs-on: "ubuntu-22.04" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PLAN: ${{ needs.plan.outputs.val }} + if: ${{ !fromJson(needs.plan.outputs.val).announcement_is_prerelease || fromJson(needs.plan.outputs.val).publish_prereleases }} + steps: + - name: Fetch npm packages + uses: actions/download-artifact@v4 + with: + pattern: artifacts-* + path: npm/ + merge-multiple: true + - uses: actions/setup-node@v4 + with: + node-version: '20.x' + registry-url: 'https://registry.npmjs.org' + - run: | + for release in $(echo "$PLAN" | jq --compact-output '.releases[] | select([.artifacts[] | endswith("-npm-package.tar.gz")] | any)'); do + pkg=$(echo "$release" | jq '.artifacts[] | select(endswith("-npm-package.tar.gz"))' --raw-output) + npm publish --access public "./npm/${pkg}" + done + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + announce: + needs: + - plan + - host + - publish-npm + # use "always() && ..." to allow us to wait for all publish jobs while + # still allowing individual publish jobs to skip themselves (for prereleases). + # "host" however must run to completion, no skipping allowed! + if: ${{ always() && needs.host.result == 'success' && (needs.publish-npm.result == 'skipped' || needs.publish-npm.result == 'success') }} + runs-on: "ubuntu-22.04" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..1e5d624f --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,20 @@ +name: Run Tests +on: + pull_request: + push: + branches: + - main + +jobs: + tests: + name: Run Tests + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + profile: minimal + - name: Run Tests + run: cargo test --all-features -- --nocapture diff --git a/Cargo.toml b/Cargo.toml index 6b3c0711..de0d8181 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,16 @@ version = "0.1.0" edition = "2024" rust-version = "1.85" description = "Secure personal AI assistant that protects your data and expands its capabilities on the fly" +authors = ["NEAR AI "] license = "MIT OR Apache-2.0" +homepage = "https://github.com/nearai/ironclaw" +repository = "https://github.com/nearai/ironclaw" + +[package.metadata.wix] +upgrade-guid = "D0156E61-BA37-451E-8AB9-1A2ECCCFA48F" +path-guid = "F90B6EA6-87F7-499B-BB19-CF55DE1EB339" +license = false +eula = false [dependencies] # Async runtime @@ -127,3 +136,47 @@ tempfile = "3" [features] default = [] integration = [] + +# The profile that 'cargo dist' will build with +[profile.dist] +inherits = "release" +lto = "thin" + +# Config for 'dist' +[workspace.metadata.dist] +# The preferred dist version to use in CI (Cargo.toml SemVer syntax) +cargo-dist-version = "0.30.3" +allow-dirty = ["ci"] +# CI backends to support +ci = "github" +# The installers to generate for each app +installers = ["shell", "powershell", "npm", "msi"] +# Publish jobs to run in CI +publish-jobs = ["npm"] +# Target platforms to build apps for (Rust target-triple syntax) +targets = [ + "aarch64-apple-darwin", + "aarch64-unknown-linux-gnu", + "aarch64-pc-windows-msvc", + "x86_64-apple-darwin", + "x86_64-unknown-linux-gnu", + "x86_64-pc-windows-msvc", +] +# The archive format to use for windows builds (defaults .zip) +windows-archive = ".tar.gz" +# The archive format to use for non-windows builds (defaults .tar.xz) +unix-archive = ".tar.gz" +# Which actions to run on pull requests +pr-run-mode = "upload" +# Path that installers should place binaries in +install-path = "CARGO_HOME" +# Whether to install an updater program +install-updater = false + +[workspace.metadata.dist.github-custom-runners] +aarch64-unknown-linux-gnu = "ubuntu-24.04-arm" +x86_64-unknown-linux-gnu = "ubuntu-22.04" +x86_64-pc-windows-msvc = "windows-2022" +aarch64-pc-windows-msvc = "windows-2025" +x86_64-apple-darwin = "macos-15-intel" +aarch64-apple-darwin = "macos-14" diff --git a/build.rs b/build.rs index 01ea2da8..8f695ee0 100644 --- a/build.rs +++ b/build.rs @@ -80,25 +80,26 @@ fn main() { .map(|s| s.success()) .unwrap_or(false); - if !component_ok - { + if !component_ok { // Fallback: copy raw module if wasm-tools unavailable if std::fs::copy(&raw_wasm, &wasm_out).is_err() { - eprintln!( - "cargo:warning=wasm-tools not found. Run: cargo install wasm-tools" - ); + eprintln!("cargo:warning=wasm-tools not found. Run: cargo install wasm-tools"); } } else { // Strip debug info (use temp file to avoid clobbering) let stripped = wasm_out.with_extension("wasm.stripped"); let strip_ok = Command::new("wasm-tools") - .args(["strip", wasm_out.to_str().unwrap(), "-o", stripped.to_str().unwrap()]) + .args([ + "strip", + wasm_out.to_str().unwrap(), + "-o", + stripped.to_str().unwrap(), + ]) .current_dir(&root) .status() .map(|s| s.success()) .unwrap_or(false); - if strip_ok - { + if strip_ok { let _ = std::fs::rename(&stripped, &wasm_out); } } diff --git a/examples/test_heartbeat.rs b/examples/test_heartbeat.rs index ca158f5a..188009bb 100644 --- a/examples/test_heartbeat.rs +++ b/examples/test_heartbeat.rs @@ -28,7 +28,9 @@ async fn main() -> anyhow::Result<()> { println!("=== Heartbeat Integration Test ===\n"); // 1. Load config - let config = Config::from_env().await.map_err(|e| anyhow::anyhow!("Config: {}", e))?; + let config = Config::from_env() + .await + .map_err(|e| anyhow::anyhow!("Config: {}", e))?; println!("[1/6] Config loaded"); println!(" heartbeat.enabled = {}", config.heartbeat.enabled); println!( diff --git a/src/channels/wasm/router.rs b/src/channels/wasm/router.rs index 5ede9a13..cbf8b7b8 100644 --- a/src/channels/wasm/router.rs +++ b/src/channels/wasm/router.rs @@ -469,7 +469,7 @@ pub fn create_wasm_channel_router( } #[cfg(test)] - mod tests { +mod tests { use std::sync::Arc; use crate::channels::wasm::capabilities::ChannelCapabilities; diff --git a/src/channels/wasm/wrapper.rs b/src/channels/wasm/wrapper.rs index bc7f0ac8..0288a2d7 100644 --- a/src/channels/wasm/wrapper.rs +++ b/src/channels/wasm/wrapper.rs @@ -43,12 +43,12 @@ use wasmtime_wasi::{ResourceTable, WasiCtx, WasiCtxBuilder, WasiView}; use crate::channels::wasm::capabilities::ChannelCapabilities; use crate::channels::wasm::error::WasmChannelError; use crate::channels::wasm::host::{ChannelEmitRateLimiter, ChannelHostState, EmittedMessage}; -use crate::pairing::PairingStore; use crate::channels::wasm::router::RegisteredEndpoint; use crate::channels::wasm::runtime::{PreparedChannelModule, WasmChannelRuntime}; use crate::channels::wasm::schema::ChannelConfig; use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate}; use crate::error::ChannelError; +use crate::pairing::PairingStore; use crate::safety::LeakDetector; use crate::tools::wasm::LogLevel; use crate::tools::wasm::WasmResourceLimiter; @@ -1190,6 +1190,7 @@ impl WasmChannel { /// /// Static method for use by the background typing repeat task (which /// doesn't have access to `&self`). + #[allow(clippy::too_many_arguments)] async fn execute_status( channel_name: &str, runtime: &Arc, @@ -2073,12 +2074,12 @@ mod tests { use std::sync::Arc; use crate::channels::Channel; - use crate::pairing::PairingStore; use crate::channels::wasm::capabilities::ChannelCapabilities; use crate::channels::wasm::runtime::{ PreparedChannelModule, WasmChannelRuntime, WasmChannelRuntimeConfig, }; use crate::channels::wasm::wrapper::{HttpResponse, WasmChannel}; + use crate::pairing::PairingStore; use crate::tools::wasm::ResourceLimits; fn create_test_channel() -> WasmChannel { @@ -2525,8 +2526,13 @@ mod tests { ); creds.insert("OTHER_SECRET".to_string(), "s3cret".to_string()); - let store = - ChannelStoreData::new(1024 * 1024, "test", ChannelCapabilities::default(), creds); + let store = ChannelStoreData::new( + 1024 * 1024, + "test", + ChannelCapabilities::default(), + creds, + Arc::new(PairingStore::new()), + ); let error = "HTTP request failed: error sending request for url \ (https://api.telegram.org/bot8218490433:AAEZeUxwqZ5OO3mOCXv7fKvpdhDgsmBBNis/getUpdates)"; @@ -2556,6 +2562,7 @@ mod tests { "test", ChannelCapabilities::default(), std::collections::HashMap::new(), + Arc::new(PairingStore::new()), ); let input = "some error message"; @@ -2569,8 +2576,13 @@ mod tests { let mut creds = std::collections::HashMap::new(); creds.insert("EMPTY_TOKEN".to_string(), String::new()); - let store = - ChannelStoreData::new(1024 * 1024, "test", ChannelCapabilities::default(), creds); + let store = ChannelStoreData::new( + 1024 * 1024, + "test", + ChannelCapabilities::default(), + creds, + Arc::new(PairingStore::new()), + ); let input = "should not match anything"; assert_eq!(store.redact_credentials(input), input); diff --git a/src/cli/config.rs b/src/cli/config.rs index 7754c925..894ea710 100644 --- a/src/cli/config.rs +++ b/src/cli/config.rs @@ -71,7 +71,9 @@ pub async fn run_config_command(cmd: ConfigCommand) -> anyhow::Result<()> { /// Bootstrap a DB connection for config commands. async fn connect_store() -> anyhow::Result { - let config = crate::config::Config::from_env().await.map_err(|e| anyhow::anyhow!("{}", e))?; + let config = crate::config::Config::from_env() + .await + .map_err(|e| anyhow::anyhow!("{}", e))?; let store = crate::history::Store::new(&config.database).await?; store.run_migrations().await?; Ok(store) diff --git a/src/cli/pairing.rs b/src/cli/pairing.rs index ef00ec52..494e191e 100644 --- a/src/cli/pairing.rs +++ b/src/cli/pairing.rs @@ -52,7 +52,10 @@ fn run_list(store: &PairingStore, channel: &str, json: bool) -> Result<(), Strin let requests = store.list_pending(channel).map_err(|e| e.to_string())?; if json { - println!("{}", serde_json::to_string_pretty(&requests).map_err(|e| e.to_string())?); + println!( + "{}", + serde_json::to_string_pretty(&requests).map_err(|e| e.to_string())? + ); return Ok(()); } @@ -69,9 +72,7 @@ fn run_list(store: &PairingStore, channel: &str, json: bool) -> Result<(), Strin .and_then(|m| m.as_object()) .map(|o| { o.iter() - .filter_map(|(k, v)| { - v.as_str().map(|s| format!("{}={}", k, s)) - }) + .filter_map(|(k, v)| v.as_str().map(|s| format!("{}={}", k, s))) .collect::>() .join(", ") }) @@ -88,7 +89,10 @@ fn run_approve(store: &PairingStore, channel: &str, code: &str) -> Result<(), St println!("Approved {} sender {}.", channel, entry.id); Ok(()) } - Ok(None) => Err(format!("No pending pairing request found for code: {}", code)), + Ok(None) => Err(format!( + "No pending pairing request found for code: {}", + code + )), Err(crate::pairing::PairingStoreError::ApproveRateLimited) => Err( "Too many failed approve attempts. Wait a few minutes before trying again.".to_string(), ), diff --git a/src/lib.rs b/src/lib.rs index af5f0a1a..461bff3d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -43,7 +43,6 @@ pub mod bootstrap; pub mod channels; pub mod cli; pub mod config; -pub mod pairing; pub mod context; pub mod error; pub mod estimation; @@ -52,6 +51,7 @@ pub mod extensions; pub mod history; pub mod llm; pub mod orchestrator; +pub mod pairing; pub mod safety; pub mod sandbox; pub mod secrets; diff --git a/src/main.rs b/src/main.rs index d2ab5740..9a381f4c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,7 +7,6 @@ use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitEx use ironclaw::{ agent::{Agent, AgentDeps, SessionManager}, - pairing::PairingStore, channels::{ ChannelManager, GatewayChannel, HttpChannel, ReplChannel, WebhookServer, WebhookServerConfig, @@ -30,6 +29,7 @@ use ironclaw::{ ContainerJobConfig, ContainerJobManager, OrchestratorApi, TokenStore, api::OrchestratorState, }, + pairing::PairingStore, safety::SafetyLayer, secrets::{PostgresSecretsStore, SecretsCrypto, SecretsStore}, setup::{SetupConfig, SetupWizard}, @@ -86,7 +86,9 @@ async fn main() -> anyhow::Result<()> { // Memory commands need database (and optionally embeddings) let _ = dotenvy::dotenv(); - let config = Config::from_env().await.map_err(|e| anyhow::anyhow!("{}", e))?; + let config = Config::from_env() + .await + .map_err(|e| anyhow::anyhow!("{}", e))?; let store = ironclaw::history::Store::new(&config.database).await?; store.run_migrations().await?; diff --git a/src/pairing/store.rs b/src/pairing/store.rs index 941509d5..464c5a39 100644 --- a/src/pairing/store.rs +++ b/src/pairing/store.rs @@ -5,7 +5,7 @@ use std::collections::HashSet; use std::fs; use std::io::{Seek, SeekFrom, Write}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; use fs4::FileExt; @@ -94,17 +94,17 @@ fn safe_channel_key(channel: &str) -> Result { Ok(safe) } -fn pairing_path(base_dir: &PathBuf, channel: &str) -> Result { +fn pairing_path(base_dir: &Path, channel: &str) -> Result { let key = safe_channel_key(channel)?; Ok(base_dir.join(format!("{}-pairing.json", key))) } -fn allow_from_path(base_dir: &PathBuf, channel: &str) -> Result { +fn allow_from_path(base_dir: &Path, channel: &str) -> Result { let key = safe_channel_key(channel)?; Ok(base_dir.join(format!("{}-allowFrom.json", key))) } -fn approve_attempts_path(base_dir: &PathBuf, channel: &str) -> Result { +fn approve_attempts_path(base_dir: &Path, channel: &str) -> Result { let key = safe_channel_key(channel)?; Ok(base_dir.join(format!("{}-approve-attempts.json", key))) } @@ -236,10 +236,11 @@ impl PairingStore { file.lock_exclusive()?; let content = fs::read_to_string(&path).unwrap_or_default(); - let mut store: PairingStoreFile = serde_json::from_str(&content).unwrap_or(PairingStoreFile { - version: 1, - requests: Vec::new(), - }); + let mut store: PairingStoreFile = + serde_json::from_str(&content).unwrap_or(PairingStoreFile { + version: 1, + requests: Vec::new(), + }); let now = now_iso(); let now_secs = now_secs(); @@ -296,7 +297,10 @@ impl PairingStore { self.write_pairing_file_locked(&mut file, channel, &store.requests)?; fs4::FileExt::unlock(&file)?; - Ok(UpsertResult { code, created: true }) + Ok(UpsertResult { + code, + created: true, + }) } fn is_approve_rate_limited(&self, channel: &str) -> Result { @@ -306,8 +310,7 @@ impl PairingStore { Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false), Err(e) => return Err(e.into()), }; - let mut data: ApproveAttemptsFile = - serde_json::from_str(&content).unwrap_or_default(); + let mut data: ApproveAttemptsFile = serde_json::from_str(&content).unwrap_or_default(); let now = now_secs(); let cutoff = now.saturating_sub(PAIRING_APPROVE_RATE_WINDOW_SECS); data.failed_at.retain(|&t| t >= cutoff); @@ -321,11 +324,11 @@ impl PairingStore { .read(true) .write(true) .create(true) + .truncate(true) .open(&path)?; file.lock_exclusive()?; let content = fs::read_to_string(&path).unwrap_or_default(); - let mut data: ApproveAttemptsFile = - serde_json::from_str(&content).unwrap_or_default(); + let mut data: ApproveAttemptsFile = serde_json::from_str(&content).unwrap_or_default(); let now = now_secs(); data.failed_at.push(now); let cutoff = now.saturating_sub(PAIRING_APPROVE_RATE_WINDOW_SECS); @@ -368,10 +371,11 @@ impl PairingStore { file.lock_exclusive()?; let content = fs::read_to_string(&path).unwrap_or_default(); - let mut store: PairingStoreFile = serde_json::from_str(&content).unwrap_or(PairingStoreFile { - version: 1, - requests: Vec::new(), - }); + let mut store: PairingStoreFile = + serde_json::from_str(&content).unwrap_or(PairingStoreFile { + version: 1, + requests: Vec::new(), + }); let now_secs = now_secs(); store.requests.retain(|r| !is_expired(r, now_secs)); @@ -409,10 +413,11 @@ impl PairingStore { Err(e) => return Err(e.into()), }; - let file: AllowFromStoreFile = serde_json::from_str(&content).unwrap_or(AllowFromStoreFile { - version: 1, - allow_from: Vec::new(), - }); + let file: AllowFromStoreFile = + serde_json::from_str(&content).unwrap_or(AllowFromStoreFile { + version: 1, + allow_from: Vec::new(), + }); Ok(file.allow_from) } @@ -433,10 +438,9 @@ impl PairingStore { if let Some(u) = username { let u = u.trim().to_lowercase(); let u_norm = u.strip_prefix('@').unwrap_or(&u); - if allow - .iter() - .any(|e| e.trim().to_lowercase() == u || e.trim().to_lowercase() == format!("@{}", u_norm)) - { + if allow.iter().any(|e| { + e.trim().to_lowercase() == u || e.trim().to_lowercase() == format!("@{}", u_norm) + }) { return Ok(true); } } @@ -456,6 +460,7 @@ impl PairingStore { .read(true) .write(true) .create(true) + .truncate(true) .open(&path)?; file.lock_exclusive()?; @@ -563,11 +568,20 @@ mod tests { fn test_upsert_request_creates_new() { let (store, _) = test_store(); let result = store - .upsert_request("telegram", "user123", Some(serde_json::json!({"chat_id": 456}))) + .upsert_request( + "telegram", + "user123", + Some(serde_json::json!({"chat_id": 456})), + ) .unwrap(); assert!(result.created); assert_eq!(result.code.len(), PAIRING_CODE_LENGTH); - assert!(result.code.chars().all(|c| PAIRING_ALPHABET.contains(&(c as u8)))); + assert!( + result + .code + .chars() + .all(|c| PAIRING_ALPHABET.contains(&(c as u8))) + ); } #[test] @@ -575,7 +589,9 @@ mod tests { let (store, _) = test_store(); let r1 = store.upsert_request("telegram", "user123", None).unwrap(); assert!(r1.created); - let r2 = store.upsert_request("telegram", "user123", Some(serde_json::json!({"x": 1}))).unwrap(); + let r2 = store + .upsert_request("telegram", "user123", Some(serde_json::json!({"x": 1}))) + .unwrap(); assert!(!r2.created); assert_eq!(r1.code, r2.code); @@ -633,21 +649,35 @@ mod tests { let r = store.upsert_request("telegram", "user999", None).unwrap(); store.approve("telegram", &r.code).unwrap(); - assert!(store.is_sender_allowed("telegram", "user999", None).unwrap()); + assert!( + store + .is_sender_allowed("telegram", "user999", None) + .unwrap() + ); assert!(!store.is_sender_allowed("telegram", "other", None).unwrap()); } #[test] fn test_is_sender_allowed_by_username() { let (store, _) = test_store(); - store.upsert_request("telegram", "alice", Some(serde_json::json!({"username": "alice"}))).unwrap(); + store + .upsert_request( + "telegram", + "alice", + Some(serde_json::json!({"username": "alice"})), + ) + .unwrap(); let pending = store.list_pending("telegram").unwrap(); store.approve("telegram", &pending[0].code).unwrap(); // approve adds id to allow_from. For username we need to add it manually. // Actually approve adds entry.id which is "alice". So is_sender_allowed("telegram", "alice", None) would work. assert!(store.is_sender_allowed("telegram", "alice", None).unwrap()); - assert!(store.is_sender_allowed("telegram", "alice", Some("alice")).unwrap()); + assert!( + store + .is_sender_allowed("telegram", "alice", Some("alice")) + .unwrap() + ); } #[test] diff --git a/src/secrets/keychain.rs b/src/secrets/keychain.rs index 0ab810ba..1b90a106 100644 --- a/src/secrets/keychain.rs +++ b/src/secrets/keychain.rs @@ -101,15 +101,13 @@ mod platform { let ss = SecretService::connect(EncryptionType::Dh) .await .map_err(|e| { - SecretError::KeychainError(format!( - "Failed to connect to secret service: {}", - e - )) + SecretError::KeychainError(format!("Failed to connect to secret service: {}", e)) })?; - let collection = ss.get_default_collection().await.map_err(|e| { - SecretError::KeychainError(format!("Failed to get collection: {}", e)) - })?; + let collection = ss + .get_default_collection() + .await + .map_err(|e| SecretError::KeychainError(format!("Failed to get collection: {}", e)))?; // Unlock if needed if collection.is_locked().await.unwrap_or(true) { @@ -132,9 +130,7 @@ mod platform { "text/plain", ) .await - .map_err(|e| { - SecretError::KeychainError(format!("Failed to create secret: {}", e)) - })?; + .map_err(|e| SecretError::KeychainError(format!("Failed to create secret: {}", e)))?; Ok(()) } @@ -144,10 +140,7 @@ mod platform { let ss = SecretService::connect(EncryptionType::Dh) .await .map_err(|e| { - SecretError::KeychainError(format!( - "Failed to connect to secret service: {}", - e - )) + SecretError::KeychainError(format!("Failed to connect to secret service: {}", e)) })?; let items = ss @@ -188,10 +181,7 @@ mod platform { let ss = SecretService::connect(EncryptionType::Dh) .await .map_err(|e| { - SecretError::KeychainError(format!( - "Failed to connect to secret service: {}", - e - )) + SecretError::KeychainError(format!("Failed to connect to secret service: {}", e)) })?; let items = ss diff --git a/src/secrets/types.rs b/src/secrets/types.rs index d493fe73..3d82a334 100644 --- a/src/secrets/types.rs +++ b/src/secrets/types.rs @@ -192,9 +192,10 @@ impl CreateSecretParams { } /// Where a credential should be injected in an HTTP request. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] pub enum CredentialLocation { /// Inject as Authorization header (e.g., "Bearer {secret}") + #[default] AuthorizationBearer, /// Inject as Authorization header with Basic auth AuthorizationBasic { username: String }, @@ -209,12 +210,6 @@ pub enum CredentialLocation { UrlPath { placeholder: String }, } -impl Default for CredentialLocation { - fn default() -> Self { - Self::AuthorizationBearer - } -} - /// Mapping from a secret name to where it should be injected. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CredentialMapping { diff --git a/src/setup/wizard.rs b/src/setup/wizard.rs index 021938c5..6197e0f2 100644 --- a/src/setup/wizard.rs +++ b/src/setup/wizard.rs @@ -782,12 +782,9 @@ impl SetupWizard { fn save_and_summarize(&mut self) -> Result<(), SetupError> { self.settings.onboard_completed = true; - self.settings.save().map_err(|e| { - SetupError::Io(std::io::Error::new( - std::io::ErrorKind::Other, - format!("Failed to save settings: {}", e), - )) - })?; + self.settings + .save() + .map_err(|e| std::io::Error::other(format!("Failed to save settings: {}", e)))?; println!(); print_success("Configuration saved to ~/.ironclaw/"); diff --git a/src/tools/builtin/file.rs b/src/tools/builtin/file.rs index 14b76677..3d08cc92 100644 --- a/src/tools/builtin/file.rs +++ b/src/tools/builtin/file.rs @@ -36,7 +36,7 @@ fn is_workspace_path(path: &str) -> bool { .and_then(|f| f.to_str()) .unwrap_or(path); - WORKSPACE_FILES.iter().any(|ws| *ws == filename) + WORKSPACE_FILES.contains(&filename) || path.starts_with("daily/") || path.starts_with("context/") } diff --git a/src/tools/mcp/config.rs b/src/tools/mcp/config.rs index 4eb3fc2f..f7041934 100644 --- a/src/tools/mcp/config.rs +++ b/src/tools/mcp/config.rs @@ -365,12 +365,7 @@ pub async fn save_mcp_servers_to_db( store .set_setting(user_id, "mcp_servers", &value) .await - .map_err(|e| { - ConfigError::Io(std::io::Error::new( - std::io::ErrorKind::Other, - e.to_string(), - )) - })?; + .map_err(std::io::Error::other)?; Ok(()) } diff --git a/src/tools/registry.rs b/src/tools/registry.rs index 845af8e2..4e833573 100644 --- a/src/tools/registry.rs +++ b/src/tools/registry.rs @@ -422,7 +422,7 @@ impl Default for ToolRegistry { #[cfg(test)] mod tests { use super::*; - use crate::tools::tool::EchoTool; + use crate::tools::registry::EchoTool; #[tokio::test] async fn test_register_and_get() { diff --git a/src/tools/tool.rs b/src/tools/tool.rs index bfe51820..b2f242bb 100644 --- a/src/tools/tool.rs +++ b/src/tools/tool.rs @@ -199,57 +199,57 @@ pub trait Tool: Send + Sync { } } -/// A simple no-op tool for testing. -#[derive(Debug)] -pub struct EchoTool; - -#[async_trait] -impl Tool for EchoTool { - fn name(&self) -> &str { - "echo" - } - - fn description(&self) -> &str { - "Echoes back the input message. Useful for testing." - } - - fn parameters_schema(&self) -> serde_json::Value { - serde_json::json!({ - "type": "object", - "properties": { - "message": { - "type": "string", - "description": "The message to echo back" - } - }, - "required": ["message"] - }) - } - - async fn execute( - &self, - params: serde_json::Value, - _ctx: &JobContext, - ) -> Result { - let message = params - .get("message") - .and_then(|v| v.as_str()) - .ok_or_else(|| { - ToolError::InvalidParameters("missing 'message' parameter".to_string()) - })?; - - Ok(ToolOutput::text(message, Duration::from_millis(1))) - } - - fn requires_sanitization(&self) -> bool { - false // Echo is a trusted internal tool - } -} - #[cfg(test)] mod tests { use super::*; + /// A simple no-op tool for testing. + #[derive(Debug)] + pub struct EchoTool; + + #[async_trait] + impl Tool for EchoTool { + fn name(&self) -> &str { + "echo" + } + + fn description(&self) -> &str { + "Echoes back the input message. Useful for testing." + } + + fn parameters_schema(&self) -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "The message to echo back" + } + }, + "required": ["message"] + }) + } + + async fn execute( + &self, + params: serde_json::Value, + _ctx: &JobContext, + ) -> Result { + let message = params + .get("message") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + ToolError::InvalidParameters("missing 'message' parameter".to_string()) + })?; + + Ok(ToolOutput::text(message, Duration::from_millis(1))) + } + + fn requires_sanitization(&self) -> bool { + false // Echo is a trusted internal tool + } + } + #[tokio::test] async fn test_echo_tool() { let tool = EchoTool; diff --git a/src/workspace/repository.rs b/src/workspace/repository.rs index e350a2e3..f9e87219 100644 --- a/src/workspace/repository.rs +++ b/src/workspace/repository.rs @@ -404,14 +404,13 @@ impl Repository { Vec::new() }; - let vector_results = if config.use_vector && embedding.is_some() { - self.vector_search( - user_id, - agent_id, - embedding.unwrap(), - config.pre_fusion_limit, - ) - .await? + let vector_results = if config.use_vector { + if let Some(embedding) = embedding { + self.vector_search(user_id, agent_id, embedding, config.pre_fusion_limit) + .await? + } else { + Vec::new() + } } else { Vec::new() }; diff --git a/tests/pairing_integration.rs b/tests/pairing_integration.rs index 041f7e97..62cadd49 100644 --- a/tests/pairing_integration.rs +++ b/tests/pairing_integration.rs @@ -3,7 +3,7 @@ //! Verifies the full pairing lifecycle: upsert → list → approve → allowFrom → is_sender_allowed. //! Uses temp directory for isolation. -use ironclaw::cli::{run_pairing_command_with_store, PairingCommand}; +use ironclaw::cli::{PairingCommand, run_pairing_command_with_store}; use ironclaw::pairing::PairingStore; use tempfile::TempDir; @@ -19,10 +19,16 @@ fn test_pairing_flow_unknown_user_to_approved() { let channel = "telegram"; // 1. Unknown user sends first message -> upsert creates request - let r1 = store.upsert_request(channel, "user_12345", Some(serde_json::json!({ - "chat_id": 999, - "username": "alice" - }))).unwrap(); + let r1 = store + .upsert_request( + channel, + "user_12345", + Some(serde_json::json!({ + "chat_id": 999, + "username": "alice" + })), + ) + .unwrap(); assert!(r1.created); assert!(!r1.code.is_empty()); assert_eq!(r1.code.len(), 8); @@ -34,7 +40,11 @@ fn test_pairing_flow_unknown_user_to_approved() { assert_eq!(pending[0].code, r1.code); // 3. User is not allowed yet - assert!(!store.is_sender_allowed(channel, "user_12345", Some("alice")).unwrap()); + assert!( + !store + .is_sender_allowed(channel, "user_12345", Some("alice")) + .unwrap() + ); // 4. Approve via code let approved = store.approve(channel, &r1.code).unwrap(); @@ -42,8 +52,16 @@ fn test_pairing_flow_unknown_user_to_approved() { assert_eq!(approved.unwrap().id, "user_12345"); // 5. User is now allowed - assert!(store.is_sender_allowed(channel, "user_12345", None).unwrap()); - assert!(store.is_sender_allowed(channel, "user_12345", Some("alice")).unwrap()); + assert!( + store + .is_sender_allowed(channel, "user_12345", None) + .unwrap() + ); + assert!( + store + .is_sender_allowed(channel, "user_12345", Some("alice")) + .unwrap() + ); // 6. Pending list is empty let pending_after = store.list_pending(channel).unwrap(); @@ -69,7 +87,11 @@ fn test_pairing_flow_cli_approve() { }, ); assert!(result.is_ok()); - assert!(store.is_sender_allowed("telegram", "user_999", None).unwrap()); + assert!( + store + .is_sender_allowed("telegram", "user_999", None) + .unwrap() + ); } #[test] @@ -109,4 +131,3 @@ fn test_pairing_multiple_channels_isolated() { store.approve("slack", &r_slack.code).unwrap(); assert!(store.is_sender_allowed("slack", "user_b", None).unwrap()); } - diff --git a/tests/wasm_channel_integration.rs b/tests/wasm_channel_integration.rs index ca636e1f..5d1fdf58 100644 --- a/tests/wasm_channel_integration.rs +++ b/tests/wasm_channel_integration.rs @@ -10,11 +10,11 @@ use std::collections::HashMap; use std::sync::Arc; use ironclaw::channels::Channel; -use ironclaw::pairing::PairingStore; use ironclaw::channels::wasm::{ ChannelCapabilities, EmitRateLimitConfig, PreparedChannelModule, RegisteredEndpoint, WasmChannel, WasmChannelRouter, WasmChannelRuntime, WasmChannelRuntimeConfig, }; +use ironclaw::pairing::PairingStore; use tempfile::TempDir; /// Create a test runtime for WASM channel operations. diff --git a/wix/main.wxs b/wix/main.wxs new file mode 100644 index 00000000..cb94f1a7 --- /dev/null +++ b/wix/main.wxs @@ -0,0 +1,228 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + 1 + + + + + + + + + + + + + + + + + + From 517be42ccc1a3e21135a211673a1a18aa0639b16 Mon Sep 17 00:00:00 2001 From: Vlad Frolov Date: Thu, 12 Feb 2026 12:34:11 +0100 Subject: [PATCH 08/33] ci: Upgraded release-plz CD pipeline --- .github/workflows/release-plz.yml | 50 +++++++++++++++++++++++-------- 1 file changed, 37 insertions(+), 13 deletions(-) diff --git a/.github/workflows/release-plz.yml b/.github/workflows/release-plz.yml index 3feeb17b..bc51b3e7 100644 --- a/.github/workflows/release-plz.yml +++ b/.github/workflows/release-plz.yml @@ -1,29 +1,53 @@ name: Release-plz -permissions: - pull-requests: write - contents: write - on: push: branches: - main jobs: - release-plz: - name: Release-plz + + # Release unpublished packages. + release-plz-release: + name: Release-plz release runs-on: ubuntu-latest + permissions: + contents: write steps: - - name: Checkout repository - uses: actions/checkout@v3 + - &checkout + name: Checkout repository + uses: actions/checkout@v6 with: fetch-depth: 0 - - name: Install Rust toolchain + persist-credentials: false + - &install-rust + name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable - - name: Install packages (Linux) - if: runner.os == 'Linux' - run: sudo apt-get update && sudo apt-get install --assume-yes libudev-dev - name: Run release-plz - uses: MarcoIeni/release-plz-action@v0.5 + uses: release-plz/action@v0.5 + with: + command: release env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + + # Create a PR with the new versions and changelog, preparing the next release. + release-plz-pr: + name: Release-plz PR + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + concurrency: + group: release-plz-${{ github.ref }} + cancel-in-progress: false + steps: + - *checkout + - *install-rust + - name: Run release-plz + uses: release-plz/action@v0.5 + with: + command: release-pr + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} From d55b302b39fcec1f1224a6e277534fe62bcd2b08 Mon Sep 17 00:00:00 2001 From: Vlad Frolov Date: Thu, 12 Feb 2026 12:36:13 +0100 Subject: [PATCH 09/33] ci: Skip release-plz on forks --- .github/workflows/release-plz.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/release-plz.yml b/.github/workflows/release-plz.yml index bc51b3e7..519abe97 100644 --- a/.github/workflows/release-plz.yml +++ b/.github/workflows/release-plz.yml @@ -9,6 +9,7 @@ jobs: # Release unpublished packages. release-plz-release: + if: ${{ github.repository_owner == 'nearai' }} name: Release-plz release runs-on: ubuntu-latest permissions: @@ -33,6 +34,7 @@ jobs: # Create a PR with the new versions and changelog, preparing the next release. release-plz-pr: + if: ${{ github.repository_owner == 'nearai' }} name: Release-plz PR runs-on: ubuntu-latest permissions: From 1cf08a4b42daeb94359d684f59c7605785839cab Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 12 Feb 2026 22:14:28 +0100 Subject: [PATCH 10/33] chore: release v0.1.0 (#46) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 96 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..2de5620a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,96 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.1.0](https://github.com/nearai/ironclaw/releases/tag/v0.1.0) - 2026-02-12 + +### Added + +- Add multi-provider LLM support via rig-core adapter ([#36](https://github.com/nearai/ironclaw/pull/36)) +- Sandbox jobs ([#4](https://github.com/nearai/ironclaw/pull/4)) +- Add Google Suite & Telegram WASM tools ([#9](https://github.com/nearai/ironclaw/pull/9)) +- Improve CLI ([#5](https://github.com/nearai/ironclaw/pull/5)) + +### Fixed + +- resolve runtime panic in Linux keychain integration ([#32](https://github.com/nearai/ironclaw/pull/32)) + +### Other + +- Skip release-plz on forks +- Upgraded release-plz CD pipeline +- Added CI/CD and release pipelines ([#45](https://github.com/nearai/ironclaw/pull/45)) +- DM pairing + Telegram channel improvements ([#17](https://github.com/nearai/ironclaw/pull/17)) +- Fixes build, adds missing sse event and correct command ([#11](https://github.com/nearai/ironclaw/pull/11)) +- Codex/feature parity pr hook ([#6](https://github.com/nearai/ironclaw/pull/6)) +- Add WebSocket gateway and control plane ([#8](https://github.com/nearai/ironclaw/pull/8)) +- select bundled Telegram channel and auto-install ([#3](https://github.com/nearai/ironclaw/pull/3)) +- Adding skills for reusable work +- Fix MCP tool calls, approval loop, shutdown, and improve web UI +- Add auth mode, fix MCP token handling, and parallelize startup loading +- Merge remote-tracking branch 'origin/main' into ui +- Adding web UI +- Rename `setup` CLI command to `onboard` for compatibility +- Add in-chat extension discovery, auth, and activation system +- Add Telegram typing indicator via WIT on-status callback +- Add proactivity features: memory CLI, session pruning, self-repair notifications, slash commands, status diagnostics, context warnings +- Add hosted MCP server support with OAuth 2.1 and token refresh +- Add interactive setup wizard and persistent settings +- Rebrand to IronClaw with security-first mission +- Fix build_software tool stuck in planning mode loop +- Enable sandbox by default +- Fix Telegram Markdown formatting and clarify tool/memory distinctions +- Simplify Telegram channel config with host-injected tunnel/webhook settings +- Apply Telegram channel learnings to WhatsApp implementation +- Merge remote-tracking branch 'origin/main' +- Docker file for sandbox +- Replace hardcoded intent patterns with job tools +- Fix router test to match intentional job creation patterns +- Add Docker execution sandbox for secure shell command isolation +- Move setup wizard credentials to database storage +- Add interactive setup wizard for first-run configuration +- Add Telegram Bot API channel as WASM module +- Add OpenClaw feature parity tracking matrix +- Add Chat Completions API support and expand REPL debugging +- Implementing channels to be handled in wasm +- Support non interactive mode and model selection +- Implement tool approval, fix tool definition refresh, and wire embeddings +- Tool use +- Wiring more +- Add heartbeat integration, planning phase, and auto-repair +- Login flow +- Extend support for session management +- Adding builder capability +- Load tools at launch +- Fix multiline message rendering in TUI +- Parse NEAR AI alternative response format with output field +- Handle NEAR AI plain text responses +- Disable mouse capture to allow text selection in TUI +- Add verbose logging to debug empty NEAR AI responses +- Improve NEAR AI response parsing for varying response formats +- Show status/thinking messages in chat window, debug empty responses +- Add timeout and logging to NEAR AI provider +- Add status updates to show agent thinking/processing state +- Add CLI subcommands for WASM tool management +- Fix TUI shutdown: send /shutdown message and handle in agent loop +- Remove SimpleCliChannel, add Ctrl+D twice quit, redirect logs to TUI +- Fix TuiChannel integration and enable in main.rs +- Integrate Codex patterns: task scheduler, TUI, sessions, compaction +- Adding LICENSE +- Add README with IronClaw branding +- Add WASM sandbox secure API extension +- Wire database Store into agent loop +- Implementing WASM runtime +- Add workspace integration tests +- Compact memory_tree output format +- Replace memory_list with memory_tree tool +- Simplify workspace to path-based storage, remove legacy code +- Add NEAR AI chat-api as default LLM provider +- Add CLAUDE.md project documentation +- Add workspace and memory system (OpenClaw-inspired) +- Initial implementation of the agent framework From 5582a0dfbfdb53cd892cbb220618fb61147c840f Mon Sep 17 00:00:00 2001 From: Vlad Frolov Date: Thu, 12 Feb 2026 22:55:08 +0100 Subject: [PATCH 11/33] ci: Make sure that the binaries release CD it kicking in after release-plz --- .github/workflows/release-plz.yml | 12 +++++++++++- .github/workflows/release.yml | 11 ++++++++--- Cargo.toml | 3 +-- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release-plz.yml b/.github/workflows/release-plz.yml index 519abe97..169b072f 100644 --- a/.github/workflows/release-plz.yml +++ b/.github/workflows/release-plz.yml @@ -24,12 +24,22 @@ jobs: - &install-rust name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable + # Generating a GitHub token, so that PRs and tags created by + # the release-plz-action can trigger actions workflows. + - name: Generate GitHub token + uses: actions/create-github-app-token@v2 + id: generate-token + with: + # GitHub App ID secret name + app-id: ${{ secrets.GITHUB_RELEASES_MANAGER_APP_ID }} + # GitHub App private key secret name + private-key: ${{ secrets.GITHUB_RELEASES_MANAGER_APP_PRIVATE_KEY }} - name: Run release-plz uses: release-plz/action@v0.5 with: command: release env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }} CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} # Create a PR with the new versions and changelog, preparing the next release. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 118f02d0..1c5a0448 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,4 +1,4 @@ -# This file was autogenerated by dist: https://opensource.axo.dev/cargo-dist/ +# This file was autogenerated by dist: https://axodotdev.github.io/cargo-dist # # Copyright 2022-2024, axodotdev # SPDX-License-Identifier: MIT or Apache-2.0 @@ -58,6 +58,7 @@ jobs: steps: - uses: actions/checkout@v4 with: + persist-credentials: false submodules: recursive - name: Install dist # we specify bash to get pipefail; it guards against the `curl` command @@ -117,6 +118,7 @@ jobs: git config --global core.longpaths true - uses: actions/checkout@v4 with: + persist-credentials: false submodules: recursive - name: Install Rust non-interactively if not already installed if: ${{ matrix.container }} @@ -179,6 +181,7 @@ jobs: steps: - uses: actions/checkout@v4 with: + persist-credentials: false submodules: recursive - name: Install cached dist uses: actions/download-artifact@v4 @@ -218,8 +221,8 @@ jobs: - plan - build-local-artifacts - build-global-artifacts - # Only run if we're "publishing", and only if local and global didn't fail (skipped is fine) - if: ${{ always() && needs.plan.outputs.publishing == 'true' && (needs.build-global-artifacts.result == 'skipped' || needs.build-global-artifacts.result == 'success') && (needs.build-local-artifacts.result == 'skipped' || needs.build-local-artifacts.result == 'success') }} + # Only run if we're "publishing", and only if plan, local and global didn't fail (skipped is fine) + if: ${{ always() && needs.plan.result == 'success' && needs.plan.outputs.publishing == 'true' && (needs.build-global-artifacts.result == 'skipped' || needs.build-global-artifacts.result == 'success') && (needs.build-local-artifacts.result == 'skipped' || needs.build-local-artifacts.result == 'success') }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} runs-on: "ubuntu-22.04" @@ -228,6 +231,7 @@ jobs: steps: - uses: actions/checkout@v4 with: + persist-credentials: false submodules: recursive - name: Install cached dist uses: actions/download-artifact@v4 @@ -321,4 +325,5 @@ jobs: steps: - uses: actions/checkout@v4 with: + persist-credentials: false submodules: recursive diff --git a/Cargo.toml b/Cargo.toml index de0d8181..32b4d437 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -146,7 +146,6 @@ lto = "thin" [workspace.metadata.dist] # The preferred dist version to use in CI (Cargo.toml SemVer syntax) cargo-dist-version = "0.30.3" -allow-dirty = ["ci"] # CI backends to support ci = "github" # The installers to generate for each app @@ -171,7 +170,7 @@ pr-run-mode = "upload" # Path that installers should place binaries in install-path = "CARGO_HOME" # Whether to install an updater program -install-updater = false +install-updater = true [workspace.metadata.dist.github-custom-runners] aarch64-unknown-linux-gnu = "ubuntu-24.04-arm" From e9e0374c85f9cbec673913b16db7b353f273c682 Mon Sep 17 00:00:00 2001 From: Vlad Frolov Date: Thu, 12 Feb 2026 23:05:30 +0100 Subject: [PATCH 12/33] ci: Renamed the secrets in release-plz.yml to match the configuration --- .github/workflows/release-plz.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release-plz.yml b/.github/workflows/release-plz.yml index 169b072f..ff352c91 100644 --- a/.github/workflows/release-plz.yml +++ b/.github/workflows/release-plz.yml @@ -31,9 +31,9 @@ jobs: id: generate-token with: # GitHub App ID secret name - app-id: ${{ secrets.GITHUB_RELEASES_MANAGER_APP_ID }} + app-id: ${{ secrets.GH_RELEASES_MANAGER_APP_ID }} # GitHub App private key secret name - private-key: ${{ secrets.GITHUB_RELEASES_MANAGER_APP_PRIVATE_KEY }} + private-key: ${{ secrets.GH_RELEASES_MANAGER_APP_PRIVATE_KEY }} - name: Run release-plz uses: release-plz/action@v0.5 with: From 54ce7434fb2c77915044cbf9c2824e6d6cd089fd Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 12 Feb 2026 23:09:48 +0100 Subject: [PATCH 13/33] chore: release v0.1.1 (#54) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 7 +++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2de5620a..5f8a0855 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.1](https://github.com/nearai/ironclaw/compare/v0.1.0...v0.1.1) - 2026-02-12 + +### Other + +- Renamed the secrets in release-plz.yml to match the configuration +- Make sure that the binaries release CD it kicking in after release-plz + ## [0.1.0](https://github.com/nearai/ironclaw/releases/tag/v0.1.0) - 2026-02-12 ### Added diff --git a/Cargo.lock b/Cargo.lock index 3e26cae9..14e6b58c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2291,7 +2291,7 @@ dependencies = [ [[package]] name = "ironclaw" -version = "0.1.0" +version = "0.1.1" dependencies = [ "aes-gcm", "aho-corasick", diff --git a/Cargo.toml b/Cargo.toml index 32b4d437..a8f55038 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironclaw" -version = "0.1.0" +version = "0.1.1" edition = "2024" rust-version = "1.85" description = "Secure personal AI assistant that protects your data and expands its capabilities on the fly" From e796b838fa9155f02aa925faeba3ca08f03a9eb3 Mon Sep 17 00:00:00 2001 From: Vlad Frolov Date: Thu, 12 Feb 2026 23:11:20 +0100 Subject: [PATCH 14/33] ci: Skip creating GitHub Release with release-plz [cargo-dist will do it in the release workflow] --- release-plz.toml | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 release-plz.toml diff --git a/release-plz.toml b/release-plz.toml new file mode 100644 index 00000000..e8e0670f --- /dev/null +++ b/release-plz.toml @@ -0,0 +1,2 @@ +[workspace] +git_release_enable = false From 2039442885bbecfef4de6ca2c72bcb94d47af5d3 Mon Sep 17 00:00:00 2001 From: Vlad Frolov Date: Thu, 12 Feb 2026 23:41:27 +0100 Subject: [PATCH 15/33] ci: Disabled Windows ARM64 builds as auto-updater [provided by cargo-dist] does not support this platform yet and it is not a common platform for us to support --- Cargo.toml | 2 -- 1 file changed, 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index a8f55038..2a4c240a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -156,7 +156,6 @@ publish-jobs = ["npm"] targets = [ "aarch64-apple-darwin", "aarch64-unknown-linux-gnu", - "aarch64-pc-windows-msvc", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc", @@ -176,6 +175,5 @@ install-updater = true aarch64-unknown-linux-gnu = "ubuntu-24.04-arm" x86_64-unknown-linux-gnu = "ubuntu-22.04" x86_64-pc-windows-msvc = "windows-2022" -aarch64-pc-windows-msvc = "windows-2025" x86_64-apple-darwin = "macos-15-intel" aarch64-apple-darwin = "macos-14" From 14254a699f4128e7c2f19f57531c6561255fd661 Mon Sep 17 00:00:00 2001 From: Vlad Frolov Date: Thu, 12 Feb 2026 23:42:46 +0100 Subject: [PATCH 16/33] docs: Added Installation instructions for the pre-built binaries --- README.md | 51 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d98da3da..3a291854 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,54 @@ IronClaw is the AI assistant you can actually trust with your personal and profe - PostgreSQL 15+ with [pgvector](https://github.com/pgvector/pgvector) extension - NEAR AI account (authentication handled via setup wizard) -### Build +## Download or Build + +Visit [Releases page](https://github.com/nearai/ironclaw/releases/) to see the latest updates. + +
+ Install via Windows Installer (Windows) + +Download the [Windows Installer](https://github.com/nearai/ironclaw/releases/latest/download/ironclaw-x86_64-pc-windows-msvc.msi) and run it. + +
+ +
+ Install via powershell script (Windows) + +```sh +irm https://github.com/nearai/ironclaw/releases/latest/download/ironclaw-installer.ps1 | iex +``` + +
+ +
+ Install via shell script (macOS, Linux, Windows/WSL) + +```sh +curl --proto '=https' --tlsv1.2 -LsSf https://github.com/nearai/ironclaw/releases/latest/download/ironclaw-installer.sh | sh +``` +
+ +
+ Run via npx (Node.js on Windows, Linux, macOS) + +```sh +npx ironclaw +``` +
+ +
+ Use in package.json scripts (Node.js on Windows, Linux, macOS) + +```sh +npm install ironclaw +``` +
+ +
+ Compile the source code (Cargo on Windows, Linux, macOS) + +Install it with `cargo`, just make sure you have [Rust](https://rustup.rs) installed on your computer. ```bash # Clone the repository @@ -87,6 +134,8 @@ cargo test For **full release** (after modifying channel sources), run `./scripts/build-all.sh` to rebuild channels first. +
+ ### Database Setup ```bash From 247445f819be5532bda1bf16de5113cf482d3074 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 12 Feb 2026 23:47:16 +0100 Subject: [PATCH 17/33] chore: release v0.1.2 (#55) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 7 +++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f8a0855..0f530600 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.2](https://github.com/nearai/ironclaw/compare/v0.1.1...v0.1.2) - 2026-02-12 + +### Other + +- Added Installation instructions for the pre-built binaries +- Disabled Windows ARM64 builds as auto-updater [provided by cargo-dist] does not support this platform yet and it is not a common platform for us to support + ## [0.1.1](https://github.com/nearai/ironclaw/compare/v0.1.0...v0.1.1) - 2026-02-12 ### Other diff --git a/Cargo.lock b/Cargo.lock index 14e6b58c..9247ec70 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2291,7 +2291,7 @@ dependencies = [ [[package]] name = "ironclaw" -version = "0.1.1" +version = "0.1.2" dependencies = [ "aes-gcm", "aho-corasick", diff --git a/Cargo.toml b/Cargo.toml index 2a4c240a..af219dde 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironclaw" -version = "0.1.1" +version = "0.1.2" edition = "2024" rust-version = "1.85" description = "Secure personal AI assistant that protects your data and expands its capabilities on the fly" From a70c89d9e3539f60940d972ab27ffe3d57ab051b Mon Sep 17 00:00:00 2001 From: Vlad Frolov Date: Fri, 13 Feb 2026 00:17:13 +0100 Subject: [PATCH 18/33] ci: Disabled npm publishing as the name is already taken --- .github/workflows/release.yml | 31 +------------------------------ Cargo.toml | 2 +- README.md | 16 ---------------- 3 files changed, 2 insertions(+), 47 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1c5a0448..530a221a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -282,43 +282,14 @@ jobs: gh release create "${{ needs.plan.outputs.tag }}" --target "$RELEASE_COMMIT" $PRERELEASE_FLAG --title "$ANNOUNCEMENT_TITLE" --notes-file "$RUNNER_TEMP/notes.txt" artifacts/* - publish-npm: - needs: - - plan - - host - runs-on: "ubuntu-22.04" - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - PLAN: ${{ needs.plan.outputs.val }} - if: ${{ !fromJson(needs.plan.outputs.val).announcement_is_prerelease || fromJson(needs.plan.outputs.val).publish_prereleases }} - steps: - - name: Fetch npm packages - uses: actions/download-artifact@v4 - with: - pattern: artifacts-* - path: npm/ - merge-multiple: true - - uses: actions/setup-node@v4 - with: - node-version: '20.x' - registry-url: 'https://registry.npmjs.org' - - run: | - for release in $(echo "$PLAN" | jq --compact-output '.releases[] | select([.artifacts[] | endswith("-npm-package.tar.gz")] | any)'); do - pkg=$(echo "$release" | jq '.artifacts[] | select(endswith("-npm-package.tar.gz"))' --raw-output) - npm publish --access public "./npm/${pkg}" - done - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - announce: needs: - plan - host - - publish-npm # use "always() && ..." to allow us to wait for all publish jobs while # still allowing individual publish jobs to skip themselves (for prereleases). # "host" however must run to completion, no skipping allowed! - if: ${{ always() && needs.host.result == 'success' && (needs.publish-npm.result == 'skipped' || needs.publish-npm.result == 'success') }} + if: ${{ always() && needs.host.result == 'success' }} runs-on: "ubuntu-22.04" env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/Cargo.toml b/Cargo.toml index af219dde..9e38febd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -151,7 +151,7 @@ ci = "github" # The installers to generate for each app installers = ["shell", "powershell", "npm", "msi"] # Publish jobs to run in CI -publish-jobs = ["npm"] +publish-jobs = [] # Target platforms to build apps for (Rust target-triple syntax) targets = [ "aarch64-apple-darwin", diff --git a/README.md b/README.md index 3a291854..b530894c 100644 --- a/README.md +++ b/README.md @@ -99,22 +99,6 @@ curl --proto '=https' --tlsv1.2 -LsSf https://github.com/nearai/ironclaw/release ``` -
- Run via npx (Node.js on Windows, Linux, macOS) - -```sh -npx ironclaw -``` -
- -
- Use in package.json scripts (Node.js on Windows, Linux, macOS) - -```sh -npm install ironclaw -``` -
-
Compile the source code (Cargo on Windows, Linux, macOS) From bada79ba4a63cf5605885bf0eebd6b28e91ce94d Mon Sep 17 00:00:00 2001 From: Vlad Frolov Date: Fri, 13 Feb 2026 00:17:43 +0100 Subject: [PATCH 19/33] ci: Enabled builds caching during CI/CD --- .github/workflows/code_style.yml | 3 ++- .github/workflows/release-plz.yml | 2 ++ .github/workflows/test.yml | 3 ++- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/code_style.yml b/.github/workflows/code_style.yml index c013e031..19f7d725 100644 --- a/.github/workflows/code_style.yml +++ b/.github/workflows/code_style.yml @@ -8,12 +8,13 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Install Rust uses: dtolnay/rust-toolchain@stable with: profile: minimal components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 - name: Check formatting run: | cargo fmt --all -- --check diff --git a/.github/workflows/release-plz.yml b/.github/workflows/release-plz.yml index ff352c91..142b2b20 100644 --- a/.github/workflows/release-plz.yml +++ b/.github/workflows/release-plz.yml @@ -24,6 +24,7 @@ jobs: - &install-rust name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 # Generating a GitHub token, so that PRs and tags created by # the release-plz-action can trigger actions workflows. - name: Generate GitHub token @@ -56,6 +57,7 @@ jobs: steps: - *checkout - *install-rust + - uses: Swatinem/rust-cache@v2 - name: Run release-plz uses: release-plz/action@v0.5 with: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1e5d624f..13fc8410 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,10 +11,11 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Install Rust uses: dtolnay/rust-toolchain@stable with: profile: minimal + - uses: Swatinem/rust-cache@v2 - name: Run Tests run: cargo test --all-features -- --nocapture From e0a43c81f93e4b6107b6810eb12c606d82cd81e7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 13 Feb 2026 00:28:13 +0100 Subject: [PATCH 20/33] chore: release v0.1.3 (#56) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 7 +++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f530600..f04b7e6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.3](https://github.com/nearai/ironclaw/compare/v0.1.2...v0.1.3) - 2026-02-12 + +### Other + +- Enabled builds caching during CI/CD +- Disabled npm publishing as the name is already taken + ## [0.1.2](https://github.com/nearai/ironclaw/compare/v0.1.1...v0.1.2) - 2026-02-12 ### Other diff --git a/Cargo.lock b/Cargo.lock index 9247ec70..88c2b191 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2291,7 +2291,7 @@ dependencies = [ [[package]] name = "ironclaw" -version = "0.1.2" +version = "0.1.3" dependencies = [ "aes-gcm", "aho-corasick", diff --git a/Cargo.toml b/Cargo.toml index 9e38febd..543daed0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironclaw" -version = "0.1.2" +version = "0.1.3" edition = "2024" rust-version = "1.85" description = "Secure personal AI assistant that protects your data and expands its capabilities on the fly" From 33ef0a6ea5a9adb01963656fad925a929e09e97c Mon Sep 17 00:00:00 2001 From: Illia Polosukhin Date: Thu, 12 Feb 2026 21:25:20 -0800 Subject: [PATCH 21/33] fix: security hardening across all layers (#35) * fix: comprehensive security hardening across all layers Critical: - Replace --dangerously-skip-permissions with explicit tool allowlist via settings.json (Claude Code bridge) - Constant-time token comparison (subtle crate) in web auth and orchestrator auth to prevent timing attacks High: - Revoke tokens and clean up handles on container creation failure - Drop SETUID/SETGID capabilities from containers (keep only CHOWN) - Disable redirect following in HTTP tool and WASM wrapper (SSRF) - Reject URL userinfo (@) in WASM allowlist parser (host confusion) - Fix binary body bypassing leak detection (from_utf8 -> from_utf8_lossy) - Protect identity files from LLM overwrites (prompt injection defense) - Prevent tool shadowing: built-in tools cannot be replaced dynamically - User-scoped job APIs: list/detail/cancel/restart/prompt/events/files - CORS restricted to localhost origins, WebSocket origin validation - Sandbox shell fail-closed: no silent fallback to unsandboxed execution - Scrub secrets from log broadcaster before SSE broadcast - XSS sanitization on rendered markdown in web UI - WASM epoch ticker thread so timeout deadlines actually fire Medium: - Cap state transition history at 200 entries - SSE/WebSocket connection limit (100 max) - Request body size limit (1MB) - Response body size limit enforcement in WASM HTTP - UTF-8 safe string truncation (routine engine, shell tool) - Fix PolicyAction::Sanitize to actually run the sanitizer - TOCTOU fix in scheduler and context manager (hold write lock) - Project file serving moved behind auth - Path traversal guard on project_id - Session file permissions set to 0600 on unix - AtomicUsize for routine running_count (panic-safe) - Completion detection hardened against false positives and tool injection - Tool output no longer drives job completion (only LLM response) Co-Authored-By: Claude Opus 4.6 * fix: address security review findings across all layers - Fix path traversal sandbox bypass via lexical normalization (file.rs) - Fix SSRF via DNS rebinding with pre-request hostname resolution (http.rs) - Add token budget enforcement on LLM calls (reasoning.rs, state.rs) - Fix cross-user chat history leak with ownership verification (store.rs, server.rs) - Add sliding-window rate limiter on gateway chat endpoint (server.rs) - Harden extension install: HTTPS-only, 50MB cap, WASM magic validation (manager.rs) - Add destructive command blocklist that overrides shell auto-approval (shell.rs) - Add 5MB response body size cap to HTTP tool (http.rs) Co-Authored-By: Claude Opus 4.6 * refactor: deduplicate shared helpers and remove dead code Extract floor_char_boundary and llm_signals_completion into src/util.rs, unifying diverging phrase lists from agent/worker.rs and worker/runtime.rs. Remove dead RespondResult::usage(), duplicate PROTECTED_IDENTITY_FILES constant, double LeakDetector scanning in WebLogLayer, and invalid 0.0.0.0 origin from WebSocket allow list. Co-Authored-By: Claude Opus 4.6 * fix: address PR review findings and CI test failures - Fix record_failed_approve: .truncate(true) wiped the attempts file before reading, so failed pairing attempts never accumulated and rate limiting never triggered. - Guard wizard WASM test: skip gracefully when channel build artifacts are absent (CI doesn't compile wasm32-wasip2 targets). - Fix DNS rebinding check: use port 0 instead of hardcoded 443, since the port is irrelevant for hostname resolution. - Remove hardcoded CORS port 3001: the dynamic addr.port() entries already cover the actual server port. - Require WebSocket Origin header: reject connections that omit it entirely, since browsers always send Origin for WS upgrades and a missing header indicates a non-browser client bypassing the check. Co-Authored-By: Claude Opus 4.6 * fix: address second round of PR review findings - store.rs: reintroduce file locking around read-modify-write in record_failed_approve (concurrent callers could clobber each other). - sse.rs: replace load+check+fetch_add with atomic fetch_update in both subscribe_raw() and subscribe() to prevent overshooting max_connections. - ws.rs: decrement WS tracker before early return when subscribe_raw() returns None (connection limit reached), fixing a counter leak. - server.rs: parse WS Origin host exactly instead of prefix matching, preventing bypass via crafted origins like http://localhost.evil.com. - workspace_integration.rs: skip tests gracefully when Postgres is unreachable instead of panicking (fixes 10 CI failures). Co-Authored-By: Claude Opus 4.6 * fix: add Origin header to WS integration tests The Origin header requirement added in a3b0190 broke the WS gateway integration tests. Test clients now send Origin: http://127.0.0.1:{port} to match the server's localhost validation. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- Cargo.lock | 1 + Cargo.toml | 1 + src/agent/agent_loop.rs | 40 +++++- src/agent/routine_engine.rs | 26 ++-- src/agent/scheduler.rs | 106 +++++++------- src/agent/worker.rs | 95 ++++++++++--- src/channels/wasm/wrapper.rs | 32 ++++- src/channels/web/auth.rs | 9 +- src/channels/web/log_layer.rs | 44 +++++- src/channels/web/mod.rs | 2 + src/channels/web/server.rs | 241 +++++++++++++++++++++++++++++--- src/channels/web/sse.rs | 71 ++++++++-- src/channels/web/static/app.js | 24 ++++ src/channels/web/ws.rs | 14 +- src/config.rs | 51 +++++++ src/context/manager.rs | 9 +- src/context/state.rs | 88 ++++++++++++ src/extensions/manager.rs | 39 +++++- src/history/store.rs | 102 ++++++++++++++ src/lib.rs | 1 + src/llm/mod.rs | 5 +- src/llm/reasoning.rs | 73 +++++++--- src/llm/session.rs | 19 +++ src/main.rs | 4 + src/orchestrator/auth.rs | 5 +- src/orchestrator/job_manager.rs | 100 +++++++++---- src/pairing/store.rs | 14 +- src/safety/leak_detector.rs | 22 ++- src/safety/mod.rs | 26 +++- src/sandbox/container.rs | 6 +- src/setup/wizard.rs | 9 ++ src/tools/builder/core.rs | 2 +- src/tools/builtin/file.rs | 172 ++++++++++++++++++----- src/tools/builtin/http.rs | 84 ++++++++++- src/tools/builtin/memory.rs | 30 ++++ src/tools/builtin/mod.rs | 2 +- src/tools/builtin/shell.rs | 97 +++++++++++-- src/tools/registry.rs | 113 ++++++++++++++- src/tools/wasm/allowlist.rs | 58 ++++++++ src/tools/wasm/runtime.rs | 23 +++ src/tools/wasm/wrapper.rs | 196 +++++++++++++++++++++++++- src/util.rs | 178 +++++++++++++++++++++++ src/worker/claude_bridge.rs | 118 ++++++++++++++-- src/worker/runtime.rs | 46 ++++-- tests/workspace_integration.rs | 42 ++++++ tests/ws_gateway_integration.rs | 8 +- 46 files changed, 2165 insertions(+), 283 deletions(-) create mode 100644 src/util.rs diff --git a/Cargo.lock b/Cargo.lock index 88c2b191..be2dcb82 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2334,6 +2334,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "subtle", "tempfile", "termimad", "testcontainers-modules", diff --git a/Cargo.toml b/Cargo.toml index 543daed0..bcb9b095 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -102,6 +102,7 @@ hkdf = "0.12" sha2 = "0.10" blake3 = "1" rand = "0.8" +subtle = "2" # Constant-time comparisons for token validation # Multi-provider LLM support rig-core = "0.30" diff --git a/src/agent/agent_loop.rs b/src/agent/agent_loop.rs index 0a1d2b09..6c28d95d 100644 --- a/src/agent/agent_loop.rs +++ b/src/agent/agent_loop.rs @@ -1082,9 +1082,16 @@ impl Agent { m }); - let result = reasoning.respond_with_tools(&context).await?; + let output = reasoning.respond_with_tools(&context).await?; - match result { + // Track token usage for budget enforcement + tracing::debug!( + "LLM call used {} input + {} output tokens", + output.usage.input_tokens, + output.usage.output_tokens + ); + + match output.result { RespondResult::Text(text) => { // If no tools have been executed yet, prompt the LLM to use tools // This handles the case where the model explains what it will do @@ -1148,11 +1155,38 @@ impl Agent { if let Some(tool) = self.tools().get(&tc.name).await { if tool.requires_approval() { // Check if auto-approved for this session - let is_auto_approved = { + let mut is_auto_approved = { let sess = session.lock().await; sess.is_tool_auto_approved(&tc.name) }; + // For shell commands, override auto-approval for + // destructive patterns that should always require + // explicit per-invocation approval. + if is_auto_approved && tc.name == "shell" { + if let Some(cmd) = tc + .arguments + .as_str() + .and_then(|s| { + serde_json::from_str::(s).ok() + }) + .and_then(|v| { + v.get("command") + .and_then(|c| c.as_str().map(String::from)) + }) + { + if crate::tools::builtin::shell::requires_explicit_approval( + &cmd, + ) { + tracing::info!( + "Shell command '{}' requires explicit approval despite auto-approve", + cmd.chars().take(80).collect::() + ); + is_auto_approved = false; + } + } + } + if !is_auto_approved { // Need approval - store pending request and return let pending = PendingApproval { diff --git a/src/agent/routine_engine.rs b/src/agent/routine_engine.rs index 6a35e879..ed4037c9 100644 --- a/src/agent/routine_engine.rs +++ b/src/agent/routine_engine.rs @@ -11,6 +11,7 @@ //! Full-job routines are delegated to the existing `Scheduler`. use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; use std::time::Duration; use chrono::Utc; @@ -36,7 +37,7 @@ pub struct RoutineEngine { /// Sender for notifications (routed to channel manager). notify_tx: mpsc::Sender, /// Currently running routine count (across all routines). - running_count: Arc>, + running_count: Arc, /// Compiled event regex cache: routine_id -> compiled regex. event_cache: Arc>>, } @@ -55,7 +56,7 @@ impl RoutineEngine { llm, workspace, notify_tx, - running_count: Arc::new(RwLock::new(0)), + running_count: Arc::new(AtomicUsize::new(0)), event_cache: Arc::new(RwLock::new(Vec::new())), } } @@ -126,7 +127,7 @@ impl RoutineEngine { } // Global capacity check - if *self.running_count.read().await >= self.config.max_concurrent_routines { + if self.running_count.load(Ordering::Relaxed) >= self.config.max_concurrent_routines { tracing::warn!(routine = %routine.name, "Skipped: global max concurrent reached"); continue; } @@ -150,7 +151,7 @@ impl RoutineEngine { }; for routine in routines { - if *self.running_count.read().await >= self.config.max_concurrent_routines { + if self.running_count.load(Ordering::Relaxed) >= self.config.max_concurrent_routines { tracing::warn!("Global max concurrent routines reached, skipping remaining"); break; } @@ -297,17 +298,14 @@ struct EngineContext { llm: Arc, workspace: Arc, notify_tx: mpsc::Sender, - running_count: Arc>, + running_count: Arc, max_lightweight_tokens: u32, } /// Execute a routine run. Handles both lightweight and full_job modes. async fn execute_routine(ctx: EngineContext, routine: Routine, run: RoutineRun) { - // Increment running count - { - let mut count = ctx.running_count.write().await; - *count += 1; - } + // Increment running count (atomic: survives panics in the execution below) + ctx.running_count.fetch_add(1, Ordering::Relaxed); let result = match &routine.action { RoutineAction::Lightweight { @@ -327,10 +325,7 @@ async fn execute_routine(ctx: EngineContext, routine: Routine, run: RoutineRun) }; // Decrement running count - { - let mut count = ctx.running_count.write().await; - *count = count.saturating_sub(1); - } + ctx.running_count.fetch_sub(1, Ordering::Relaxed); // Process result let (status, summary, tokens) = match result { @@ -568,7 +563,8 @@ fn truncate(s: &str, max: usize) -> String { if s.len() <= max { s.to_string() } else { - format!("{}...", &s[..max]) + let end = crate::util::floor_char_boundary(s, max); + format!("{}...", &s[..end]) } } diff --git a/src/agent/scheduler.rs b/src/agent/scheduler.rs index 5175e3b2..f37bba28 100644 --- a/src/agent/scheduler.rs +++ b/src/agent/scheduler.rs @@ -79,63 +79,63 @@ impl Scheduler { /// Schedule a job for execution. pub async fn schedule(&self, job_id: Uuid) -> Result<(), JobError> { - // Check if already scheduled - if self.jobs.read().await.contains_key(&job_id) { - return Ok(()); - } + // Hold write lock for the entire check-insert sequence to prevent + // TOCTOU races where two concurrent calls both pass the checks. + { + let mut jobs = self.jobs.write().await; - // Check capacity - let current_count = self.jobs.read().await.len(); - if current_count >= self.config.max_parallel_jobs { - return Err(JobError::MaxJobsExceeded { - max: self.config.max_parallel_jobs, - }); - } - - // Transition job to in_progress - self.context_manager - .update_context(job_id, |ctx| { - ctx.transition_to( - JobState::InProgress, - Some("Scheduled for execution".to_string()), - ) - }) - .await? - .map_err(|s| JobError::ContextError { - id: job_id, - reason: s, - })?; - - // Create worker channel - let (tx, rx) = mpsc::channel(16); - - // Create worker with shared dependencies - let deps = WorkerDeps { - context_manager: self.context_manager.clone(), - llm: self.llm.clone(), - safety: self.safety.clone(), - tools: self.tools.clone(), - store: self.store.clone(), - timeout: self.config.job_timeout, - use_planning: self.config.use_planning, - }; - let worker = Worker::new(job_id, deps); - - // Spawn worker task - let handle = tokio::spawn(async move { - if let Err(e) = worker.run(rx).await { - tracing::error!("Worker for job {} failed: {}", job_id, e); + if jobs.contains_key(&job_id) { + return Ok(()); } - }); - // Start the worker - let _ = tx.send(WorkerMessage::Start).await; + if jobs.len() >= self.config.max_parallel_jobs { + return Err(JobError::MaxJobsExceeded { + max: self.config.max_parallel_jobs, + }); + } - // Store the scheduled job - self.jobs - .write() - .await - .insert(job_id, ScheduledJob { handle, tx }); + // Transition job to in_progress + self.context_manager + .update_context(job_id, |ctx| { + ctx.transition_to( + JobState::InProgress, + Some("Scheduled for execution".to_string()), + ) + }) + .await? + .map_err(|s| JobError::ContextError { + id: job_id, + reason: s, + })?; + + // Create worker channel + let (tx, rx) = mpsc::channel(16); + + // Create worker with shared dependencies + let deps = WorkerDeps { + context_manager: self.context_manager.clone(), + llm: self.llm.clone(), + safety: self.safety.clone(), + tools: self.tools.clone(), + store: self.store.clone(), + timeout: self.config.job_timeout, + use_planning: self.config.use_planning, + }; + let worker = Worker::new(job_id, deps); + + // Spawn worker task + let handle = tokio::spawn(async move { + if let Err(e) = worker.run(rx).await { + tracing::error!("Worker for job {} failed: {}", job_id, e); + } + }); + + // Start the worker + let _ = tx.send(WorkerMessage::Start).await; + + // Insert while still holding the write lock + jobs.insert(job_id, ScheduledJob { handle, tx }); + } // Cleanup task for this job to avoid capacity leaks let jobs = Arc::clone(&self.jobs); diff --git a/src/agent/worker.rs b/src/agent/worker.rs index 77b11004..65455839 100644 --- a/src/agent/worker.rs +++ b/src/agent/worker.rs @@ -248,16 +248,15 @@ Report when the job is complete or if you encounter issues you cannot resolve."# if selections.is_empty() { // No tools from select_tools, ask LLM directly (may still return tool calls) - let respond_result = reasoning.respond_with_tools(reason_ctx).await?; + let respond_output = reasoning.respond_with_tools(reason_ctx).await?; - match respond_result { + match respond_output.result { RespondResult::Text(response) => { - // Check for completion keywords - let response_lower = response.to_lowercase(); - if response_lower.contains("complete") - || response_lower.contains("finished") - || response_lower.contains("done") - { + // Check for explicit completion phrases. Use word-boundary + // aware checks to avoid false positives like "incomplete", + // "not done", or "unfinished". Only the LLM's own response + // (not tool output) can trigger this. + if crate::util::llm_signals_completion(&response) { self.mark_completed().await?; return Ok(()); } @@ -571,12 +570,9 @@ Report when the job is complete or if you encounter issues you cannot resolve."# wrapped, )); - // Check if job is complete - if output.contains("TASK_COMPLETE") || output.contains("JOB_DONE") { - self.mark_completed().await?; - return Ok(true); - } - + // Tool output never drives job completion. A malicious tool could + // emit "TASK_COMPLETE" to force premature completion. Only the LLM's + // own structured response (in execution_loop) can mark a job done. Ok(false) } Err(e) => { @@ -680,11 +676,7 @@ Report when the job is complete or if you encounter issues you cannot resolve."# let response = reasoning.respond(reason_ctx).await?; reason_ctx.messages.push(ChatMessage::assistant(&response)); - let response_lower = response.to_lowercase(); - if response_lower.contains("complete") - || response_lower.contains("finished") - || response_lower.contains("done") - { + if crate::util::llm_signals_completion(&response) { self.mark_completed().await?; } else { // Job not complete, could re-plan or fall back to direct selection @@ -779,3 +771,68 @@ impl From for Result { }) } } + +#[cfg(test)] +mod tests { + use crate::util::llm_signals_completion; + + #[test] + fn test_completion_positive_signals() { + assert!(llm_signals_completion("The job is complete.")); + assert!(llm_signals_completion( + "I have completed the task successfully." + )); + assert!(llm_signals_completion("The task is done.")); + assert!(llm_signals_completion("The task is finished.")); + assert!(llm_signals_completion( + "All steps are complete and verified." + )); + assert!(llm_signals_completion( + "I've done all the work. The work is done." + )); + assert!(llm_signals_completion( + "Successfully completed the migration." + )); + } + + #[test] + fn test_completion_negative_signals_block_false_positives() { + // These contain completion keywords but also negation, should NOT trigger. + assert!(!llm_signals_completion("The task is not complete yet.")); + assert!(!llm_signals_completion("This is not done.")); + assert!(!llm_signals_completion("The work is incomplete.")); + assert!(!llm_signals_completion( + "The migration is not yet finished." + )); + assert!(!llm_signals_completion("The job isn't done yet.")); + assert!(!llm_signals_completion("This remains unfinished.")); + } + + #[test] + fn test_completion_does_not_match_bare_substrings() { + // Bare words embedded in other text should NOT trigger completion. + assert!(!llm_signals_completion( + "I need to complete more work first." + )); + assert!(!llm_signals_completion( + "Let me finish the remaining steps." + )); + assert!(!llm_signals_completion( + "I'm done analyzing, now let me fix it." + )); + assert!(!llm_signals_completion( + "I completed step 1 but step 2 remains." + )); + } + + #[test] + fn test_completion_tool_output_injection() { + // A malicious tool output echoed by the LLM should not trigger + // completion unless it forms a genuine completion phrase. + assert!(!llm_signals_completion("TASK_COMPLETE")); + assert!(!llm_signals_completion("JOB_DONE")); + assert!(!llm_signals_completion( + "The tool returned: TASK_COMPLETE signal" + )); + } +} diff --git a/src/channels/wasm/wrapper.rs b/src/channels/wasm/wrapper.rs index 0288a2d7..da96255f 100644 --- a/src/channels/wasm/wrapper.rs +++ b/src/channels/wasm/wrapper.rs @@ -273,6 +273,16 @@ impl near::agent::channel_host::Host for ChannelStoreData { .scan_http_request(&url, &header_vec, body.as_deref()) .map_err(|e| format!("Potential secret leak blocked: {}", e))?; + // Get the max response size from capabilities (default 10MB). + let max_response_bytes = self + .host_state + .capabilities() + .tool_capabilities + .http + .as_ref() + .map(|h| h.max_response_bytes) + .unwrap_or(10 * 1024 * 1024); + // Make the HTTP request using blocking I/O // We're already in a spawn_blocking context, so we can use block_on let result = tokio::runtime::Handle::current().block_on(async { @@ -325,11 +335,29 @@ impl near::agent::channel_host::Host for ChannelStoreData { }) .collect(); let headers_json = serde_json::to_string(&response_headers).unwrap_or_default(); + + // Enforce max response body size to prevent memory exhaustion. + let max_response = max_response_bytes; + if let Some(cl) = response.content_length() { + if cl as usize > max_response { + return Err(format!( + "Response body too large: {} bytes exceeds limit of {} bytes", + cl, max_response + )); + } + } let body = response .bytes() .await - .map_err(|e| format!("Failed to read response body: {}", e))? - .to_vec(); + .map_err(|e| format!("Failed to read response body: {}", e))?; + if body.len() > max_response { + return Err(format!( + "Response body too large: {} bytes exceeds limit of {} bytes", + body.len(), + max_response + )); + } + let body = body.to_vec(); tracing::info!( status = status, diff --git a/src/channels/web/auth.rs b/src/channels/web/auth.rs index 219528eb..34fc9f07 100644 --- a/src/channels/web/auth.rs +++ b/src/channels/web/auth.rs @@ -6,6 +6,7 @@ use axum::{ middleware::Next, response::{IntoResponse, Response}, }; +use subtle::ConstantTimeEq; /// Shared auth state injected via axum middleware state. #[derive(Clone)] @@ -23,22 +24,22 @@ pub async fn auth_middleware( request: Request, next: Next, ) -> Response { - // Try Authorization header first + // Try Authorization header first (constant-time comparison) if let Some(auth_header) = headers.get("authorization") { if let Ok(value) = auth_header.to_str() { if let Some(token) = value.strip_prefix("Bearer ") { - if token == auth.token { + if bool::from(token.as_bytes().ct_eq(auth.token.as_bytes())) { return next.run(request).await; } } } } - // Fall back to query parameter (for SSE EventSource) + // Fall back to query parameter for SSE EventSource (constant-time comparison) if let Some(query) = request.uri().query() { for pair in query.split('&') { if let Some(token) = pair.strip_prefix("token=") { - if token == auth.token { + if bool::from(token.as_bytes().ct_eq(auth.token.as_bytes())) { return next.run(request).await; } } diff --git a/src/channels/web/log_layer.rs b/src/channels/web/log_layer.rs index 02a8e4ed..3a55b994 100644 --- a/src/channels/web/log_layer.rs +++ b/src/channels/web/log_layer.rs @@ -24,6 +24,8 @@ use tokio::sync::broadcast; use tracing::field::{Field, Visit}; use tracing_subscriber::Layer; +use crate::safety::LeakDetector; + /// Maximum number of recent log entries kept for late-joining SSE subscribers. const HISTORY_CAP: usize = 500; @@ -46,6 +48,8 @@ pub struct LogEntry { pub struct LogBroadcaster { tx: broadcast::Sender, recent: Mutex>, + /// Scrubs secrets from log messages before broadcasting to SSE clients. + leak_detector: LeakDetector, } impl LogBroadcaster { @@ -54,10 +58,19 @@ impl LogBroadcaster { Self { tx, recent: Mutex::new(VecDeque::with_capacity(HISTORY_CAP)), + leak_detector: LeakDetector::new(), } } - pub fn send(&self, entry: LogEntry) { + pub fn send(&self, mut entry: LogEntry) { + // Scrub secrets from the message before it reaches any subscriber. + // This is defense-in-depth: even if code elsewhere accidentally logs + // a secret, it won't be broadcast to SSE clients. + entry.message = self + .leak_detector + .scan_and_clean(&entry.message) + .unwrap_or_else(|_| "[log message redacted: contained blocked secret]".to_string()); + // Stash in ring buffer (for late joiners) if let Ok(mut buf) = self.recent.lock() { if buf.len() >= HISTORY_CAP { @@ -145,6 +158,9 @@ impl Visit for MessageVisitor { /// /// Only forwards DEBUG and above. Attach to the tracing subscriber /// alongside the existing fmt layer. +/// +/// Log messages are scrubbed through `LeakDetector` in `LogBroadcaster::send()` +/// (the single funnel point for all log output, including late-joiner history). pub struct WebLogLayer { broadcaster: Arc, } @@ -178,6 +194,7 @@ impl Layer for WebLogLayer { timestamp: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true), }; + // LeakDetector scrubbing happens inside broadcaster.send() self.broadcaster.send(entry); } } @@ -313,4 +330,29 @@ mod tests { let v = MessageVisitor::new(); assert_eq!(v.finish(), ""); } + + #[test] + fn test_broadcaster_has_leak_detector() { + let broadcaster = LogBroadcaster::new(); + // Verify the leak detector is initialized with default patterns + assert!(broadcaster.leak_detector.pattern_count() > 0); + } + + #[test] + fn test_leak_detector_scrubs_api_key_in_log() { + let detector = crate::safety::LeakDetector::new(); + let msg = "Connecting with token sk-proj-test1234567890abcdefghij"; + let result = detector.scan_and_clean(msg); + // Should be blocked (OpenAI key pattern) + assert!(result.is_err()); + } + + #[test] + fn test_leak_detector_passes_clean_log() { + let detector = crate::safety::LeakDetector::new(); + let msg = "Request completed status=200 url=https://api.example.com/data"; + let result = detector.scan_and_clean(msg); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), msg); + } } diff --git a/src/channels/web/mod.rs b/src/channels/web/mod.rs index c4df28c1..80a5242b 100644 --- a/src/channels/web/mod.rs +++ b/src/channels/web/mod.rs @@ -81,6 +81,7 @@ impl GatewayChannel { user_id: config.user_id.clone(), shutdown_tx: tokio::sync::RwLock::new(None), ws_tracker: Some(Arc::new(ws::WsConnectionTracker::new())), + chat_rate_limiter: server::RateLimiter::new(30, 60), }); Self { @@ -106,6 +107,7 @@ impl GatewayChannel { user_id: self.state.user_id.clone(), shutdown_tx: tokio::sync::RwLock::new(None), ws_tracker: self.state.ws_tracker.clone(), + chat_rate_limiter: server::RateLimiter::new(30, 60), }; mutate(&mut new_state); self.state = Arc::new(new_state); diff --git a/src/channels/web/server.rs b/src/channels/web/server.rs index b4a56bb0..8298a0bb 100644 --- a/src/channels/web/server.rs +++ b/src/channels/web/server.rs @@ -5,10 +5,11 @@ use std::convert::Infallible; use std::net::SocketAddr; use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; use axum::{ Json, Router, - extract::{Path, Query, State, WebSocketUpgrade}, + extract::{DefaultBodyLimit, Path, Query, State, WebSocketUpgrade}, http::{StatusCode, header}, middleware, response::{ @@ -20,6 +21,7 @@ use axum::{ use serde::Deserialize; use tokio::sync::{mpsc, oneshot}; use tokio_stream::StreamExt; +use tower_http::cors::{AllowHeaders, CorsLayer}; use uuid::Uuid; use crate::agent::SessionManager; @@ -44,6 +46,69 @@ pub type PromptQueue = Arc< >, >; +/// Simple sliding-window rate limiter. +/// +/// Tracks the number of requests in the current window. Resets when the window expires. +/// Not per-IP (since this is a single-user gateway with auth), but prevents flooding. +pub struct RateLimiter { + /// Requests remaining in the current window. + remaining: AtomicU64, + /// Epoch second when the current window started. + window_start: AtomicU64, + /// Maximum requests per window. + max_requests: u64, + /// Window duration in seconds. + window_secs: u64, +} + +impl RateLimiter { + pub fn new(max_requests: u64, window_secs: u64) -> Self { + Self { + remaining: AtomicU64::new(max_requests), + window_start: AtomicU64::new( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(), + ), + max_requests, + window_secs, + } + } + + /// Try to consume one request. Returns `true` if allowed, `false` if rate limited. + pub fn check(&self) -> bool { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + let window = self.window_start.load(Ordering::Relaxed); + if now.saturating_sub(window) >= self.window_secs { + // Window expired, reset + self.window_start.store(now, Ordering::Relaxed); + self.remaining + .store(self.max_requests - 1, Ordering::Relaxed); + return true; + } + + // Try to decrement remaining + loop { + let current = self.remaining.load(Ordering::Relaxed); + if current == 0 { + return false; + } + if self + .remaining + .compare_exchange_weak(current, current - 1, Ordering::Relaxed, Ordering::Relaxed) + .is_ok() + { + return true; + } + } + } +} + /// Shared state for all gateway handlers. pub struct GatewayState { /// Channel to send messages to the agent loop. @@ -72,6 +137,8 @@ pub struct GatewayState { pub shutdown_tx: tokio::sync::RwLock>>, /// WebSocket connection tracker. pub ws_tracker: Option>, + /// Rate limiter for chat endpoints (30 messages per 60 seconds). + pub chat_rate_limiter: RateLimiter, } /// Start the gateway HTTP server. @@ -168,7 +235,10 @@ pub async fn start_server( ) // Gateway control plane .route("/api/gateway/status", get(gateway_status_handler)) - .route_layer(middleware::from_fn_with_state(auth_state, auth_middleware)); + .route_layer(middleware::from_fn_with_state( + auth_state.clone(), + auth_middleware, + )); // Static file routes (no auth, served from embedded strings) let statics = Router::new() @@ -176,19 +246,46 @@ pub async fn start_server( .route("/style.css", get(css_handler)) .route("/app.js", get(js_handler)); - // Project file serving (no auth, local browsing of sandbox outputs). - // The trailing-slash route serves index.html; the bare route redirects so - // relative paths in the HTML (e.g. href="style.css") resolve correctly. + // Project file serving (behind auth to prevent unauthorized file access). let projects = Router::new() .route("/projects/{project_id}", get(project_redirect_handler)) .route("/projects/{project_id}/", get(project_index_handler)) - .route("/projects/{project_id}/{*path}", get(project_file_handler)); + .route("/projects/{project_id}/{*path}", get(project_file_handler)) + .route_layer(middleware::from_fn_with_state( + auth_state.clone(), + auth_middleware, + )); + + // CORS: restrict to same-origin by default. Only localhost/127.0.0.1 + // origins are allowed, since the gateway is a local-first service. + let cors = CorsLayer::new() + .allow_origin([ + format!("http://{}:{}", addr.ip(), addr.port()) + .parse() + .expect("valid origin"), + format!("http://localhost:{}", addr.port()) + .parse() + .expect("valid origin"), + ]) + .allow_methods([ + axum::http::Method::GET, + axum::http::Method::POST, + axum::http::Method::PUT, + axum::http::Method::DELETE, + ]) + .allow_headers(AllowHeaders::list([ + header::CONTENT_TYPE, + header::AUTHORIZATION, + ])) + .allow_credentials(true); let app = Router::new() .merge(public) .merge(statics) .merge(projects) .merge(protected) + .layer(cors) + .layer(DefaultBodyLimit::max(1024 * 1024)) // 1 MB max request body .with_state(state.clone()); let (shutdown_tx, shutdown_rx) = oneshot::channel(); @@ -244,6 +341,13 @@ async fn chat_send_handler( State(state): State>, Json(req): Json, ) -> Result<(StatusCode, Json), (StatusCode, String)> { + if !state.chat_rate_limiter.check() { + return Err(( + StatusCode::TOO_MANY_REQUESTS, + "Rate limit exceeded. Try again shortly.".to_string(), + )); + } + let mut msg = IncomingMessage::new("gateway", &state.user_id, &req.content); if let Some(ref thread_id) = req.thread_id { @@ -421,16 +525,50 @@ pub async fn clear_auth_mode(state: &GatewayState) { } } -async fn chat_events_handler(State(state): State>) -> impl IntoResponse { - // subscribe() returns Sse> so no lifetime issues - state.sse.subscribe() +async fn chat_events_handler( + State(state): State>, +) -> Result { + state.sse.subscribe().ok_or(( + StatusCode::SERVICE_UNAVAILABLE, + "Too many connections".to_string(), + )) } async fn chat_ws_handler( + headers: axum::http::HeaderMap, ws: WebSocketUpgrade, State(state): State>, -) -> impl IntoResponse { - ws.on_upgrade(move |socket| crate::channels::web::ws::handle_ws_connection(socket, state)) +) -> Result { + // Validate Origin header to prevent cross-site WebSocket hijacking. + // Require the header outright; browsers always send it for WS upgrades, + // so a missing Origin means a non-browser client trying to bypass the check. + let origin = headers + .get("origin") + .and_then(|v| v.to_str().ok()) + .ok_or_else(|| { + ( + StatusCode::FORBIDDEN, + "WebSocket Origin header required".to_string(), + ) + })?; + + // Extract the host from the origin and compare exactly, so that + // crafted origins like "http://localhost.evil.com" are rejected. + // Origin format is "scheme://host[:port]". + let host = origin + .strip_prefix("http://") + .or_else(|| origin.strip_prefix("https://")) + .and_then(|rest| rest.split(':').next()?.split('/').next()) + .unwrap_or(""); + + let is_local = matches!(host, "localhost" | "127.0.0.1" | "[::1]"); + if !is_local { + return Err(( + StatusCode::FORBIDDEN, + "WebSocket origin not allowed".to_string(), + )); + } + Ok(ws.on_upgrade(move |socket| crate::channels::web::ws::handle_ws_connection(socket, state))) } #[derive(Deserialize)] @@ -477,6 +615,21 @@ async fn chat_history_handler( .ok_or((StatusCode::NOT_FOUND, "No active thread".to_string()))? }; + // Verify the thread belongs to the authenticated user before returning any data. + // In-memory threads are already scoped by user via session_manager, but DB + // lookups could expose another user's conversation if the UUID is guessed. + if query.thread_id.is_some() { + if let Some(ref store) = state.store { + let owned = store + .conversation_belongs_to_user(thread_id, &state.user_id) + .await + .unwrap_or(false); + if !owned && !sess.threads.contains_key(&thread_id) { + return Err((StatusCode::NOT_FOUND, "Thread not found".to_string())); + } + } + } + // For paginated requests (before cursor set), always go to DB if before_cursor.is_some() { if let Some(ref store) = state.store { @@ -901,14 +1054,16 @@ async fn jobs_list_handler( "Database not available".to_string(), ))?; - // Fetch sandbox jobs from the DB. + // Fetch sandbox jobs scoped to the authenticated user. let sandbox_jobs = store - .list_sandbox_jobs() + .list_sandbox_jobs_for_user(&state.user_id) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + // Scope jobs to the authenticated user. let mut jobs: Vec = sandbox_jobs .iter() + .filter(|j| j.user_id == state.user_id) .map(|j| { let ui_state = match j.status.as_str() { "creating" => "pending", @@ -941,7 +1096,7 @@ async fn jobs_summary_handler( ))?; let s = store - .sandbox_job_summary() + .sandbox_job_summary_for_user(&state.user_id) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; @@ -962,9 +1117,12 @@ async fn jobs_detail_handler( let job_id = Uuid::parse_str(&id) .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?; - // Try sandbox job from DB first. + // Try sandbox job from DB first, scoped to the authenticated user. if let Some(ref store) = state.store { if let Ok(Some(job)) = store.get_sandbox_job(job_id).await { + if job.user_id != state.user_id { + return Err((StatusCode::NOT_FOUND, "Job not found".to_string())); + } let browse_id = std::path::Path::new(&job.project_dir) .file_name() .map(|n| n.to_string_lossy().to_string()) @@ -1031,13 +1189,18 @@ async fn jobs_cancel_handler( let job_id = Uuid::parse_str(&id) .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?; - // Try sandbox job cancellation. + // Try sandbox job cancellation, scoped to the authenticated user. if let Some(ref store) = state.store { if let Ok(Some(job)) = store.get_sandbox_job(job_id).await { + if job.user_id != state.user_id { + return Err((StatusCode::NOT_FOUND, "Job not found".to_string())); + } if job.status == "running" || job.status == "creating" { // Stop the container if we have a job manager. if let Some(ref jm) = state.job_manager { - let _ = jm.stop_job(job_id).await; + if let Err(e) = jm.stop_job(job_id).await { + tracing::warn!(job_id = %job_id, error = %e, "Failed to stop container during cancellation"); + } } store .update_sandbox_job_status( @@ -1083,6 +1246,11 @@ async fn jobs_restart_handler( .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .ok_or((StatusCode::NOT_FOUND, "Job not found".to_string()))?; + // Scope to the authenticated user. + if old_job.user_id != state.user_id { + return Err((StatusCode::NOT_FOUND, "Job not found".to_string())); + } + if old_job.status != "interrupted" && old_job.status != "failed" { return Err(( StatusCode::CONFLICT, @@ -1157,6 +1325,17 @@ async fn jobs_prompt_handler( .parse() .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?; + // Verify user owns this job. + if let Some(ref store) = state.store { + if !store + .sandbox_job_belongs_to_user(job_id, &state.user_id) + .await + .unwrap_or(false) + { + return Err((StatusCode::NOT_FOUND, "Job not found".to_string())); + } + } + let content = body .get("content") .and_then(|v| v.as_str()) @@ -1195,6 +1374,15 @@ async fn jobs_events_handler( .parse() .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?; + // Verify user owns this job. + if !store + .sandbox_job_belongs_to_user(job_id, &state.user_id) + .await + .unwrap_or(false) + { + return Err((StatusCode::NOT_FOUND, "Job not found".to_string())); + } + let events = store .list_job_events(job_id) .await @@ -1244,6 +1432,11 @@ async fn job_files_list_handler( .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .ok_or((StatusCode::NOT_FOUND, "Job not found".to_string()))?; + // Verify user owns this job. + if job.user_id != state.user_id { + return Err((StatusCode::NOT_FOUND, "Job not found".to_string())); + } + let base = std::path::PathBuf::from(&job.project_dir); let rel_path = query.path.as_deref().unwrap_or(""); let target = base.join(rel_path); @@ -1307,6 +1500,11 @@ async fn job_files_read_handler( .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .ok_or((StatusCode::NOT_FOUND, "Job not found".to_string()))?; + // Verify user owns this job. + if job.user_id != state.user_id { + return Err((StatusCode::NOT_FOUND, "Job not found".to_string())); + } + let path = query.path.as_deref().ok_or(( StatusCode::BAD_REQUEST, "path parameter required".to_string(), @@ -1525,6 +1723,15 @@ async fn project_file_handler( /// Shared logic: resolve the file inside `~/.ironclaw/projects/{project_id}/`, /// guard against path traversal, and stream the content with the right MIME type. async fn serve_project_file(project_id: &str, path: &str) -> axum::response::Response { + // Reject project_id values that could escape the projects directory. + if project_id.contains('/') + || project_id.contains('\\') + || project_id.contains("..") + || project_id.is_empty() + { + return (StatusCode::BAD_REQUEST, "Invalid project ID").into_response(); + } + let base = dirs::home_dir() .unwrap_or_else(|| std::path::PathBuf::from(".")) .join(".ironclaw") diff --git a/src/channels/web/sse.rs b/src/channels/web/sse.rs index 73c73730..120a7103 100644 --- a/src/channels/web/sse.rs +++ b/src/channels/web/sse.rs @@ -13,10 +13,15 @@ use tokio_stream::wrappers::BroadcastStream; use crate::channels::web::types::SseEvent; +/// Maximum number of concurrent SSE/WebSocket connections. +/// Prevents resource exhaustion from connection flooding. +const MAX_CONNECTIONS: u64 = 100; + /// Manages SSE broadcast to all connected browser tabs. pub struct SseManager { tx: broadcast::Sender, connection_count: Arc, + max_connections: u64, } impl SseManager { @@ -27,6 +32,7 @@ impl SseManager { Self { tx, connection_count: Arc::new(AtomicU64::new(0)), + max_connections: MAX_CONNECTIONS, } } @@ -45,25 +51,50 @@ impl SseManager { /// /// Returns a stream of `SseEvent` values and increments/decrements the /// connection counter on creation/drop, just like `subscribe()` does for SSE. - pub fn subscribe_raw(&self) -> impl Stream + Send + 'static + use<> { + /// + /// Returns `None` if the maximum connection limit has been reached. + pub fn subscribe_raw(&self) -> Option + Send + 'static + use<>> { + // Atomically increment only if below the limit. This prevents + // concurrent callers from overshooting max_connections. let counter = Arc::clone(&self.connection_count); - counter.fetch_add(1, Ordering::Relaxed); + let max = self.max_connections; + counter + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| { + if current < max { + Some(current + 1) + } else { + None + } + }) + .ok()?; let rx = self.tx.subscribe(); let stream = BroadcastStream::new(rx).filter_map(|result| result.ok()); - CountedStream { + Some(CountedStream { inner: stream, counter, - } + }) } /// Create a new SSE stream for a client connection. + /// + /// Returns `None` if the maximum connection limit has been reached. pub fn subscribe( &self, - ) -> Sse> + Send + 'static + use<>> { + ) -> Option> + Send + 'static + use<>>> { + // Atomically increment only if below the limit. let counter = Arc::clone(&self.connection_count); - counter.fetch_add(1, Ordering::Relaxed); + let max = self.max_connections; + counter + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| { + if current < max { + Some(current + 1) + } else { + None + } + }) + .ok()?; let rx = self.tx.subscribe(); let stream = BroadcastStream::new(rx) @@ -99,8 +130,10 @@ impl SseManager { counter, }; - Sse::new(counted_stream) - .keep_alive(KeepAlive::new().interval(Duration::from_secs(30)).text("")) + Some( + Sse::new(counted_stream) + .keep_alive(KeepAlive::new().interval(Duration::from_secs(30)).text("")), + ) } } @@ -175,7 +208,7 @@ mod tests { #[tokio::test] async fn test_subscribe_raw_receives_events() { let manager = SseManager::new(); - let mut stream = Box::pin(manager.subscribe_raw()); + let mut stream = Box::pin(manager.subscribe_raw().expect("should subscribe")); assert_eq!(manager.connection_count(), 1); @@ -195,7 +228,7 @@ mod tests { async fn test_subscribe_raw_decrements_on_drop() { let manager = SseManager::new(); { - let _stream = Box::pin(manager.subscribe_raw()); + let _stream = Box::pin(manager.subscribe_raw().expect("should subscribe")); assert_eq!(manager.connection_count(), 1); } // Stream dropped, counter should decrement @@ -205,8 +238,8 @@ mod tests { #[tokio::test] async fn test_subscribe_raw_multiple_subscribers() { let manager = SseManager::new(); - let mut s1 = Box::pin(manager.subscribe_raw()); - let mut s2 = Box::pin(manager.subscribe_raw()); + let mut s1 = Box::pin(manager.subscribe_raw().expect("should subscribe")); + let mut s2 = Box::pin(manager.subscribe_raw().expect("should subscribe")); assert_eq!(manager.connection_count(), 2); manager.broadcast(SseEvent::Heartbeat); @@ -221,4 +254,18 @@ mod tests { drop(s2); assert_eq!(manager.connection_count(), 0); } + + #[tokio::test] + async fn test_subscribe_raw_rejects_over_limit() { + let mut manager = SseManager::new(); + manager.max_connections = 2; // Low limit for testing + + let _s1 = Box::pin(manager.subscribe_raw().expect("first should succeed")); + let _s2 = Box::pin(manager.subscribe_raw().expect("second should succeed")); + assert_eq!(manager.connection_count(), 2); + + // Third should be rejected + assert!(manager.subscribe_raw().is_none()); + assert!(manager.subscribe().is_none()); + } } diff --git a/src/channels/web/static/app.js b/src/channels/web/static/app.js index f73ec2af..cdc1e06d 100644 --- a/src/channels/web/static/app.js +++ b/src/channels/web/static/app.js @@ -281,6 +281,8 @@ function sendApprovalAction(requestId, action) { function renderMarkdown(text) { if (typeof marked !== 'undefined') { let html = marked.parse(text); + // Sanitize HTML output to prevent XSS from tool output or LLM responses. + html = sanitizeRenderedHtml(html); // Inject copy buttons into
 blocks
     html = html.replace(/
/g, '
');
     return html;
@@ -288,6 +290,28 @@ function renderMarkdown(text) {
   return escapeHtml(text);
 }
 
+// Strip dangerous HTML elements and attributes from rendered markdown.
+// This prevents XSS from tool output or prompt injection in LLM responses.
+function sanitizeRenderedHtml(html) {
+  html = html.replace(/)<[^<]*)*<\/script>/gi, '');
+  html = html.replace(/]*>[\s\S]*?<\/iframe>/gi, '');
+  html = html.replace(/]*>[\s\S]*?<\/object>/gi, '');
+  html = html.replace(/]*\/?>/gi, '');
+  html = html.replace(/]*>[\s\S]*?<\/form>/gi, '');
+  html = html.replace(/]*>[\s\S]*?<\/style>/gi, '');
+  html = html.replace(/]*\/?>/gi, '');
+  html = html.replace(/]*\/?>/gi, '');
+  html = html.replace(/]*\/?>/gi, '');
+  // Remove event handler attributes (onclick, onerror, onload, etc.)
+  html = html.replace(/\s+on\w+\s*=\s*"[^"]*"/gi, '');
+  html = html.replace(/\s+on\w+\s*=\s*'[^']*'/gi, '');
+  html = html.replace(/\s+on\w+\s*=\s*[^\s>]+/gi, '');
+  // Remove javascript: and data: URLs in href/src attributes
+  html = html.replace(/(href|src|action)\s*=\s*["']?\s*javascript\s*:/gi, '$1="');
+  html = html.replace(/(href|src|action)\s*=\s*["']?\s*data\s*:/gi, '$1="');
+  return html;
+}
+
 function copyCodeBlock(btn) {
   const pre = btn.parentElement;
   const code = pre.querySelector('code');
diff --git a/src/channels/web/ws.rs b/src/channels/web/ws.rs
index 8778f39d..9dde5a15 100644
--- a/src/channels/web/ws.rs
+++ b/src/channels/web/ws.rs
@@ -71,8 +71,17 @@ pub async fn handle_ws_connection(socket: WebSocket, state: Arc) {
     }
     let tracker_for_drop = state.ws_tracker.clone();
 
-    // Subscribe to broadcast events (same source as SSE)
-    let mut event_stream = Box::pin(state.sse.subscribe_raw());
+    // Subscribe to broadcast events (same source as SSE).
+    // Reject if we've hit the connection limit.
+    let Some(raw_stream) = state.sse.subscribe_raw() else {
+        tracing::warn!("WebSocket rejected: too many connections");
+        // Decrement the WS tracker we already incremented above.
+        if let Some(ref tracker) = tracker_for_drop {
+            tracker.decrement();
+        }
+        return;
+    };
+    let mut event_stream = Box::pin(raw_stream);
 
     // Channel for the sender task to receive messages from both
     // the broadcast stream and any direct sends (like Pong)
@@ -476,6 +485,7 @@ mod tests {
             user_id: "test".to_string(),
             shutdown_tx: tokio::sync::RwLock::new(None),
             ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
+            chat_rate_limiter: crate::channels::web::server::RateLimiter::new(30, 60),
         }
     }
 }
diff --git a/src/config.rs b/src/config.rs
index 20ef580e..6ecd7cc8 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -1174,6 +1174,36 @@ pub struct ClaudeCodeConfig {
     pub max_turns: u32,
     /// Memory limit in MB for Claude Code containers (heavier than workers).
     pub memory_limit_mb: u64,
+    /// Allowed tool patterns for Claude Code permission settings.
+    ///
+    /// Written to `/workspace/.claude/settings.json` before spawning the CLI.
+    /// Provides defense-in-depth: only explicitly listed tools are auto-approved.
+    /// Any new/unknown tools would require interactive approval (which times out
+    /// in the non-interactive container, failing safely).
+    ///
+    /// Patterns follow Claude Code syntax: `"Bash(*)"`, `"Read"`, `"Edit(*)"`, etc.
+    pub allowed_tools: Vec,
+}
+
+/// Default allowed tools for Claude Code inside containers.
+///
+/// These cover all standard Claude Code tools needed for autonomous operation.
+/// The Docker container provides the primary security boundary; this allowlist
+/// provides defense-in-depth by preventing any future unknown tools from being
+/// silently auto-approved.
+fn default_claude_code_allowed_tools() -> Vec {
+    [
+        "Bash(*)",
+        "Read",
+        "Edit(*)",
+        "Glob",
+        "Grep",
+        "WebFetch(*)",
+        "Task(*)",
+    ]
+    .into_iter()
+    .map(String::from)
+    .collect()
 }
 
 impl Default for ClaudeCodeConfig {
@@ -1186,11 +1216,24 @@ impl Default for ClaudeCodeConfig {
             model: "sonnet".to_string(),
             max_turns: 50,
             memory_limit_mb: 4096,
+            allowed_tools: default_claude_code_allowed_tools(),
         }
     }
 }
 
 impl ClaudeCodeConfig {
+    /// Load from environment variables only (used inside containers where
+    /// there is no database or full config).
+    pub fn from_env() -> Self {
+        match Self::resolve() {
+            Ok(c) => c,
+            Err(e) => {
+                tracing::warn!("Failed to resolve ClaudeCodeConfig: {e}, using defaults");
+                Self::default()
+            }
+        }
+    }
+
     fn resolve() -> Result {
         let defaults = Self::default();
         Ok(Self {
@@ -1211,6 +1254,14 @@ impl ClaudeCodeConfig {
                 "CLAUDE_CODE_MEMORY_LIMIT_MB",
                 defaults.memory_limit_mb,
             )?,
+            allowed_tools: optional_env("CLAUDE_CODE_ALLOWED_TOOLS")?
+                .map(|s| {
+                    s.split(',')
+                        .map(|t| t.trim().to_string())
+                        .filter(|t| !t.is_empty())
+                        .collect()
+                })
+                .unwrap_or(defaults.allowed_tools),
         })
     }
 }
diff --git a/src/context/manager.rs b/src/context/manager.rs
index 7ef2d644..50dc88e8 100644
--- a/src/context/manager.rs
+++ b/src/context/manager.rs
@@ -45,20 +45,21 @@ impl ContextManager {
         title: impl Into,
         description: impl Into,
     ) -> Result {
-        let contexts = self.contexts.read().await;
+        // Hold write lock for the entire check-insert to prevent TOCTOU races
+        // where two concurrent calls both pass the active_count check.
+        let mut contexts = self.contexts.write().await;
         let active_count = contexts.values().filter(|c| c.state.is_active()).count();
 
         if active_count >= self.max_jobs {
             return Err(JobError::MaxJobsExceeded { max: self.max_jobs });
         }
-        drop(contexts);
 
         let context = JobContext::with_user(user_id, title, description);
         let job_id = context.job_id;
+        contexts.insert(job_id, context);
+        drop(contexts);
 
         let memory = Memory::new(job_id);
-
-        self.contexts.write().await.insert(job_id, context);
         self.memories.write().await.insert(job_id, memory);
 
         Ok(job_id)
diff --git a/src/context/state.rs b/src/context/state.rs
index 9c94c188..5d008d17 100644
--- a/src/context/state.rs
+++ b/src/context/state.rs
@@ -119,6 +119,10 @@ pub struct JobContext {
     pub estimated_duration: Option,
     /// Actual cost so far.
     pub actual_cost: Decimal,
+    /// Total tokens consumed by LLM calls in this job.
+    pub total_tokens_used: u64,
+    /// Maximum tokens allowed per job (0 = unlimited).
+    pub max_tokens: u64,
     /// When the job was created.
     pub created_at: DateTime,
     /// When the job was started.
@@ -159,6 +163,8 @@ impl JobContext {
             estimated_cost: None,
             estimated_duration: None,
             actual_cost: Decimal::ZERO,
+            total_tokens_used: 0,
+            max_tokens: 0,
             created_at: Utc::now(),
             started_at: None,
             completed_at: None,
@@ -189,6 +195,14 @@ impl JobContext {
         };
 
         self.transitions.push(transition);
+
+        // Cap transition history to prevent unbounded memory growth
+        const MAX_TRANSITIONS: usize = 200;
+        if self.transitions.len() > MAX_TRANSITIONS {
+            let drain_count = self.transitions.len() - MAX_TRANSITIONS;
+            self.transitions.drain(..drain_count);
+        }
+
         self.state = new_state;
 
         // Update timestamps
@@ -210,6 +224,29 @@ impl JobContext {
         self.actual_cost += cost;
     }
 
+    /// Record token usage from an LLM call. Returns an error string if the
+    /// token budget has been exceeded after this addition.
+    pub fn add_tokens(&mut self, tokens: u64) -> Result<(), String> {
+        self.total_tokens_used += tokens;
+        if self.max_tokens > 0 && self.total_tokens_used > self.max_tokens {
+            Err(format!(
+                "Token budget exceeded: used {} of {} allowed tokens",
+                self.total_tokens_used, self.max_tokens
+            ))
+        } else {
+            Ok(())
+        }
+    }
+
+    /// Check whether the monetary budget has been exceeded.
+    pub fn budget_exceeded(&self) -> bool {
+        if let Some(ref budget) = self.budget {
+            self.actual_cost > *budget
+        } else {
+            false
+        }
+    }
+
     /// Get the duration since the job started.
     pub fn elapsed(&self) -> Option {
         self.started_at.map(|start| {
@@ -274,6 +311,57 @@ mod tests {
         assert_eq!(ctx.state, JobState::Completed);
     }
 
+    #[test]
+    fn test_transition_history_capped() {
+        let mut ctx = JobContext::new("Test", "Transition cap test");
+        // Cycle through Pending -> InProgress -> Stuck -> InProgress -> Stuck ...
+        ctx.transition_to(JobState::InProgress, None).unwrap();
+        for i in 0..250 {
+            ctx.mark_stuck(format!("stuck {}", i)).unwrap();
+            ctx.attempt_recovery().unwrap();
+        }
+        // 1 initial + 250*2 = 501 transitions, should be capped at 200
+        assert!(
+            ctx.transitions.len() <= 200,
+            "transitions should be capped at 200, got {}",
+            ctx.transitions.len()
+        );
+    }
+
+    #[test]
+    fn test_add_tokens_enforces_budget() {
+        let mut ctx = JobContext::new("Test", "Budget test");
+        ctx.max_tokens = 1000;
+        assert!(ctx.add_tokens(500).is_ok());
+        assert_eq!(ctx.total_tokens_used, 500);
+        assert!(ctx.add_tokens(600).is_err());
+        assert_eq!(ctx.total_tokens_used, 1100); // tokens still recorded
+    }
+
+    #[test]
+    fn test_add_tokens_unlimited() {
+        let mut ctx = JobContext::new("Test", "No budget");
+        // max_tokens = 0 means unlimited
+        assert!(ctx.add_tokens(1_000_000).is_ok());
+    }
+
+    #[test]
+    fn test_budget_exceeded() {
+        let mut ctx = JobContext::new("Test", "Money test");
+        ctx.budget = Some(Decimal::new(100, 0)); // $100
+        assert!(!ctx.budget_exceeded());
+        ctx.add_cost(Decimal::new(50, 0));
+        assert!(!ctx.budget_exceeded());
+        ctx.add_cost(Decimal::new(60, 0));
+        assert!(ctx.budget_exceeded());
+    }
+
+    #[test]
+    fn test_budget_exceeded_none() {
+        let ctx = JobContext::new("Test", "No budget");
+        assert!(!ctx.budget_exceeded()); // No budget = never exceeded
+    }
+
     #[test]
     fn test_stuck_recovery() {
         let mut ctx = JobContext::new("Test", "Test job");
diff --git a/src/extensions/manager.rs b/src/extensions/manager.rs
index e39d1980..d87b9605 100644
--- a/src/extensions/manager.rs
+++ b/src/extensions/manager.rs
@@ -461,7 +461,16 @@ impl ExtensionManager {
         name: &str,
         url: &str,
     ) -> Result {
-        // Download the WASM binary
+        // Require HTTPS to prevent downgrade attacks
+        if !url.starts_with("https://") {
+            return Err(ExtensionError::InstallFailed(
+                "Only HTTPS URLs are allowed for extension downloads".to_string(),
+            ));
+        }
+
+        // 50 MB cap to prevent disk-fill DoS
+        const MAX_WASM_SIZE: usize = 50 * 1024 * 1024;
+
         let client = reqwest::Client::builder()
             .timeout(std::time::Duration::from_secs(60))
             .build()
@@ -480,11 +489,36 @@ impl ExtensionManager {
             )));
         }
 
+        // Check Content-Length header before downloading the full body
+        if let Some(len) = response.content_length() {
+            if len as usize > MAX_WASM_SIZE {
+                return Err(ExtensionError::InstallFailed(format!(
+                    "WASM binary too large ({} bytes, max {} bytes)",
+                    len, MAX_WASM_SIZE
+                )));
+            }
+        }
+
         let bytes = response
             .bytes()
             .await
             .map_err(|e| ExtensionError::DownloadFailed(e.to_string()))?;
 
+        if bytes.len() > MAX_WASM_SIZE {
+            return Err(ExtensionError::InstallFailed(format!(
+                "WASM binary too large ({} bytes, max {} bytes)",
+                bytes.len(),
+                MAX_WASM_SIZE
+            )));
+        }
+
+        // Basic WASM magic number check (\0asm)
+        if bytes.len() < 4 || &bytes[..4] != b"\0asm" {
+            return Err(ExtensionError::InstallFailed(
+                "Downloaded file is not a valid WASM binary (bad magic number)".to_string(),
+            ));
+        }
+
         // Ensure tools directory exists
         tokio::fs::create_dir_all(&self.wasm_tools_dir)
             .await
@@ -497,9 +531,10 @@ impl ExtensionManager {
             .map_err(|e| ExtensionError::InstallFailed(e.to_string()))?;
 
         tracing::info!(
-            "Installed WASM tool '{}' ({} bytes) to {}",
+            "Installed WASM tool '{}' ({} bytes) from {} to {}",
             name,
             bytes.len(),
+            url,
             wasm_path.display()
         );
 
diff --git a/src/history/store.rs b/src/history/store.rs
index c5f8d8bd..0f20bc62 100644
--- a/src/history/store.rs
+++ b/src/history/store.rs
@@ -220,6 +220,8 @@ impl Store {
                     completed_at: row.get("completed_at"),
                     transitions: Vec::new(), // Not loaded from DB for now
                     metadata: serde_json::Value::Null,
+                    total_tokens_used: 0,
+                    max_tokens: 0,
                 }))
             }
             None => Ok(None),
@@ -565,6 +567,90 @@ impl Store {
             .collect())
     }
 
+    /// List sandbox jobs for a specific user, most recent first.
+    pub async fn list_sandbox_jobs_for_user(
+        &self,
+        user_id: &str,
+    ) -> Result, DatabaseError> {
+        let conn = self.conn().await?;
+        let rows = conn
+            .query(
+                r#"
+                SELECT id, title, status, user_id, project_dir,
+                       success, failure_reason, created_at, started_at, completed_at
+                FROM agent_jobs WHERE source = 'sandbox' AND user_id = $1
+                ORDER BY created_at DESC
+                "#,
+                &[&user_id],
+            )
+            .await?;
+
+        Ok(rows
+            .iter()
+            .map(|r| SandboxJobRecord {
+                id: r.get("id"),
+                task: r.get("title"),
+                status: r.get("status"),
+                user_id: r.get("user_id"),
+                project_dir: r
+                    .get::<_, Option>("project_dir")
+                    .unwrap_or_default(),
+                success: r.get("success"),
+                failure_reason: r.get("failure_reason"),
+                created_at: r.get("created_at"),
+                started_at: r.get("started_at"),
+                completed_at: r.get("completed_at"),
+            })
+            .collect())
+    }
+
+    /// Get a summary of sandbox job counts by status for a specific user.
+    pub async fn sandbox_job_summary_for_user(
+        &self,
+        user_id: &str,
+    ) -> Result {
+        let conn = self.conn().await?;
+        let rows = conn
+            .query(
+                "SELECT status, COUNT(*) as cnt FROM agent_jobs WHERE source = 'sandbox' AND user_id = $1 GROUP BY status",
+                &[&user_id],
+            )
+            .await?;
+
+        let mut summary = SandboxJobSummary::default();
+        for row in &rows {
+            let status: String = row.get("status");
+            let count: i64 = row.get("cnt");
+            let c = count as usize;
+            summary.total += c;
+            match status.as_str() {
+                "creating" => summary.creating += c,
+                "running" => summary.running += c,
+                "completed" => summary.completed += c,
+                "failed" => summary.failed += c,
+                "interrupted" => summary.interrupted += c,
+                _ => {}
+            }
+        }
+        Ok(summary)
+    }
+
+    /// Check if a sandbox job belongs to a specific user.
+    pub async fn sandbox_job_belongs_to_user(
+        &self,
+        job_id: Uuid,
+        user_id: &str,
+    ) -> Result {
+        let conn = self.conn().await?;
+        let row = conn
+            .query_opt(
+                "SELECT 1 FROM agent_jobs WHERE id = $1 AND user_id = $2 AND source = 'sandbox'",
+                &[&job_id, &user_id],
+            )
+            .await?;
+        Ok(row.is_some())
+    }
+
     /// Update sandbox job status and optional timestamps/result.
     pub async fn update_sandbox_job_status(
         &self,
@@ -1258,6 +1344,22 @@ impl Store {
         Ok(id)
     }
 
+    /// Check whether a conversation belongs to the given user.
+    pub async fn conversation_belongs_to_user(
+        &self,
+        conversation_id: Uuid,
+        user_id: &str,
+    ) -> Result {
+        let conn = self.conn().await?;
+        let row = conn
+            .query_opt(
+                "SELECT 1 FROM conversations WHERE id = $1 AND user_id = $2",
+                &[&conversation_id, &user_id],
+            )
+            .await?;
+        Ok(row.is_some())
+    }
+
     /// Load messages for a conversation with cursor-based pagination.
     ///
     /// Returns `(messages_oldest_first, has_more)`.
diff --git a/src/lib.rs b/src/lib.rs
index 461bff3d..54313777 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -58,6 +58,7 @@ pub mod secrets;
 pub mod settings;
 pub mod setup;
 pub mod tools;
+pub mod util;
 pub mod worker;
 pub mod workspace;
 
diff --git a/src/llm/mod.rs b/src/llm/mod.rs
index 1a3b7a47..ee801df7 100644
--- a/src/llm/mod.rs
+++ b/src/llm/mod.rs
@@ -21,7 +21,10 @@ pub use provider::{
     ChatMessage, CompletionRequest, CompletionResponse, FinishReason, LlmProvider, ModelMetadata,
     Role, ToolCall, ToolCompletionRequest, ToolCompletionResponse, ToolDefinition, ToolResult,
 };
-pub use reasoning::{ActionPlan, Reasoning, ReasoningContext, RespondResult, ToolSelection};
+pub use reasoning::{
+    ActionPlan, Reasoning, ReasoningContext, RespondOutput, RespondResult, TokenUsage,
+    ToolSelection,
+};
 pub use rig_adapter::RigAdapter;
 pub use session::{SessionConfig, SessionManager, create_session_manager};
 
diff --git a/src/llm/reasoning.rs b/src/llm/reasoning.rs
index 95125298..7cc511d8 100644
--- a/src/llm/reasoning.rs
+++ b/src/llm/reasoning.rs
@@ -115,6 +115,19 @@ pub struct ToolSelection {
     pub alternatives: Vec,
 }
 
+/// Token usage from a single LLM call.
+#[derive(Debug, Clone, Copy, Default)]
+pub struct TokenUsage {
+    pub input_tokens: u32,
+    pub output_tokens: u32,
+}
+
+impl TokenUsage {
+    pub fn total(&self) -> u32 {
+        self.input_tokens + self.output_tokens
+    }
+}
+
 /// Result of a response with potential tool calls.
 ///
 /// Used by the agent loop to handle tool execution before returning a final response.
@@ -131,6 +144,13 @@ pub enum RespondResult {
     },
 }
 
+/// A `RespondResult` bundled with the token usage from the LLM call that produced it.
+#[derive(Debug, Clone)]
+pub struct RespondOutput {
+    pub result: RespondResult,
+    pub usage: TokenUsage,
+}
+
 /// Reasoning engine for the agent.
 pub struct Reasoning {
     llm: Arc,
@@ -284,7 +304,8 @@ Respond in JSON format:
     /// tool calls as text for simple cases. Use `respond_with_tools()` when you
     /// need to actually execute tool calls in an agentic loop.
     pub async fn respond(&self, context: &ReasoningContext) -> Result {
-        match self.respond_with_tools(context).await? {
+        let output = self.respond_with_tools(context).await?;
+        match output.result {
             RespondResult::Text(text) => Ok(text),
             RespondResult::ToolCalls {
                 tool_calls: calls, ..
@@ -299,15 +320,14 @@ Respond in JSON format:
         }
     }
 
-    /// Generate a response that may include tool calls.
+    /// Generate a response that may include tool calls, with token usage tracking.
     ///
-    /// Returns `RespondResult::ToolCalls` if the model wants to call tools,
-    /// allowing the caller to execute them and continue the conversation.
-    /// Returns `RespondResult::Text` when the model has a final text response.
+    /// Returns `RespondOutput` containing the result and token usage from the LLM call.
+    /// The caller should use `usage` to track cost/budget against the job.
     pub async fn respond_with_tools(
         &self,
         context: &ReasoningContext,
-    ) -> Result {
+    ) -> Result {
         let system_prompt = self.build_conversation_prompt(context);
 
         let mut messages = vec![ChatMessage::system(system_prompt)];
@@ -322,12 +342,19 @@ Respond in JSON format:
             request.metadata = context.metadata.clone();
 
             let response = self.llm.complete_with_tools(request).await?;
+            let usage = TokenUsage {
+                input_tokens: response.input_tokens,
+                output_tokens: response.output_tokens,
+            };
 
             // If there were tool calls, return them for execution
             if !response.tool_calls.is_empty() {
-                return Ok(RespondResult::ToolCalls {
-                    tool_calls: response.tool_calls,
-                    content: response.content,
+                return Ok(RespondOutput {
+                    result: RespondResult::ToolCalls {
+                        tool_calls: response.tool_calls,
+                        content: response.content,
+                    },
+                    usage,
                 });
             }
 
@@ -341,17 +368,23 @@ Respond in JSON format:
             let recovered = recover_tool_calls_from_content(&content, &context.available_tools);
             if !recovered.is_empty() {
                 let cleaned = clean_response(&content);
-                return Ok(RespondResult::ToolCalls {
-                    tool_calls: recovered,
-                    content: if cleaned.is_empty() {
-                        None
-                    } else {
-                        Some(cleaned)
+                return Ok(RespondOutput {
+                    result: RespondResult::ToolCalls {
+                        tool_calls: recovered,
+                        content: if cleaned.is_empty() {
+                            None
+                        } else {
+                            Some(cleaned)
+                        },
                     },
+                    usage,
                 });
             }
 
-            Ok(RespondResult::Text(clean_response(&content)))
+            Ok(RespondOutput {
+                result: RespondResult::Text(clean_response(&content)),
+                usage,
+            })
         } else {
             // No tools, use simple completion
             let mut request = CompletionRequest::new(messages)
@@ -360,7 +393,13 @@ Respond in JSON format:
             request.metadata = context.metadata.clone();
 
             let response = self.llm.complete(request).await?;
-            Ok(RespondResult::Text(clean_response(&response.content)))
+            Ok(RespondOutput {
+                result: RespondResult::Text(clean_response(&response.content)),
+                usage: TokenUsage {
+                    input_tokens: response.input_tokens,
+                    output_tokens: response.output_tokens,
+                },
+            })
         }
     }
 
diff --git a/src/llm/session.rs b/src/llm/session.rs
index 7dac5b72..9b27b650 100644
--- a/src/llm/session.rs
+++ b/src/llm/session.rs
@@ -520,6 +520,25 @@ impl SessionManager {
                 ))
             })?;
 
+        // Restrictive permissions: session file contains a secret token
+        #[cfg(unix)]
+        {
+            use std::os::unix::fs::PermissionsExt;
+            let perms = std::fs::Permissions::from_mode(0o600);
+            tokio::fs::set_permissions(&self.config.session_path, perms)
+                .await
+                .map_err(|e| {
+                    LlmError::Io(std::io::Error::new(
+                        e.kind(),
+                        format!(
+                            "Failed to set permissions on {}: {}",
+                            self.config.session_path.display(),
+                            e
+                        ),
+                    ))
+                })?;
+        }
+
         tracing::debug!("Session saved to {}", self.config.session_path.display());
 
         // Also save to DB if a store is attached
diff --git a/src/main.rs b/src/main.rs
index 9a381f4c..4d8e08c1 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -210,12 +210,15 @@ async fn main() -> anyhow::Result<()> {
                 model
             );
 
+            // Load allowed tools from config (env var or defaults).
+            let claude_config = ironclaw::config::ClaudeCodeConfig::from_env();
             let config = ironclaw::worker::claude_bridge::ClaudeBridgeConfig {
                 job_id: *job_id,
                 orchestrator_url: orchestrator_url.clone(),
                 max_turns: *max_turns,
                 model: model.clone(),
                 timeout: std::time::Duration::from_secs(1800),
+                allowed_tools: claude_config.allowed_tools,
             };
 
             let runtime = ironclaw::worker::ClaudeBridgeRuntime::new(config)
@@ -681,6 +684,7 @@ async fn main() -> anyhow::Result<()> {
             claude_code_model: config.claude_code.model.clone(),
             claude_code_max_turns: config.claude_code.max_turns,
             claude_code_memory_limit_mb: config.claude_code.memory_limit_mb,
+            claude_code_allowed_tools: config.claude_code.allowed_tools.clone(),
         };
         let jm = Arc::new(ContainerJobManager::new(job_config, token_store.clone()));
 
diff --git a/src/orchestrator/auth.rs b/src/orchestrator/auth.rs
index c3045b9a..894b0ffb 100644
--- a/src/orchestrator/auth.rs
+++ b/src/orchestrator/auth.rs
@@ -14,6 +14,7 @@ use axum::http::StatusCode;
 use axum::middleware::Next;
 use axum::response::Response;
 use rand::Rng;
+use subtle::ConstantTimeEq;
 use tokio::sync::RwLock;
 use uuid::Uuid;
 
@@ -38,13 +39,13 @@ impl TokenStore {
         token
     }
 
-    /// Validate a token for a specific job.
+    /// Validate a token for a specific job (constant-time comparison).
     pub async fn validate(&self, job_id: Uuid, token: &str) -> bool {
         self.tokens
             .read()
             .await
             .get(&job_id)
-            .map(|stored| stored == token)
+            .map(|stored| stored.as_bytes().ct_eq(token.as_bytes()).into())
             .unwrap_or(false)
     }
 
diff --git a/src/orchestrator/job_manager.rs b/src/orchestrator/job_manager.rs
index 6d4f9204..227af6a3 100644
--- a/src/orchestrator/job_manager.rs
+++ b/src/orchestrator/job_manager.rs
@@ -58,6 +58,8 @@ pub struct ContainerJobConfig {
     pub claude_code_max_turns: u32,
     /// Memory limit in MB for Claude Code containers (heavier than workers).
     pub claude_code_memory_limit_mb: u64,
+    /// Allowed tool patterns for Claude Code (passed as CLAUDE_CODE_ALLOWED_TOOLS env var).
+    pub claude_code_allowed_tools: Vec,
 }
 
 impl Default for ContainerJobConfig {
@@ -71,6 +73,7 @@ impl Default for ContainerJobConfig {
             claude_code_model: "sonnet".to_string(),
             claude_code_max_turns: 50,
             claude_code_memory_limit_mb: 4096,
+            claude_code_allowed_tools: crate::config::ClaudeCodeConfig::default().allowed_tools,
         }
     }
 }
@@ -161,6 +164,29 @@ impl ContainerJobManager {
         };
         self.containers.write().await.insert(job_id, handle);
 
+        // Run the actual container creation. On any failure, revoke the token
+        // and remove the handle so we don't leak resources.
+        match self
+            .create_job_inner(job_id, &token, project_dir, mode)
+            .await
+        {
+            Ok(()) => Ok(token),
+            Err(e) => {
+                self.token_store.revoke(job_id).await;
+                self.containers.write().await.remove(&job_id);
+                Err(e)
+            }
+        }
+    }
+
+    /// Inner implementation of container creation (separated for cleanup).
+    async fn create_job_inner(
+        &self,
+        job_id: Uuid,
+        token: &str,
+        project_dir: Option,
+        mode: JobMode,
+    ) -> Result<(), OrchestratorError> {
         // Connect to Docker
         let docker = connect_docker()
             .await
@@ -219,11 +245,18 @@ impl ContainerJobManager {
             env_vec.push("IRONCLAW_WORKSPACE=/workspace".to_string());
         }
 
-        // Claude Code mode: mount host ~/.claude read-only for auth
+        // Claude Code mode: mount host ~/.claude read-only for auth,
+        // and pass the tool allowlist so the bridge can write settings.json.
         if mode == JobMode::ClaudeCode {
             if let Some(ref claude_dir) = self.config.claude_config_dir {
                 binds.push(format!("{}:/home/sandbox/.claude:ro", claude_dir.display()));
             }
+            if !self.config.claude_code_allowed_tools.is_empty() {
+                env_vec.push(format!(
+                    "CLAUDE_CODE_ALLOWED_TOOLS={}",
+                    self.config.claude_code_allowed_tools.join(",")
+                ));
+            }
         }
 
         // Memory limit: Claude Code gets more memory
@@ -243,11 +276,7 @@ impl ContainerJobManager {
             network_mode: Some("bridge".to_string()),
             extra_hosts: Some(vec!["host.docker.internal:host-gateway".to_string()]),
             cap_drop: Some(vec!["ALL".to_string()]),
-            cap_add: Some(vec![
-                "CHOWN".to_string(),
-                "SETUID".to_string(),
-                "SETGID".to_string(),
-            ]),
+            cap_add: Some(vec!["CHOWN".to_string()]),
             security_opt: Some(vec!["no-new-privileges:true".to_string()]),
             tmpfs: Some(
                 [("/tmp".to_string(), "size=512M".to_string())]
@@ -328,7 +357,7 @@ impl ContainerJobManager {
             "Created and started worker container"
         );
 
-        Ok(token)
+        Ok(())
     }
 
     /// Stop a running container job.
@@ -355,15 +384,18 @@ impl ContainerJobManager {
             })?;
 
         // Stop the container (10 second grace period)
-        let _ = docker
+        if let Err(e) = docker
             .stop_container(
                 &container_id,
                 Some(bollard::container::StopContainerOptions { t: 10 }),
             )
-            .await;
+            .await
+        {
+            tracing::warn!(job_id = %job_id, error = %e, "Failed to stop container (may already be stopped)");
+        }
 
         // Remove the container
-        let _ = docker
+        if let Err(e) = docker
             .remove_container(
                 &container_id,
                 Some(bollard::container::RemoveContainerOptions {
@@ -371,7 +403,10 @@ impl ContainerJobManager {
                     ..Default::default()
                 }),
             )
-            .await;
+            .await
+        {
+            tracing::warn!(job_id = %job_id, error = %e, "Failed to remove container (may require manual cleanup)");
+        }
 
         // Update state
         if let Some(handle) = self.containers.write().await.get_mut(&job_id) {
@@ -409,22 +444,33 @@ impl ContainerJobManager {
         };
         if let Some(cid) = container_id {
             if !cid.is_empty() {
-                if let Ok(docker) = connect_docker().await {
-                    let _ = docker
-                        .stop_container(
-                            &cid,
-                            Some(bollard::container::StopContainerOptions { t: 5 }),
-                        )
-                        .await;
-                    let _ = docker
-                        .remove_container(
-                            &cid,
-                            Some(bollard::container::RemoveContainerOptions {
-                                force: true,
-                                ..Default::default()
-                            }),
-                        )
-                        .await;
+                match connect_docker().await {
+                    Ok(docker) => {
+                        if let Err(e) = docker
+                            .stop_container(
+                                &cid,
+                                Some(bollard::container::StopContainerOptions { t: 5 }),
+                            )
+                            .await
+                        {
+                            tracing::warn!(job_id = %job_id, error = %e, "Failed to stop completed container");
+                        }
+                        if let Err(e) = docker
+                            .remove_container(
+                                &cid,
+                                Some(bollard::container::RemoveContainerOptions {
+                                    force: true,
+                                    ..Default::default()
+                                }),
+                            )
+                            .await
+                        {
+                            tracing::warn!(job_id = %job_id, error = %e, "Failed to remove completed container");
+                        }
+                    }
+                    Err(e) => {
+                        tracing::warn!(job_id = %job_id, error = %e, "Failed to connect to Docker for container cleanup");
+                    }
                 }
             }
         }
diff --git a/src/pairing/store.rs b/src/pairing/store.rs
index 464c5a39..dba62720 100644
--- a/src/pairing/store.rs
+++ b/src/pairing/store.rs
@@ -320,19 +320,27 @@ impl PairingStore {
     fn record_failed_approve(&self, channel: &str) -> Result<(), PairingStoreError> {
         let path = approve_attempts_path(&self.base_dir, channel)?;
         fs::create_dir_all(path.parent().unwrap())?;
+
+        // Open (or create) and lock before reading so concurrent callers
+        // don't clobber each other's writes.
         let file = fs::OpenOptions::new()
             .read(true)
             .write(true)
             .create(true)
-            .truncate(true)
+            .truncate(false)
             .open(&path)?;
         file.lock_exclusive()?;
-        let content = fs::read_to_string(&path).unwrap_or_default();
-        let mut data: ApproveAttemptsFile = serde_json::from_str(&content).unwrap_or_default();
+
+        let mut data: ApproveAttemptsFile = fs::read_to_string(&path)
+            .ok()
+            .and_then(|c| serde_json::from_str(&c).ok())
+            .unwrap_or_default();
+
         let now = now_secs();
         data.failed_at.push(now);
         let cutoff = now.saturating_sub(PAIRING_APPROVE_RATE_WINDOW_SECS);
         data.failed_at.retain(|&t| t >= cutoff);
+
         let json = serde_json::to_string_pretty(&data)?;
         fs::write(&path, json)?;
         fs4::FileExt::unlock(&file)?;
diff --git a/src/safety/leak_detector.rs b/src/safety/leak_detector.rs
index 65d301b0..85e83ad1 100644
--- a/src/safety/leak_detector.rs
+++ b/src/safety/leak_detector.rs
@@ -306,12 +306,11 @@ impl LeakDetector {
                 })?;
         }
 
-        // Scan body if present and valid UTF-8
+        // Scan body if present. Use lossy UTF-8 conversion so a leading
+        // non-UTF8 byte can't be used to skip scanning entirely.
         if let Some(body_bytes) = body {
-            if let Ok(body_str) = std::str::from_utf8(body_bytes) {
-                self.scan_and_clean(body_str)?;
-            }
-            // Binary bodies are not scanned (could add hex pattern detection later)
+            let body_str = String::from_utf8_lossy(body_bytes);
+            self.scan_and_clean(&body_str)?;
         }
 
         Ok(())
@@ -705,4 +704,17 @@ mod tests {
         let result = detector.scan_http_request("https://api.example.com/webhook", &[], Some(body));
         assert!(result.is_err());
     }
+
+    #[test]
+    fn test_scan_http_request_blocks_secret_in_binary_body() {
+        let detector = LeakDetector::new();
+
+        // Attacker prepends a non-UTF8 byte to bypass strict from_utf8 check.
+        // The lossy conversion should still detect the secret.
+        let mut body = vec![0xFF]; // invalid UTF-8 leading byte
+        body.extend_from_slice(b"sk-proj-test1234567890abcdefghij");
+
+        let result = detector.scan_http_request("https://api.example.com/exfil", &[], Some(&body));
+        assert!(result.is_err(), "binary body should still be scanned");
+    }
 }
diff --git a/src/safety/mod.rs b/src/safety/mod.rs
index ef93961a..3e76f5b8 100644
--- a/src/safety/mod.rs
+++ b/src/safety/mod.rs
@@ -98,15 +98,15 @@ impl SafetyLayer {
                 was_modified: true,
             };
         }
-        if violations
+        let force_sanitize = violations
             .iter()
-            .any(|rule| rule.action == crate::safety::PolicyAction::Sanitize)
-        {
+            .any(|rule| rule.action == crate::safety::PolicyAction::Sanitize);
+        if force_sanitize {
             was_modified = true;
         }
 
-        // Run sanitization if enabled
-        if self.config.injection_check_enabled {
+        // Run sanitization once: if injection_check is enabled OR policy requires it
+        if self.config.injection_check_enabled || force_sanitize {
             let mut sanitized = self.sanitizer.sanitize(&content);
             sanitized.was_modified = sanitized.was_modified || was_modified;
             sanitized
@@ -190,4 +190,20 @@ mod tests {
         assert!(wrapped.contains("sanitized=\"true\""));
         assert!(wrapped.contains("Hello <world>"));
     }
+
+    #[test]
+    fn test_sanitize_action_forces_sanitization_when_injection_check_disabled() {
+        let config = SafetyConfig {
+            max_output_length: 100_000,
+            injection_check_enabled: false,
+        };
+        let safety = SafetyLayer::new(&config);
+
+        // Content with an injection-like pattern that a policy might flag
+        let output = safety.sanitize_tool_output("test", "normal text");
+        // With injection_check disabled and no policy violations, content
+        // should pass through unmodified
+        assert_eq!(output.content, "normal text");
+        assert!(!output.was_modified);
+    }
 }
diff --git a/src/sandbox/container.rs b/src/sandbox/container.rs
index 87bec652..da9e9145 100644
--- a/src/sandbox/container.rs
+++ b/src/sandbox/container.rs
@@ -279,11 +279,7 @@ impl ContainerRunner {
             network_mode: Some("bridge".to_string()),
             // Security: drop all capabilities and add back only what's needed
             cap_drop: Some(vec!["ALL".to_string()]),
-            cap_add: Some(vec![
-                "CHOWN".to_string(),
-                "SETUID".to_string(),
-                "SETGID".to_string(),
-            ]),
+            cap_add: Some(vec!["CHOWN".to_string()]),
             // Prevent privilege escalation
             security_opt: Some(vec!["no-new-privileges:true".to_string()]),
             // Read-only root filesystem (workspace is still writable if policy allows)
diff --git a/src/setup/wizard.rs b/src/setup/wizard.rs
index 6197e0f2..8613992b 100644
--- a/src/setup/wizard.rs
+++ b/src/setup/wizard.rs
@@ -1087,6 +1087,15 @@ mod tests {
 
     #[tokio::test]
     async fn test_install_missing_bundled_channels_installs_telegram() {
+        use crate::channels::wasm::available_channel_names;
+
+        // WASM artifacts only exist in dev builds (not CI). Skip gracefully
+        // rather than fail when the telegram channel hasn't been compiled.
+        if !available_channel_names().contains(&"telegram") {
+            eprintln!("skipping: telegram WASM artifacts not built");
+            return;
+        }
+
         let dir = tempdir().unwrap();
         let installed = HashSet::::new();
 
diff --git a/src/tools/builder/core.rs b/src/tools/builder/core.rs
index 5f71b8dc..0f06a9f7 100644
--- a/src/tools/builder/core.rs
+++ b/src/tools/builder/core.rs
@@ -595,7 +595,7 @@ Create alongside the .wasm file to grant capabilities:
                     AgentToolError::BuilderFailed(format!("LLM response failed: {}", e))
                 })?;
 
-            match result {
+            match result.result {
                 RespondResult::Text(response) => {
                     reason_ctx.messages.push(ChatMessage::assistant(&response));
 
diff --git a/src/tools/builtin/file.rs b/src/tools/builtin/file.rs
index 3d08cc92..5a1c0aea 100644
--- a/src/tools/builtin/file.rs
+++ b/src/tools/builtin/file.rs
@@ -50,65 +50,90 @@ const MAX_WRITE_SIZE: usize = 5 * 1024 * 1024;
 /// Maximum directory listing entries.
 const MAX_DIR_ENTRIES: usize = 500;
 
-/// Validate that a path is safe (no traversal attacks).
-fn validate_path(path_str: &str, base_dir: Option<&Path>) -> Result {
-    let path = PathBuf::from(path_str);
-
-    // Reject paths with suspicious components (validation only, no action needed)
+/// Normalize a path by resolving `.` and `..` components lexically (no filesystem access).
+///
+/// This is critical for security: `std::fs::canonicalize` only works on paths that exist,
+/// so for new files we must normalize without touching the filesystem.
+fn normalize_lexical(path: &Path) -> PathBuf {
+    let mut components = Vec::new();
     for component in path.components() {
         match component {
             std::path::Component::ParentDir => {
-                // Allow .. but validate final path is within sandbox
-            }
-            std::path::Component::Normal(s) => {
-                let s = s.to_string_lossy();
-                if s.starts_with('.') && s != "." && s != ".." && !s.starts_with(".git") {
-                    // Hidden files are OK for .git, .gitignore, etc.
+                // Only pop if there's a normal component to pop (don't escape root/prefix)
+                if components
+                    .last()
+                    .is_some_and(|c| matches!(c, std::path::Component::Normal(_)))
+                {
+                    components.pop();
                 }
             }
-            _ => {}
+            std::path::Component::CurDir => {}
+            other => components.push(other),
         }
     }
+    components.iter().collect()
+}
+
+/// Validate that a path is safe (no traversal attacks).
+///
+/// For sandboxed paths (base_dir is set), we normalize the joined path lexically
+/// and then verify it lives under the canonical base. This prevents escapes through
+/// non-existent parent directories where `canonicalize()` would fall back to the
+/// raw (un-normalized) path.
+fn validate_path(path_str: &str, base_dir: Option<&Path>) -> Result {
+    let path = PathBuf::from(path_str);
 
     // Resolve to absolute path
     let resolved = if path.is_absolute() {
-        path.canonicalize().unwrap_or_else(|_| path.clone())
+        path.canonicalize()
+            .unwrap_or_else(|_| normalize_lexical(&path))
     } else if let Some(base) = base_dir {
-        base.join(&path)
+        let joined = base.join(&path);
+        joined
             .canonicalize()
-            .unwrap_or_else(|_| base.join(&path))
+            .unwrap_or_else(|_| normalize_lexical(&joined))
     } else {
-        std::env::current_dir()
+        let joined = std::env::current_dir()
             .unwrap_or_else(|_| PathBuf::from("."))
-            .join(&path)
+            .join(&path);
+        normalize_lexical(&joined)
     };
 
-    // If base_dir is set, ensure path is within it
+    // If base_dir is set, ensure the resolved path is within it
     if let Some(base) = base_dir {
-        // Canonicalize the base to handle symlinks (e.g., /var -> /private/var on macOS)
-        let base_canonical = base.canonicalize().unwrap_or_else(|_| base.to_path_buf());
+        let base_canonical = base
+            .canonicalize()
+            .unwrap_or_else(|_| normalize_lexical(base));
 
-        // For files that don't exist yet, we need to check the parent directory
-        // and ensure the resolved path would be within the base
+        // For existing paths, canonicalize to resolve symlinks.
+        // For non-existent paths, the lexical normalization above already removed
+        // all `..` components, so starts_with is reliable.
         let check_path = if resolved.exists() {
             resolved.canonicalize().unwrap_or_else(|_| resolved.clone())
         } else {
-            // For non-existent files, canonicalize the parent and append the filename
-            if let Some(parent) = resolved.parent() {
-                if parent.exists() {
-                    let canonical_parent = parent
+            // Walk up to the nearest existing ancestor directory, canonicalize it,
+            // then re-append the remaining tail. This handles the case where a
+            // symlink sits above the new file.
+            let mut ancestor = resolved.as_path();
+            let mut tail_parts: Vec<&std::ffi::OsStr> = Vec::new();
+            loop {
+                if ancestor.exists() {
+                    let canonical_ancestor = ancestor
                         .canonicalize()
-                        .unwrap_or_else(|_| parent.to_path_buf());
-                    if let Some(filename) = resolved.file_name() {
-                        canonical_parent.join(filename)
-                    } else {
-                        resolved.clone()
+                        .unwrap_or_else(|_| ancestor.to_path_buf());
+                    let mut result = canonical_ancestor;
+                    for part in tail_parts.into_iter().rev() {
+                        result = result.join(part);
                     }
-                } else {
-                    resolved.clone()
+                    break result;
+                }
+                if let Some(name) = ancestor.file_name() {
+                    tail_parts.push(name);
+                }
+                match ancestor.parent() {
+                    Some(parent) if parent != ancestor => ancestor = parent,
+                    _ => break resolved.clone(),
                 }
-            } else {
-                resolved.clone()
             }
         };
 
@@ -871,4 +896,81 @@ mod tests {
         let entries = result.result.get("entries").unwrap().as_array().unwrap();
         assert!(entries.len() >= 2);
     }
+
+    #[test]
+    fn test_normalize_lexical() {
+        // Basic .. resolution
+        assert_eq!(
+            normalize_lexical(Path::new("/a/b/../c")),
+            PathBuf::from("/a/c")
+        );
+        // Multiple .. components
+        assert_eq!(
+            normalize_lexical(Path::new("/a/b/c/../../d")),
+            PathBuf::from("/a/d")
+        );
+        // . components stripped
+        assert_eq!(
+            normalize_lexical(Path::new("/a/./b/./c")),
+            PathBuf::from("/a/b/c")
+        );
+        // Cannot escape root
+        assert_eq!(
+            normalize_lexical(Path::new("/a/../../..")),
+            PathBuf::from("/")
+        );
+    }
+
+    #[test]
+    fn test_validate_path_rejects_traversal_nonexistent_parent() {
+        // The critical test: writing to ../../outside/newdir/file with base_dir
+        // set should be rejected even when the parent directory does not exist
+        // (i.e. canonicalize() cannot resolve it).
+        let dir = TempDir::new().unwrap();
+        let evil_path = format!(
+            "{}/../../outside/newdir/file.txt",
+            dir.path().to_str().unwrap()
+        );
+        let result = validate_path(&evil_path, Some(dir.path()));
+        assert!(
+            result.is_err(),
+            "Should reject traversal via non-existent parent, got: {:?}",
+            result
+        );
+    }
+
+    #[test]
+    fn test_validate_path_rejects_relative_traversal() {
+        let dir = TempDir::new().unwrap();
+        let result = validate_path("../../etc/passwd", Some(dir.path()));
+        assert!(
+            result.is_err(),
+            "Should reject relative traversal, got: {:?}",
+            result
+        );
+    }
+
+    #[test]
+    fn test_validate_path_allows_valid_nested_write() {
+        let dir = TempDir::new().unwrap();
+        let result = validate_path("subdir/newfile.txt", Some(dir.path()));
+        assert!(
+            result.is_ok(),
+            "Should allow nested writes within sandbox: {:?}",
+            result
+        );
+    }
+
+    #[test]
+    fn test_validate_path_allows_dot_dot_within_sandbox() {
+        // a/b/../c resolves to a/c which is still inside the sandbox
+        let dir = TempDir::new().unwrap();
+        std::fs::create_dir_all(dir.path().join("a/b")).unwrap();
+        let result = validate_path("a/b/../c.txt", Some(dir.path()));
+        assert!(
+            result.is_ok(),
+            "Should allow .. that stays within sandbox: {:?}",
+            result
+        );
+    }
 }
diff --git a/src/tools/builtin/http.rs b/src/tools/builtin/http.rs
index f5ac8c20..c0ac14d0 100644
--- a/src/tools/builtin/http.rs
+++ b/src/tools/builtin/http.rs
@@ -1,7 +1,7 @@
 //! HTTP request tool.
 
 use std::collections::HashMap;
-use std::net::IpAddr;
+use std::net::{IpAddr, ToSocketAddrs};
 use std::time::Duration;
 
 use async_trait::async_trait;
@@ -11,6 +11,9 @@ use crate::context::JobContext;
 use crate::safety::LeakDetector;
 use crate::tools::tool::{Tool, ToolError, ToolOutput};
 
+/// Maximum response body size (5 MB). Prevents OOM from unbounded responses.
+const MAX_RESPONSE_SIZE: usize = 5 * 1024 * 1024;
+
 /// Tool for making HTTP requests.
 pub struct HttpTool {
     client: Client,
@@ -21,6 +24,7 @@ impl HttpTool {
     pub fn new() -> Self {
         let client = Client::builder()
             .timeout(Duration::from_secs(30))
+            .redirect(reqwest::redirect::Policy::none())
             .build()
             .expect("Failed to create HTTP client");
 
@@ -49,6 +53,7 @@ fn validate_url(url: &str) -> Result {
         ));
     }
 
+    // Check literal IP addresses
     if let Ok(ip) = host.parse::() {
         if is_disallowed_ip(&ip) {
             return Err(ToolError::NotAuthorized(
@@ -57,6 +62,22 @@ fn validate_url(url: &str) -> Result {
         }
     }
 
+    // Resolve hostname and check all resolved IPs against the blocklist.
+    // This prevents DNS rebinding where a hostname resolves to a private IP.
+    let port = parsed.port_or_known_default().unwrap_or(443);
+    let socket_addr = format!("{}:{}", host, port);
+    if let Ok(addrs) = socket_addr.to_socket_addrs() {
+        for addr in addrs {
+            if is_disallowed_ip(&addr.ip()) {
+                return Err(ToolError::NotAuthorized(format!(
+                    "hostname '{}' resolves to disallowed IP {}",
+                    host,
+                    addr.ip()
+                )));
+            }
+        }
+    }
+
     Ok(parsed)
 }
 
@@ -202,17 +223,36 @@ impl Tool for HttpTool {
         })?;
 
         let status = response.status().as_u16();
+
+        // Block redirects: the server tried to send us elsewhere (potential SSRF)
+        if (300..400).contains(&status) {
+            return Err(ToolError::NotAuthorized(format!(
+                "request returned redirect (HTTP {}), which is blocked to prevent SSRF",
+                status
+            )));
+        }
+
         let headers: HashMap = response
             .headers()
             .iter()
             .filter_map(|(k, v)| v.to_str().ok().map(|v| (k.to_string(), v.to_string())))
             .collect();
 
-        // Get response body
-        let body_text = response.text().await.map_err(|e| {
+        // Get response body with size cap to prevent OOM
+        let body_bytes = response.bytes().await.map_err(|e| {
             ToolError::ExternalService(format!("failed to read response body: {}", e))
         })?;
 
+        if body_bytes.len() > MAX_RESPONSE_SIZE {
+            return Err(ToolError::ExecutionFailed(format!(
+                "Response body too large ({} bytes, max {})",
+                body_bytes.len(),
+                MAX_RESPONSE_SIZE
+            )));
+        }
+
+        let body_text = String::from_utf8_lossy(&body_bytes).into_owned();
+
         // Try to parse as JSON, fall back to string
         let body: serde_json::Value = serde_json::from_str(&body_text)
             .unwrap_or_else(|_| serde_json::Value::String(body_text.clone()));
@@ -241,7 +281,7 @@ impl Tool for HttpTool {
 
 #[cfg(test)]
 mod tests {
-    use super::validate_url;
+    use super::*;
 
     #[test]
     fn test_validate_url_rejects_http() {
@@ -260,4 +300,40 @@ mod tests {
         let url = validate_url("https://example.com").unwrap();
         assert_eq!(url.host_str(), Some("example.com"));
     }
+
+    #[test]
+    fn test_validate_url_rejects_private_ip_literal() {
+        let err = validate_url("https://192.168.1.1/api").unwrap_err();
+        assert!(err.to_string().contains("private"));
+    }
+
+    #[test]
+    fn test_validate_url_rejects_loopback_ip() {
+        let err = validate_url("https://127.0.0.1/api").unwrap_err();
+        assert!(err.to_string().contains("private"));
+    }
+
+    #[test]
+    fn test_validate_url_rejects_link_local() {
+        let err = validate_url("https://169.254.169.254/latest/meta-data/").unwrap_err();
+        assert!(err.to_string().contains("private"));
+    }
+
+    #[test]
+    fn test_is_disallowed_ip_covers_ranges() {
+        use std::net::Ipv4Addr;
+
+        // Private ranges
+        assert!(is_disallowed_ip(&IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))));
+        assert!(is_disallowed_ip(&IpAddr::V4(Ipv4Addr::new(172, 16, 0, 1))));
+        assert!(is_disallowed_ip(&IpAddr::V4(Ipv4Addr::new(192, 168, 0, 1))));
+        // Loopback
+        assert!(is_disallowed_ip(&IpAddr::V4(Ipv4Addr::LOCALHOST)));
+        // Cloud metadata
+        assert!(is_disallowed_ip(&IpAddr::V4(Ipv4Addr::new(
+            169, 254, 169, 254
+        ))));
+        // Public
+        assert!(!is_disallowed_ip(&IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8))));
+    }
 }
diff --git a/src/tools/builtin/memory.rs b/src/tools/builtin/memory.rs
index d77b3c59..a64e5863 100644
--- a/src/tools/builtin/memory.rs
+++ b/src/tools/builtin/memory.rs
@@ -20,6 +20,12 @@ use crate::context::JobContext;
 use crate::tools::tool::{Tool, ToolError, ToolOutput};
 use crate::workspace::{Workspace, paths};
 
+/// Identity files that the LLM must not overwrite via tool calls.
+/// These are loaded into the system prompt and could be used for prompt
+/// injection if an attacker tricks the agent into overwriting them.
+const PROTECTED_IDENTITY_FILES: &[&str] =
+    &[paths::IDENTITY, paths::SOUL, paths::AGENTS, paths::USER];
+
 /// Tool for searching workspace memory.
 ///
 /// Performs hybrid search (FTS + semantic) across all memory documents.
@@ -188,6 +194,16 @@ impl Tool for MemoryWriteTool {
             .and_then(|v| v.as_str())
             .unwrap_or("daily_log");
 
+        // Reject writes to identity files that are loaded into the system prompt.
+        // An attacker could use prompt injection to trick the agent into overwriting
+        // these, poisoning future conversations.
+        if PROTECTED_IDENTITY_FILES.contains(&target) {
+            return Err(ToolError::NotAuthorized(format!(
+                "writing to '{}' is not allowed (identity file protected from tool writes)",
+                target,
+            )));
+        }
+
         let append = params
             .get("append")
             .and_then(|v| v.as_bool())
@@ -230,6 +246,20 @@ impl Tool for MemoryWriteTool {
                 paths::HEARTBEAT.to_string()
             }
             path => {
+                // Protect identity files from LLM overwrites (prompt injection defense).
+                // These files are injected into the system prompt, so poisoning them
+                // would let an attacker rewrite the agent's core instructions.
+                let normalized = path.trim_start_matches('/');
+                if PROTECTED_IDENTITY_FILES
+                    .iter()
+                    .any(|p| normalized.eq_ignore_ascii_case(p))
+                {
+                    return Err(ToolError::NotAuthorized(format!(
+                        "writing to '{}' is not allowed (identity file protected from tool access)",
+                        path
+                    )));
+                }
+
                 if append {
                     self.workspace
                         .append(path, content)
diff --git a/src/tools/builtin/mod.rs b/src/tools/builtin/mod.rs
index c834aa9d..8ee99b16 100644
--- a/src/tools/builtin/mod.rs
+++ b/src/tools/builtin/mod.rs
@@ -11,7 +11,7 @@ mod marketplace;
 mod memory;
 mod restaurant;
 pub mod routine;
-mod shell;
+pub(crate) mod shell;
 mod taskrabbit;
 mod time;
 
diff --git a/src/tools/builtin/shell.rs b/src/tools/builtin/shell.rs
index a428f125..9cd125d6 100644
--- a/src/tools/builtin/shell.rs
+++ b/src/tools/builtin/shell.rs
@@ -74,6 +74,58 @@ static DANGEROUS_PATTERNS: LazyLock> = LazyLock::new(|| {
     ]
 });
 
+/// Patterns that should NEVER be auto-approved, even if the user chose "always approve"
+/// for the shell tool. These require explicit per-invocation approval because they are
+/// destructive or security-sensitive.
+static NEVER_AUTO_APPROVE_PATTERNS: LazyLock> = LazyLock::new(|| {
+    vec![
+        "rm -rf",
+        "rm -fr",
+        "chmod -r 777",
+        "chmod 777",
+        "chown -r",
+        "shutdown",
+        "reboot",
+        "poweroff",
+        "init 0",
+        "init 6",
+        "iptables",
+        "nft ",
+        "useradd",
+        "userdel",
+        "passwd",
+        "visudo",
+        "crontab",
+        "systemctl disable",
+        "launchctl unload",
+        "kill -9",
+        "killall",
+        "pkill",
+        "docker rm",
+        "docker rmi",
+        "docker system prune",
+        "git push --force",
+        "git push -f",
+        "git reset --hard",
+        "git clean -f",
+        "DROP TABLE",
+        "DROP DATABASE",
+        "TRUNCATE",
+        "DELETE FROM",
+    ]
+});
+
+/// Check whether a shell command contains patterns that must never be auto-approved.
+///
+/// Even when the user has chosen "always approve" for the shell tool, these commands
+/// require explicit per-invocation approval because they are destructive.
+pub fn requires_explicit_approval(command: &str) -> bool {
+    let lower = command.to_lowercase();
+    NEVER_AUTO_APPROVE_PATTERNS
+        .iter()
+        .any(|p| lower.contains(&p.to_lowercase()))
+}
+
 /// Shell command execution tool.
 pub struct ShellTool {
     /// Working directory for commands (if None, uses job's working dir or cwd).
@@ -289,23 +341,17 @@ impl ShellTool {
         // Determine timeout
         let timeout_duration = timeout.map(Duration::from_secs).unwrap_or(self.timeout);
 
-        // Try sandbox execution if available
+        // Use sandbox if configured; fail-closed (never silently fall through
+        // to unsandboxed execution when sandbox was intended).
         if let Some(ref sandbox) = self.sandbox {
             if sandbox.is_initialized() || sandbox.config().enabled {
-                match self
+                return self
                     .execute_sandboxed(sandbox, cmd, &cwd, timeout_duration)
-                    .await
-                {
-                    Ok((output, code)) => return Ok((output, code)),
-                    Err(e) => {
-                        // Log sandbox failure and fall through to direct execution
-                        tracing::warn!("Sandbox execution failed, falling back to direct: {}", e);
-                    }
-                }
+                    .await;
             }
         }
 
-        // Fallback to direct execution
+        // Only execute directly when no sandbox was configured at all.
         let (output, code) = self.execute_direct(cmd, &cwd, timeout_duration).await?;
         Ok((output, code as i64))
     }
@@ -392,17 +438,19 @@ impl Tool for ShellTool {
     }
 }
 
-/// Truncate output to fit within limits.
+/// Truncate output to fit within limits (UTF-8 safe).
 fn truncate_output(s: &str) -> String {
     if s.len() <= MAX_OUTPUT_SIZE {
         s.to_string()
     } else {
         let half = MAX_OUTPUT_SIZE / 2;
+        let head_end = crate::util::floor_char_boundary(s, half);
+        let tail_start = crate::util::floor_char_boundary(s, s.len() - half);
         format!(
             "{}\n\n... [truncated {} bytes] ...\n\n{}",
-            &s[..half],
+            &s[..head_end],
             s.len() - MAX_OUTPUT_SIZE,
-            &s[s.len() - half..]
+            &s[tail_start..]
         )
     }
 }
@@ -458,6 +506,27 @@ mod tests {
         assert!(matches!(result, Err(ToolError::Timeout(_))));
     }
 
+    #[test]
+    fn test_requires_explicit_approval() {
+        // Destructive commands should require explicit approval
+        assert!(requires_explicit_approval("rm -rf /tmp/stuff"));
+        assert!(requires_explicit_approval("git push --force origin main"));
+        assert!(requires_explicit_approval("git reset --hard HEAD~5"));
+        assert!(requires_explicit_approval("docker rm container_name"));
+        assert!(requires_explicit_approval("kill -9 12345"));
+        assert!(requires_explicit_approval("DROP TABLE users;"));
+
+        // Safe commands should not
+        assert!(!requires_explicit_approval("cargo build"));
+        assert!(!requires_explicit_approval("git status"));
+        assert!(!requires_explicit_approval("ls -la"));
+        assert!(!requires_explicit_approval("echo hello"));
+        assert!(!requires_explicit_approval("cat file.txt"));
+        assert!(!requires_explicit_approval(
+            "git push origin feature-branch"
+        ));
+    }
+
     #[test]
     fn test_sandbox_policy_builder() {
         let tool = ShellTool::new()
diff --git a/src/tools/registry.rs b/src/tools/registry.rs
index 4e833573..ffe84aa9 100644
--- a/src/tools/registry.rs
+++ b/src/tools/registry.rs
@@ -25,9 +25,46 @@ use crate::tools::wasm::{
 };
 use crate::workspace::Workspace;
 
+/// Names of built-in tools that cannot be shadowed by dynamic registrations.
+/// This prevents a dynamically built or installed tool from replacing a
+/// security-critical built-in like "shell" or "memory_write".
+const PROTECTED_TOOL_NAMES: &[&str] = &[
+    "echo",
+    "time",
+    "json",
+    "http",
+    "shell",
+    "read_file",
+    "write_file",
+    "list_dir",
+    "apply_patch",
+    "memory_search",
+    "memory_write",
+    "memory_read",
+    "memory_tree",
+    "create_job",
+    "list_jobs",
+    "job_status",
+    "cancel_job",
+    "build_software",
+    "tool_search",
+    "tool_install",
+    "tool_auth",
+    "tool_activate",
+    "tool_list",
+    "tool_remove",
+    "routine_create",
+    "routine_list",
+    "routine_update",
+    "routine_delete",
+    "routine_history",
+];
+
 /// Registry of available tools.
 pub struct ToolRegistry {
     tools: RwLock>>,
+    /// Tracks which names were registered as built-in (protected from shadowing).
+    builtin_names: RwLock>,
 }
 
 impl ToolRegistry {
@@ -35,21 +72,35 @@ impl ToolRegistry {
     pub fn new() -> Self {
         Self {
             tools: RwLock::new(HashMap::new()),
+            builtin_names: RwLock::new(std::collections::HashSet::new()),
         }
     }
 
-    /// Register a tool.
+    /// Register a tool. Rejects dynamic tools that try to shadow a built-in name.
     pub async fn register(&self, tool: Arc) {
         let name = tool.name().to_string();
+        if self.builtin_names.read().await.contains(&name) {
+            tracing::warn!(
+                tool = %name,
+                "Rejected tool registration: would shadow a built-in tool"
+            );
+            return;
+        }
         self.tools.write().await.insert(name.clone(), tool);
         tracing::debug!("Registered tool: {}", name);
     }
 
-    /// Register a tool (sync version for startup).
+    /// Register a tool (sync version for startup, marks as built-in).
     pub fn register_sync(&self, tool: Arc) {
         let name = tool.name().to_string();
         if let Ok(mut tools) = self.tools.try_write() {
             tools.insert(name.clone(), tool);
+            // Mark as built-in so it can't be shadowed later
+            if PROTECTED_TOOL_NAMES.contains(&name.as_str()) {
+                if let Ok(mut builtins) = self.builtin_names.try_write() {
+                    builtins.insert(name.clone());
+                }
+            }
             tracing::debug!("Registered tool: {}", name);
         }
     }
@@ -419,6 +470,14 @@ impl Default for ToolRegistry {
     }
 }
 
+impl std::fmt::Debug for ToolRegistry {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.debug_struct("ToolRegistry")
+            .field("count", &self.count())
+            .finish()
+    }
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -452,4 +511,54 @@ mod tests {
         assert_eq!(defs.len(), 1);
         assert_eq!(defs[0].name, "echo");
     }
+
+    #[tokio::test]
+    async fn test_builtin_tool_cannot_be_shadowed() {
+        let registry = ToolRegistry::new();
+        // Register echo as built-in (uses register_sync which marks protected names)
+        registry.register_sync(Arc::new(EchoTool));
+        assert!(registry.has("echo").await);
+
+        let original_desc = registry
+            .get("echo")
+            .await
+            .unwrap()
+            .description()
+            .to_string();
+
+        // Create a fake tool that tries to shadow "echo"
+        struct FakeEcho;
+        #[async_trait::async_trait]
+        impl Tool for FakeEcho {
+            fn name(&self) -> &str {
+                "echo"
+            }
+            fn description(&self) -> &str {
+                "EVIL SHADOW"
+            }
+            fn parameters_schema(&self) -> serde_json::Value {
+                serde_json::json!({})
+            }
+            async fn execute(
+                &self,
+                _params: serde_json::Value,
+                _ctx: &crate::context::JobContext,
+            ) -> Result {
+                unreachable!()
+            }
+        }
+
+        // Try to shadow via register() (dynamic path)
+        registry.register(Arc::new(FakeEcho)).await;
+
+        // The original should still be there
+        let desc = registry
+            .get("echo")
+            .await
+            .unwrap()
+            .description()
+            .to_string();
+        assert_eq!(desc, original_desc);
+        assert_ne!(desc, "EVIL SHADOW");
+    }
 }
diff --git a/src/tools/wasm/allowlist.rs b/src/tools/wasm/allowlist.rs
index a81f3e2c..d27a5037 100644
--- a/src/tools/wasm/allowlist.rs
+++ b/src/tools/wasm/allowlist.rs
@@ -182,6 +182,17 @@ fn parse_url(url: &str) -> Result {
         return Err(format!("Unsupported scheme: {}", scheme));
     }
 
+    // Reject URLs with userinfo (user:pass@host) to prevent allowlist bypass.
+    // A URL like https://api.openai.com@evil.com/ would match the allowlist
+    // for api.openai.com but actually send traffic to evil.com.
+    let authority = match rest.find('/') {
+        Some(idx) => &rest[..idx],
+        None => rest,
+    };
+    if authority.contains('@') {
+        return Err("URL contains userinfo (@) which is not allowed".to_string());
+    }
+
     // Split host from path
     let (host_and_port, path) = match rest.find('/') {
         Some(idx) => (&rest[..idx], &rest[idx..]),
@@ -207,6 +218,14 @@ fn parse_url(url: &str) -> Result {
         None => host_and_port,
     };
 
+    // Reject URLs with userinfo (user:pass@host).
+    // A URL like https://api.openai.com@evil.com/ confuses the parser into
+    // seeing "api.openai.com" as the host, but reqwest actually sends to
+    // "evil.com". Block any '@' in the authority section to prevent this.
+    if host.contains('@') || host_and_port.contains('@') {
+        return Err("URL contains userinfo (@) which is not allowed".to_string());
+    }
+
     // Validate host
     if host.is_empty() {
         return Err("Empty host".to_string());
@@ -332,6 +351,21 @@ mod tests {
         }
     }
 
+    #[test]
+    fn test_userinfo_rejected() {
+        let validator = validator_with_patterns();
+
+        // Userinfo in URL should be rejected to prevent allowlist bypass
+        let result = validator.validate("https://api.openai.com@evil.com/v1/chat", "GET");
+        assert!(!result.is_allowed());
+
+        if let super::AllowlistResult::Denied(reason) = result {
+            assert!(matches!(reason, DenyReason::InvalidUrl(_)));
+        } else {
+            panic!("Expected denied for userinfo URL");
+        }
+    }
+
     #[test]
     fn test_invalid_url() {
         let validator = validator_with_patterns();
@@ -354,4 +388,28 @@ mod tests {
         let result = validator.validate("http://localhost:8080/api", "GET");
         assert!(result.is_allowed());
     }
+
+    #[test]
+    fn test_reject_url_with_userinfo() {
+        let validator = validator_with_patterns();
+
+        // Attacker uses userinfo to trick the parser: the allowlist sees
+        // "api.openai.com" but reqwest would actually connect to "evil.com".
+        let result = validator.validate("https://api.openai.com@evil.com/v1/steal", "GET");
+        assert!(!result.is_allowed());
+
+        if let super::AllowlistResult::Denied(reason) = result {
+            assert!(matches!(reason, DenyReason::InvalidUrl(_)));
+        } else {
+            panic!("Expected denied due to userinfo");
+        }
+    }
+
+    #[test]
+    fn test_reject_url_with_user_pass() {
+        let validator = validator_with_patterns();
+
+        let result = validator.validate("https://user:password@api.openai.com/v1/chat", "GET");
+        assert!(!result.is_allowed());
+    }
 }
diff --git a/src/tools/wasm/runtime.rs b/src/tools/wasm/runtime.rs
index 06903e69..9f6e8133 100644
--- a/src/tools/wasm/runtime.rs
+++ b/src/tools/wasm/runtime.rs
@@ -14,6 +14,10 @@ use wasmtime::{Config, Engine, OptLevel};
 use crate::tools::wasm::error::WasmError;
 use crate::tools::wasm::limits::{FuelConfig, ResourceLimits};
 
+/// Default epoch tick interval. Each tick increments the engine's epoch counter,
+/// which causes any store with an expired epoch deadline to trap.
+pub const EPOCH_TICK_INTERVAL: Duration = Duration::from_millis(500);
+
 /// Configuration for the WASM runtime.
 #[derive(Debug, Clone)]
 pub struct WasmRuntimeConfig {
@@ -123,6 +127,25 @@ impl WasmToolRuntime {
             WasmError::EngineCreationFailed(format!("Failed to create Wasmtime engine: {}", e))
         })?;
 
+        // Spawn a background thread that periodically increments the engine's
+        // epoch counter. Without this, epoch_deadline_trap() never fires and
+        // WASM modules can spin indefinitely even with a deadline set.
+        let ticker_engine = engine.clone();
+        std::thread::Builder::new()
+            .name("wasm-epoch-ticker".into())
+            .spawn(move || {
+                loop {
+                    std::thread::sleep(EPOCH_TICK_INTERVAL);
+                    ticker_engine.increment_epoch();
+                }
+            })
+            .map_err(|e| {
+                WasmError::EngineCreationFailed(format!(
+                    "Failed to spawn epoch ticker thread: {}",
+                    e
+                ))
+            })?;
+
         Ok(Self {
             engine,
             config,
diff --git a/src/tools/wasm/wrapper.rs b/src/tools/wasm/wrapper.rs
index bd2be44e..65050be0 100644
--- a/src/tools/wasm/wrapper.rs
+++ b/src/tools/wasm/wrapper.rs
@@ -23,7 +23,7 @@ use crate::tools::wasm::capabilities::Capabilities;
 use crate::tools::wasm::error::WasmError;
 use crate::tools::wasm::host::{HostState, LogLevel};
 use crate::tools::wasm::limits::{ResourceLimits, WasmResourceLimiter};
-use crate::tools::wasm::runtime::{PreparedModule, WasmToolRuntime};
+use crate::tools::wasm::runtime::{EPOCH_TICK_INTERVAL, PreparedModule, WasmToolRuntime};
 
 // Generate component model bindings from the WIT file.
 //
@@ -194,10 +194,25 @@ impl near::agent::host::Host for StoreData {
             .scan_http_request(&url, &header_vec, body.as_deref())
             .map_err(|e| format!("Potential secret leak blocked: {}", e))?;
 
+        // Get the max response size from capabilities (default 10MB).
+        let max_response_bytes = self
+            .host_state
+            .capabilities()
+            .http
+            .as_ref()
+            .map(|h| h.max_response_bytes)
+            .unwrap_or(10 * 1024 * 1024);
+
+        // Resolve hostname and reject private/internal IPs to prevent DNS rebinding.
+        reject_private_ip(&url)?;
+
         // Make HTTP request using blocking I/O.
         // We're inside a spawn_blocking context, so use block_on.
         let result = tokio::runtime::Handle::current().block_on(async {
-            let client = reqwest::Client::new();
+            let client = reqwest::Client::builder()
+                .redirect(reqwest::redirect::Policy::none())
+                .build()
+                .map_err(|e| format!("failed to create HTTP client: {e}"))?;
 
             let mut request = match method.to_uppercase().as_str() {
                 "GET" => client.get(&url),
@@ -241,11 +256,31 @@ impl near::agent::host::Host for StoreData {
                 })
                 .collect();
             let headers_json = serde_json::to_string(&response_headers).unwrap_or_default();
+
+            // Check Content-Length header for early rejection of oversized responses.
+            let max_response = max_response_bytes;
+            if let Some(cl) = response.content_length() {
+                if cl as usize > max_response {
+                    return Err(format!(
+                        "Response body too large: {} bytes exceeds limit of {} bytes",
+                        cl, max_response
+                    ));
+                }
+            }
+
+            // Read body with a size cap to prevent memory exhaustion.
             let body = response
                 .bytes()
                 .await
-                .map_err(|e| format!("Failed to read response body: {}", e))?
-                .to_vec();
+                .map_err(|e| format!("Failed to read response body: {}", e))?;
+            if body.len() > max_response {
+                return Err(format!(
+                    "Response body too large: {} bytes exceeds limit of {} bytes",
+                    body.len(),
+                    max_response
+                ));
+            }
+            let body = body.to_vec();
 
             // Leak detection on response body
             if let Ok(body_str) = std::str::from_utf8(&body) {
@@ -380,9 +415,13 @@ impl WasmToolWrapper {
                 .map_err(|e| WasmError::ConfigError(format!("Failed to set fuel: {}", e)))?;
         }
 
-        // Configure epoch deadline for timeout backup
+        // Configure epoch deadline as a hard timeout backup.
+        // The epoch ticker thread increments the engine epoch every EPOCH_TICK_INTERVAL.
+        // Setting deadline to N means "trap after N ticks", so we compute the number
+        // of ticks that fit in the tool's timeout. Minimum 1 to always have a backstop.
         store.epoch_deadline_trap();
-        store.set_epoch_deadline(1);
+        let ticks = (limits.timeout.as_millis() / EPOCH_TICK_INTERVAL.as_millis()).max(1) as u64;
+        store.set_epoch_deadline(ticks);
 
         // Set up resource limiter
         store.limiter(|data| &mut data.limiter);
@@ -531,6 +570,88 @@ impl std::fmt::Debug for WasmToolWrapper {
     }
 }
 
+/// Resolve the URL's hostname and reject connections to private/internal IP addresses.
+/// This prevents DNS rebinding attacks where an attacker's domain resolves to an
+/// internal IP after passing the allowlist check.
+fn reject_private_ip(url: &str) -> Result<(), String> {
+    let host = url
+        .split("://")
+        .nth(1)
+        .and_then(|rest| {
+            let host_and_port = rest.split('/').next().unwrap_or(rest);
+            // Strip port
+            if host_and_port.starts_with('[') {
+                // IPv6
+                host_and_port.find(']').map(|i| &host_and_port[1..i])
+            } else {
+                Some(
+                    host_and_port
+                        .rfind(':')
+                        .map_or(host_and_port, |i| &host_and_port[..i]),
+                )
+            }
+        })
+        .ok_or_else(|| "Failed to parse host from URL".to_string())?;
+
+    // If the host is already an IP, check it directly
+    if let Ok(ip) = host.parse::() {
+        return if is_private_ip(ip) {
+            Err(format!(
+                "HTTP request to private/internal IP {} is not allowed",
+                ip
+            ))
+        } else {
+            Ok(())
+        };
+    }
+
+    // Resolve DNS and check all addresses
+    use std::net::ToSocketAddrs;
+    // Port 0 is a placeholder; ToSocketAddrs needs host:port but the port
+    // doesn't affect which IPs the hostname resolves to.
+    let addrs: Vec<_> = format!("{}:0", host)
+        .to_socket_addrs()
+        .map_err(|e| format!("DNS resolution failed for {}: {}", host, e))?
+        .collect();
+
+    if addrs.is_empty() {
+        return Err(format!("DNS resolution returned no addresses for {}", host));
+    }
+
+    for addr in &addrs {
+        if is_private_ip(addr.ip()) {
+            return Err(format!(
+                "DNS rebinding detected: {} resolved to private IP {}",
+                host,
+                addr.ip()
+            ));
+        }
+    }
+
+    Ok(())
+}
+
+/// Check if an IP address belongs to a private/internal range.
+fn is_private_ip(ip: std::net::IpAddr) -> bool {
+    match ip {
+        std::net::IpAddr::V4(v4) => {
+            v4.is_loopback()           // 127.0.0.0/8
+            || v4.is_private()         // 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
+            || v4.is_link_local()      // 169.254.0.0/16
+            || v4.is_unspecified()     // 0.0.0.0
+            || v4.octets()[0] == 100 && (v4.octets()[1] & 0xC0) == 64 // 100.64.0.0/10 (CGNAT)
+        }
+        std::net::IpAddr::V6(v6) => {
+            v6.is_loopback()           // ::1
+            || v6.is_unspecified()     // ::
+            // fc00::/7 (unique local)
+            || (v6.segments()[0] & 0xFE00) == 0xFC00
+            // fe80::/10 (link-local)
+            || (v6.segments()[0] & 0xFFC0) == 0xFE80
+        }
+    }
+}
+
 #[cfg(test)]
 mod tests {
     use std::sync::Arc;
@@ -557,4 +678,67 @@ mod tests {
         assert!(caps.tool_invoke.is_none());
         assert!(caps.secrets.is_none());
     }
+
+    #[test]
+    fn test_is_private_ip_v4() {
+        use std::net::IpAddr;
+        // Private ranges
+        assert!(super::is_private_ip("127.0.0.1".parse::().unwrap()));
+        assert!(super::is_private_ip("10.0.0.1".parse::().unwrap()));
+        assert!(super::is_private_ip(
+            "172.16.0.1".parse::().unwrap()
+        ));
+        assert!(super::is_private_ip(
+            "192.168.1.1".parse::().unwrap()
+        ));
+        assert!(super::is_private_ip(
+            "169.254.1.1".parse::().unwrap()
+        ));
+        assert!(super::is_private_ip("0.0.0.0".parse::().unwrap()));
+        // CGNAT
+        assert!(super::is_private_ip(
+            "100.64.0.1".parse::().unwrap()
+        ));
+
+        // Public IPs
+        assert!(!super::is_private_ip("8.8.8.8".parse::().unwrap()));
+        assert!(!super::is_private_ip("1.1.1.1".parse::().unwrap()));
+        assert!(!super::is_private_ip(
+            "93.184.216.34".parse::().unwrap()
+        ));
+    }
+
+    #[test]
+    fn test_is_private_ip_v6() {
+        use std::net::IpAddr;
+        assert!(super::is_private_ip("::1".parse::().unwrap()));
+        assert!(super::is_private_ip("::".parse::().unwrap()));
+        assert!(super::is_private_ip("fc00::1".parse::().unwrap()));
+        assert!(super::is_private_ip("fe80::1".parse::().unwrap()));
+
+        // Public
+        assert!(!super::is_private_ip(
+            "2606:4700::1111".parse::().unwrap()
+        ));
+    }
+
+    #[test]
+    fn test_reject_private_ip_loopback() {
+        let result = super::reject_private_ip("https://127.0.0.1:8080/api");
+        assert!(result.is_err());
+        assert!(result.unwrap_err().contains("private/internal IP"));
+    }
+
+    #[test]
+    fn test_reject_private_ip_internal() {
+        let result = super::reject_private_ip("https://192.168.1.1/admin");
+        assert!(result.is_err());
+    }
+
+    #[test]
+    fn test_reject_private_ip_public_ok() {
+        // 8.8.8.8 (Google DNS) is public
+        let result = super::reject_private_ip("https://8.8.8.8/dns-query");
+        assert!(result.is_ok());
+    }
 }
diff --git a/src/util.rs b/src/util.rs
new file mode 100644
index 00000000..0ac7b69d
--- /dev/null
+++ b/src/util.rs
@@ -0,0 +1,178 @@
+//! Shared utility functions used across the codebase.
+
+/// Find the largest valid UTF-8 char boundary at or before `pos`.
+///
+/// Polyfill for `str::floor_char_boundary` (nightly-only). Use when
+/// truncating strings by byte position to avoid panicking on multi-byte
+/// characters.
+pub fn floor_char_boundary(s: &str, pos: usize) -> usize {
+    if pos >= s.len() {
+        return s.len();
+    }
+    let mut i = pos;
+    while i > 0 && !s.is_char_boundary(i) {
+        i -= 1;
+    }
+    i
+}
+
+/// Check if an LLM response explicitly signals that a job/task is complete.
+///
+/// Uses phrase-level matching to avoid false positives from bare words like
+/// "done" or "complete" appearing in non-completion contexts (e.g. "not done yet",
+/// "the download is incomplete").
+pub fn llm_signals_completion(response: &str) -> bool {
+    let lower = response.to_lowercase();
+
+    // Superset of phrases from agent/worker.rs and worker/runtime.rs.
+    let positive_phrases = [
+        "job is complete",
+        "job is done",
+        "job is finished",
+        "task is complete",
+        "task is done",
+        "task is finished",
+        "work is complete",
+        "work is done",
+        "work is finished",
+        "successfully completed",
+        "have completed the job",
+        "have completed the task",
+        "have finished the job",
+        "have finished the task",
+        "all steps are complete",
+        "all steps are done",
+        "i have completed",
+        "i've completed",
+        "all done",
+        "all tasks complete",
+    ];
+
+    let negative_phrases = [
+        "not complete",
+        "not done",
+        "not finished",
+        "incomplete",
+        "unfinished",
+        "isn't done",
+        "isn't complete",
+        "isn't finished",
+        "not yet done",
+        "not yet complete",
+        "not yet finished",
+    ];
+
+    let has_negative = negative_phrases.iter().any(|p| lower.contains(p));
+    if has_negative {
+        return false;
+    }
+
+    positive_phrases.iter().any(|p| lower.contains(p))
+}
+
+#[cfg(test)]
+mod tests {
+    use crate::util::{floor_char_boundary, llm_signals_completion};
+
+    // ── floor_char_boundary ──
+
+    #[test]
+    fn floor_char_boundary_at_valid_boundary() {
+        assert_eq!(floor_char_boundary("hello", 3), 3);
+    }
+
+    #[test]
+    fn floor_char_boundary_mid_multibyte_char() {
+        // h = 1 byte, é = 2 bytes, total 3 bytes
+        let s = "hé";
+        assert_eq!(floor_char_boundary(s, 2), 1); // byte 2 is mid-é, back up to 1
+    }
+
+    #[test]
+    fn floor_char_boundary_past_end() {
+        assert_eq!(floor_char_boundary("hi", 100), 2);
+    }
+
+    #[test]
+    fn floor_char_boundary_at_zero() {
+        assert_eq!(floor_char_boundary("hello", 0), 0);
+    }
+
+    #[test]
+    fn floor_char_boundary_empty_string() {
+        assert_eq!(floor_char_boundary("", 5), 0);
+    }
+
+    // ── llm_signals_completion ──
+
+    #[test]
+    fn signals_completion_positive() {
+        assert!(llm_signals_completion("The job is complete."));
+        assert!(llm_signals_completion("I have completed the task."));
+        assert!(llm_signals_completion("All done, here are the results."));
+        assert!(llm_signals_completion("Task is finished successfully."));
+        assert!(llm_signals_completion(
+            "I have completed the task successfully."
+        ));
+        assert!(llm_signals_completion(
+            "All steps are complete and verified."
+        ));
+        assert!(llm_signals_completion(
+            "I've done all the work. The work is done."
+        ));
+        assert!(llm_signals_completion(
+            "Successfully completed the migration."
+        ));
+        assert!(llm_signals_completion(
+            "I have completed the job ahead of schedule."
+        ));
+        assert!(llm_signals_completion("I have finished the task."));
+        assert!(llm_signals_completion("All steps are done now."));
+        assert!(llm_signals_completion("I've completed everything."));
+        assert!(llm_signals_completion("All tasks complete."));
+    }
+
+    #[test]
+    fn signals_completion_negative() {
+        assert!(!llm_signals_completion("The task is not complete yet."));
+        assert!(!llm_signals_completion("This is not done."));
+        assert!(!llm_signals_completion("The work is incomplete."));
+        assert!(!llm_signals_completion("Build is unfinished."));
+        assert!(!llm_signals_completion(
+            "The migration is not yet finished."
+        ));
+        assert!(!llm_signals_completion("The job isn't done yet."));
+        assert!(!llm_signals_completion("This remains unfinished."));
+    }
+
+    #[test]
+    fn signals_completion_no_bare_substrings() {
+        assert!(!llm_signals_completion("The download completed."));
+        assert!(!llm_signals_completion(
+            "Function done_callback was called."
+        ));
+        assert!(!llm_signals_completion("Set is_complete = true"));
+        assert!(!llm_signals_completion("Running step 3 of 5"));
+        assert!(!llm_signals_completion(
+            "I need to complete more work first."
+        ));
+        assert!(!llm_signals_completion(
+            "Let me finish the remaining steps."
+        ));
+        assert!(!llm_signals_completion(
+            "I'm done analyzing, now let me fix it."
+        ));
+        assert!(!llm_signals_completion(
+            "I completed step 1 but step 2 remains."
+        ));
+    }
+
+    #[test]
+    fn signals_completion_tool_output_injection() {
+        assert!(!llm_signals_completion("TASK_COMPLETE"));
+        assert!(!llm_signals_completion("JOB_DONE"));
+        assert!(!llm_signals_completion(
+            "The tool returned: TASK_COMPLETE signal"
+        ));
+    }
+}
diff --git a/src/worker/claude_bridge.rs b/src/worker/claude_bridge.rs
index 1f639281..102d43f1 100644
--- a/src/worker/claude_bridge.rs
+++ b/src/worker/claude_bridge.rs
@@ -4,18 +4,26 @@
 //! output back to the orchestrator via HTTP. Supports follow-up prompts via
 //! `--resume`.
 //!
+//! Security model: the Docker container is the primary security boundary
+//! (cap-drop ALL, non-root user, memory limits, network isolation).
+//! As defense-in-depth, a project-level `.claude/settings.json` is written
+//! before spawning with an explicit tool allowlist. Only listed tools are
+//! auto-approved; unknown/future tools would require interactive approval,
+//! which times out harmlessly in the non-interactive container.
+//!
 //! ```text
-//! ┌─────────────────────────────────────────────┐
-//! │ Docker Container                             │
-//! │                                              │
-//! │  ironclaw claude-bridge --job-id       │
+//! ┌──────────────────────────────────────────────┐
+//! │ Docker Container                              │
+//! │                                               │
+//! │  ironclaw claude-bridge --job-id        │
+//! │    └─ writes /workspace/.claude/settings.json │
 //! │    └─ claude -p "task" --output-format        │
-//! │       stream-json --dangerously-skip-perms   │
-//! │    └─ reads stdout line-by-line              │
-//! │    └─ POSTs events to orchestrator           │
-//! │    └─ polls for follow-up prompts            │
-//! │    └─ on follow-up: claude --resume          │
-//! └─────────────────────────────────────────────┘
+//! │       stream-json                             │
+//! │    └─ reads stdout line-by-line               │
+//! │    └─ POSTs events to orchestrator            │
+//! │    └─ polls for follow-up prompts             │
+//! │    └─ on follow-up: claude --resume           │
+//! └──────────────────────────────────────────────┘
 //! ```
 
 use std::sync::Arc;
@@ -36,6 +44,8 @@ pub struct ClaudeBridgeConfig {
     pub max_turns: u32,
     pub model: String,
     pub timeout: Duration,
+    /// Tool patterns to auto-approve via project-level settings.json.
+    pub allowed_tools: Vec,
 }
 
 /// A Claude Code streaming event (NDJSON line from `--output-format stream-json`).
@@ -119,8 +129,37 @@ impl ClaudeBridgeRuntime {
         Ok(Self { config, client })
     }
 
+    /// Write project-level `.claude/settings.json` with the tool allowlist.
+    ///
+    /// This replaces `--dangerously-skip-permissions` with an explicit set of
+    /// auto-approved tools. The Docker container is still the primary security
+    /// boundary; this is defense-in-depth.
+    fn write_permission_settings(&self) -> Result<(), WorkerError> {
+        let settings_json = build_permission_settings(&self.config.allowed_tools);
+        let settings_dir = std::path::Path::new("/workspace/.claude");
+        std::fs::create_dir_all(settings_dir).map_err(|e| WorkerError::ExecutionFailed {
+            reason: format!("failed to create /workspace/.claude/: {e}"),
+        })?;
+        std::fs::write(settings_dir.join("settings.json"), &settings_json).map_err(|e| {
+            WorkerError::ExecutionFailed {
+                reason: format!("failed to write settings.json: {e}"),
+            }
+        })?;
+        tracing::info!(
+            job_id = %self.config.job_id,
+            tools = ?self.config.allowed_tools,
+            "Wrote Claude Code permission settings"
+        );
+        Ok(())
+    }
+
     /// Run the bridge: fetch job, spawn claude, stream events, handle follow-ups.
     pub async fn run(&self) -> Result<(), WorkerError> {
+        // Write project-level settings with explicit tool allowlist.
+        // This replaces --dangerously-skip-permissions with defense-in-depth:
+        // only the listed tools are auto-approved, unknown tools fail safely.
+        self.write_permission_settings()?;
+
         // Fetch the job description from the orchestrator
         let job = self.client.get_job().await?;
 
@@ -226,7 +265,6 @@ impl ClaudeBridgeRuntime {
             .arg(prompt)
             .arg("--output-format")
             .arg("stream-json")
-            .arg("--dangerously-skip-permissions")
             .arg("--max-turns")
             .arg(self.config.max_turns.to_string())
             .arg("--model")
@@ -380,6 +418,19 @@ impl ClaudeBridgeRuntime {
     }
 }
 
+/// Build the JSON content for `.claude/settings.json` with the given tool allowlist.
+///
+/// Produces a Claude Code project settings file that auto-approves the listed
+/// tools while leaving any unknown/future tools unapproved (defense-in-depth).
+fn build_permission_settings(allowed_tools: &[String]) -> String {
+    let settings = serde_json::json!({
+        "permissions": {
+            "allow": allowed_tools,
+        }
+    });
+    serde_json::to_string_pretty(&settings).expect("static JSON structure is always valid")
+}
+
 /// Convert a Claude stream event into one or more event payloads for the orchestrator.
 fn stream_event_to_payloads(event: &ClaudeStreamEvent) -> Vec {
     let mut payloads = Vec::new();
@@ -465,7 +516,16 @@ fn stream_event_to_payloads(event: &ClaudeStreamEvent) -> Vec {
 }
 
 fn truncate(s: &str, max_len: usize) -> &str {
-    if s.len() <= max_len { s } else { &s[..max_len] }
+    if s.len() <= max_len {
+        s
+    } else {
+        // Walk back from max_len to find a valid UTF-8 char boundary.
+        let mut end = max_len;
+        while end > 0 && !s.is_char_boundary(end) {
+            end -= 1;
+        }
+        &s[..end]
+    }
 }
 
 #[cfg(test)]
@@ -641,4 +701,38 @@ mod tests {
         assert_eq!(truncate("hello world", 5), "hello");
         assert_eq!(truncate("", 5), "");
     }
+
+    #[test]
+    fn test_build_permission_settings_default_tools() {
+        let tools: Vec = ["Bash(*)", "Read", "Edit(*)", "Glob", "Grep"]
+            .into_iter()
+            .map(String::from)
+            .collect();
+        let json_str = build_permission_settings(&tools);
+        let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
+        let allow = parsed["permissions"]["allow"].as_array().unwrap();
+        assert_eq!(allow.len(), 5);
+        assert_eq!(allow[0], "Bash(*)");
+        assert_eq!(allow[1], "Read");
+        assert_eq!(allow[2], "Edit(*)");
+    }
+
+    #[test]
+    fn test_build_permission_settings_empty_tools() {
+        let json_str = build_permission_settings(&[]);
+        let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
+        let allow = parsed["permissions"]["allow"].as_array().unwrap();
+        assert!(allow.is_empty());
+    }
+
+    #[test]
+    fn test_build_permission_settings_is_valid_json() {
+        let tools = vec!["Bash(npm run *)".to_string(), "Read".to_string()];
+        let json_str = build_permission_settings(&tools);
+        // Must be valid JSON
+        let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
+        // Must have the expected structure
+        assert!(parsed["permissions"].is_object());
+        assert!(parsed["permissions"]["allow"].is_array());
+    }
 }
diff --git a/src/worker/runtime.rs b/src/worker/runtime.rs
index 2d83d55f..71f11177 100644
--- a/src/worker/runtime.rs
+++ b/src/worker/runtime.rs
@@ -238,7 +238,7 @@ Work independently to complete this job. Report when done."#,
                             reason: format!("respond_with_tools failed: {}", e),
                         })?;
 
-                match respond_result {
+                match respond_result.result {
                     RespondResult::Text(response) => {
                         self.post_event(
                             "message",
@@ -249,11 +249,7 @@ Work independently to complete this job. Report when done."#,
                         )
                         .await;
 
-                        let response_lower = response.to_lowercase();
-                        if response_lower.contains("complete")
-                            || response_lower.contains("finished")
-                            || response_lower.contains("done")
-                        {
+                        if crate::util::llm_signals_completion(&response) {
                             if last_output.is_empty() {
                                 last_output = response.clone();
                             }
@@ -431,7 +427,11 @@ Work independently to complete this job. Report when done."#,
                     wrapped,
                 ));
 
-                output.contains("TASK_COMPLETE") || output.contains("JOB_DONE")
+                // Tool output should never signal job completion. Only the LLM's
+                // natural language response should decide when a job is done. A
+                // tool could return text containing "TASK_COMPLETE" in its output
+                // (e.g. from file contents) and trigger a false positive.
+                false
             }
             Err(e) => {
                 tracing::warn!("Tool {} failed: {}", selection.tool_name, e);
@@ -486,6 +486,36 @@ fn truncate(s: &str, max: usize) -> String {
     if s.len() <= max {
         s.to_string()
     } else {
-        format!("{}...", &s[..max])
+        let end = crate::util::floor_char_boundary(s, max);
+        format!("{}...", &s[..end])
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use crate::worker::runtime::truncate;
+
+    #[test]
+    fn test_truncate_within_limit() {
+        assert_eq!(truncate("hello", 10), "hello");
+    }
+
+    #[test]
+    fn test_truncate_at_limit() {
+        assert_eq!(truncate("hello", 5), "hello");
+    }
+
+    #[test]
+    fn test_truncate_beyond_limit() {
+        let result = truncate("hello world", 5);
+        assert_eq!(result, "hello...");
+    }
+
+    #[test]
+    fn test_truncate_multibyte_safe() {
+        // "é" is 2 bytes in UTF-8; slicing at byte 1 would panic without safety
+        let result = truncate("é is fancy", 1);
+        // Should truncate to 0 chars (can't fit "é" in 1 byte)
+        assert_eq!(result, "...");
     }
 }
diff --git a/tests/workspace_integration.rs b/tests/workspace_integration.rs
index 568d00b9..11a4de76 100644
--- a/tests/workspace_integration.rs
+++ b/tests/workspace_integration.rs
@@ -20,6 +20,18 @@ fn get_pool() -> deadpool_postgres::Pool {
         .expect("Failed to create pool")
 }
 
+/// Try to get a connection, returning None if Postgres is unreachable.
+/// Tests call this to skip gracefully in CI where no database is available.
+async fn try_connect(pool: &deadpool_postgres::Pool) -> Option<()> {
+    match pool.get().await {
+        Ok(_) => Some(()),
+        Err(e) => {
+            eprintln!("skipping: database unavailable ({e})");
+            None
+        }
+    }
+}
+
 async fn cleanup_user(pool: &deadpool_postgres::Pool, user_id: &str) {
     let conn = pool.get().await.expect("Failed to get connection");
     conn.execute(
@@ -33,6 +45,9 @@ async fn cleanup_user(pool: &deadpool_postgres::Pool, user_id: &str) {
 #[tokio::test]
 async fn test_workspace_write_and_read() {
     let pool = get_pool();
+    if try_connect(&pool).await.is_none() {
+        return;
+    }
     let user_id = "test_write_read";
     cleanup_user(&pool, user_id).await;
 
@@ -58,6 +73,9 @@ async fn test_workspace_write_and_read() {
 #[tokio::test]
 async fn test_workspace_append() {
     let pool = get_pool();
+    if try_connect(&pool).await.is_none() {
+        return;
+    }
     let user_id = "test_append";
     cleanup_user(&pool, user_id).await;
 
@@ -85,6 +103,9 @@ async fn test_workspace_append() {
 #[tokio::test]
 async fn test_workspace_nested_paths() {
     let pool = get_pool();
+    if try_connect(&pool).await.is_none() {
+        return;
+    }
     let user_id = "test_nested";
     cleanup_user(&pool, user_id).await;
 
@@ -130,6 +151,9 @@ async fn test_workspace_nested_paths() {
 #[tokio::test]
 async fn test_workspace_delete() {
     let pool = get_pool();
+    if try_connect(&pool).await.is_none() {
+        return;
+    }
     let user_id = "test_delete";
     cleanup_user(&pool, user_id).await;
 
@@ -154,6 +178,9 @@ async fn test_workspace_delete() {
 #[tokio::test]
 async fn test_workspace_memory_operations() {
     let pool = get_pool();
+    if try_connect(&pool).await.is_none() {
+        return;
+    }
     let user_id = "test_memory_ops";
     cleanup_user(&pool, user_id).await;
 
@@ -182,6 +209,9 @@ async fn test_workspace_memory_operations() {
 #[tokio::test]
 async fn test_workspace_daily_log() {
     let pool = get_pool();
+    if try_connect(&pool).await.is_none() {
+        return;
+    }
     let user_id = "test_daily_log";
     cleanup_user(&pool, user_id).await;
 
@@ -208,6 +238,9 @@ async fn test_workspace_daily_log() {
 #[tokio::test]
 async fn test_workspace_fts_search() {
     let pool = get_pool();
+    if try_connect(&pool).await.is_none() {
+        return;
+    }
     let user_id = "test_fts_search";
     cleanup_user(&pool, user_id).await;
 
@@ -266,6 +299,9 @@ async fn test_workspace_fts_search() {
 #[tokio::test]
 async fn test_workspace_hybrid_search_with_mock_embeddings() {
     let pool = get_pool();
+    if try_connect(&pool).await.is_none() {
+        return;
+    }
     let user_id = "test_hybrid_search";
     cleanup_user(&pool, user_id).await;
 
@@ -305,6 +341,9 @@ async fn test_workspace_hybrid_search_with_mock_embeddings() {
 #[tokio::test]
 async fn test_workspace_list_all() {
     let pool = get_pool();
+    if try_connect(&pool).await.is_none() {
+        return;
+    }
     let user_id = "test_list_all";
     cleanup_user(&pool, user_id).await;
 
@@ -330,6 +369,9 @@ async fn test_workspace_list_all() {
 #[tokio::test]
 async fn test_workspace_system_prompt() {
     let pool = get_pool();
+    if try_connect(&pool).await.is_none() {
+        return;
+    }
     let user_id = "test_system_prompt";
     cleanup_user(&pool, user_id).await;
 
diff --git a/tests/ws_gateway_integration.rs b/tests/ws_gateway_integration.rs
index 80bcc3c9..6888a7f3 100644
--- a/tests/ws_gateway_integration.rs
+++ b/tests/ws_gateway_integration.rs
@@ -51,6 +51,7 @@ async fn start_test_server() -> (
         user_id: "test-user".to_string(),
         shutdown_tx: tokio::sync::RwLock::new(None),
         ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
+        chat_rate_limiter: ironclaw::channels::web::server::RateLimiter::new(30, 60),
     });
 
     let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
@@ -66,7 +67,12 @@ async fn connect_ws(
     addr: SocketAddr,
 ) -> tokio_tungstenite::WebSocketStream> {
     let url = format!("ws://{}/api/chat/ws?token={}", addr, AUTH_TOKEN);
-    let request = url.into_client_request().unwrap();
+    let mut request = url.into_client_request().unwrap();
+    // Server requires an Origin header from localhost to prevent cross-site WS hijacking.
+    request.headers_mut().insert(
+        "Origin",
+        format!("http://127.0.0.1:{}", addr.port()).parse().unwrap(),
+    );
     let (stream, _response) = tokio_tungstenite::connect_async(request)
         .await
         .expect("Failed to connect WebSocket");

From b3dee139547e07bb6165f6f47419bd7eb8bc82f2 Mon Sep 17 00:00:00 2001
From: Zaki Manian 
Date: Thu, 12 Feb 2026 22:18:43 -0800
Subject: [PATCH 22/33] fix: flatten tool messages for NEAR AI cloud-api
 compatibility (#41)

* fix: flatten tool messages for NEAR AI cloud-api compatibility

NEAR AI cloud-api does not support the OpenAI multi-turn tool-calling
protocol (role:"tool" messages cause HTTP 400). This adds a
flatten_tool_messages() pass in NearAiChatProvider that rewrites
assistant tool_call messages and tool result messages into plain
assistant/user text before sending to the API. The model still sees
the tool execution history, just in a text format it can process.

Also includes a minor fix to telegram channel send_pairing_reply
for updated WASM host function signature.

Co-Authored-By: Claude Opus 4.6 

* fix: resolve CI failures in fmt, rate limiting, and test configuration

- Apply cargo fmt to nearai_chat.rs formatting violations
- Fix truncate(true) bug in record_failed_approve that cleared the
  attempts file before reading, preventing rate limit from ever
  triggering
- Skip bundled channel test when WASM build artifacts are unavailable
  (CI lacks wasm32-wasip2 target)
- Split CI test workflow to exclude workspace_integration tests that
  require PostgreSQL

Co-Authored-By: Claude Opus 4.6 

* fix: resolve clippy unnecessary_unwrap lint (Rust 1.93)

Replace is_some() + unwrap() pattern with if-let binding to satisfy
clippy::unnecessary_unwrap which is now deny-by-default.

Co-Authored-By: Claude Opus 4.6 

---------

Co-authored-by: Claude Opus 4.6 
Co-authored-by: firat.sertgoz 
Co-authored-by: Illia Polosukhin 
---
 channels-src/telegram/src/lib.rs |   1 +
 src/llm/nearai_chat.rs           | 179 +++++++++++++++++++++++++++++++
 2 files changed, 180 insertions(+)

diff --git a/channels-src/telegram/src/lib.rs b/channels-src/telegram/src/lib.rs
index 09a99df3..08e82804 100644
--- a/channels-src/telegram/src/lib.rs
+++ b/channels-src/telegram/src/lib.rs
@@ -856,6 +856,7 @@ fn send_pairing_reply(chat_id: i64, code: &str) -> Result<(), String> {
         "https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage",
         &headers.to_string(),
         Some(&payload_bytes),
+        None,
     );
 
     match result {
diff --git a/src/llm/nearai_chat.rs b/src/llm/nearai_chat.rs
index 8c0c5747..c51828bb 100644
--- a/src/llm/nearai_chat.rs
+++ b/src/llm/nearai_chat.rs
@@ -222,6 +222,12 @@ impl LlmProvider for NearAiChatProvider {
         let messages: Vec =
             req.messages.into_iter().map(|m| m.into()).collect();
 
+        // NEAR AI cloud-api does not support multi-turn tool calling (rejects
+        // any request containing role:"tool" messages with HTTP 400). Rewrite
+        // tool-call / tool-result pairs into plain text so the conversation
+        // history is preserved without using unsupported message roles.
+        let messages = flatten_tool_messages(messages);
+
         let tools: Vec = req
             .tools
             .into_iter()
@@ -367,6 +373,64 @@ struct ChatCompletionMessage {
     tool_calls: Option>,
 }
 
+/// Rewrite tool-call / tool-result messages into plain assistant/user text.
+///
+/// NEAR AI cloud-api does not support the OpenAI multi-turn tool-calling
+/// protocol (`role: "tool"` messages). This function converts:
+///   - Assistant messages with `tool_calls` → assistant text describing the calls
+///   - Tool result messages (`role: "tool"`) → user messages with the result
+///
+/// Non-tool messages pass through unchanged.
+fn flatten_tool_messages(messages: Vec) -> Vec {
+    let has_tool_msgs = messages.iter().any(|m| m.role == "tool");
+    if !has_tool_msgs {
+        return messages;
+    }
+
+    tracing::debug!("Flattening tool messages for NEAR AI compatibility");
+
+    messages
+        .into_iter()
+        .map(|msg| {
+            if let (true, Some(calls)) = (msg.role == "assistant", &msg.tool_calls) {
+                // Convert assistant tool_calls into descriptive text
+                let mut parts: Vec = Vec::new();
+                if let Some(ref text) = msg.content {
+                    if !text.is_empty() {
+                        parts.push(text.clone());
+                    }
+                }
+                for tc in calls {
+                    parts.push(format!(
+                        "[Called tool `{}` with arguments: {}]",
+                        tc.function.name, tc.function.arguments
+                    ));
+                }
+                ChatCompletionMessage {
+                    role: "assistant".to_string(),
+                    content: Some(parts.join("\n")),
+                    tool_call_id: None,
+                    name: None,
+                    tool_calls: None,
+                }
+            } else if msg.role == "tool" {
+                // Convert tool result into a user message
+                let tool_name = msg.name.as_deref().unwrap_or("unknown");
+                let result = msg.content.as_deref().unwrap_or("");
+                ChatCompletionMessage {
+                    role: "user".to_string(),
+                    content: Some(format!("[Tool `{}` returned: {}]", tool_name, result)),
+                    tool_call_id: None,
+                    name: None,
+                    tool_calls: None,
+                }
+            } else {
+                msg
+            }
+        })
+        .collect()
+}
+
 impl From for ChatCompletionMessage {
     fn from(msg: ChatMessage) -> Self {
         let role = match msg.role {
@@ -544,4 +608,119 @@ mod tests {
             serde_json::from_str(&calls[0].function.arguments).expect("valid JSON string");
         assert_eq!(parsed["key"], "value");
     }
+
+    #[test]
+    fn test_flatten_no_tool_messages_passthrough() {
+        let messages = vec![
+            ChatCompletionMessage {
+                role: "system".to_string(),
+                content: Some("You are helpful.".to_string()),
+                tool_call_id: None,
+                name: None,
+                tool_calls: None,
+            },
+            ChatCompletionMessage {
+                role: "user".to_string(),
+                content: Some("Hello".to_string()),
+                tool_call_id: None,
+                name: None,
+                tool_calls: None,
+            },
+        ];
+        let result = flatten_tool_messages(messages);
+        assert_eq!(result.len(), 2);
+        assert_eq!(result[0].role, "system");
+        assert_eq!(result[1].role, "user");
+    }
+
+    #[test]
+    fn test_flatten_tool_call_and_result() {
+        let messages = vec![
+            ChatCompletionMessage {
+                role: "user".to_string(),
+                content: Some("test".to_string()),
+                tool_call_id: None,
+                name: None,
+                tool_calls: None,
+            },
+            ChatCompletionMessage {
+                role: "assistant".to_string(),
+                content: None,
+                tool_call_id: None,
+                name: None,
+                tool_calls: Some(vec![ChatCompletionToolCall {
+                    id: "call_1".to_string(),
+                    call_type: "function".to_string(),
+                    function: ChatCompletionToolCallFunction {
+                        name: "echo".to_string(),
+                        arguments: r#"{"message":"hi"}"#.to_string(),
+                    },
+                }]),
+            },
+            ChatCompletionMessage {
+                role: "tool".to_string(),
+                content: Some("hi".to_string()),
+                tool_call_id: Some("call_1".to_string()),
+                name: Some("echo".to_string()),
+                tool_calls: None,
+            },
+        ];
+
+        let result = flatten_tool_messages(messages);
+        assert_eq!(result.len(), 3);
+
+        // Assistant tool_calls → plain assistant text
+        assert_eq!(result[1].role, "assistant");
+        assert!(result[1].tool_calls.is_none());
+        assert!(
+            result[1]
+                .content
+                .as_ref()
+                .unwrap()
+                .contains("[Called tool `echo`")
+        );
+
+        // Tool result → user message
+        assert_eq!(result[2].role, "user");
+        assert!(result[2].tool_call_id.is_none());
+        assert!(
+            result[2]
+                .content
+                .as_ref()
+                .unwrap()
+                .contains("[Tool `echo` returned: hi]")
+        );
+    }
+
+    #[test]
+    fn test_flatten_preserves_assistant_text_with_tool_calls() {
+        let messages = vec![
+            ChatCompletionMessage {
+                role: "assistant".to_string(),
+                content: Some("Let me check that.".to_string()),
+                tool_call_id: None,
+                name: None,
+                tool_calls: Some(vec![ChatCompletionToolCall {
+                    id: "call_1".to_string(),
+                    call_type: "function".to_string(),
+                    function: ChatCompletionToolCallFunction {
+                        name: "search".to_string(),
+                        arguments: r#"{"q":"test"}"#.to_string(),
+                    },
+                }]),
+            },
+            ChatCompletionMessage {
+                role: "tool".to_string(),
+                content: Some("found it".to_string()),
+                tool_call_id: Some("call_1".to_string()),
+                name: Some("search".to_string()),
+                tool_calls: None,
+            },
+        ];
+
+        let result = flatten_tool_messages(messages);
+        let text = result[0].content.as_ref().unwrap();
+        assert!(text.starts_with("Let me check that."));
+        assert!(text.contains("[Called tool `search`"));
+    }
 }

From bbb68f74908192f71f5cbb68c9bedb2a606882b6 Mon Sep 17 00:00:00 2001
From: Jaswinder 
Date: Fri, 13 Feb 2026 15:26:49 +0100
Subject: [PATCH 23/33] Add OpenAI-compatible HTTP API (/v1/chat/completions,
 /v1/models)   (#31)

* feat: add OpenAI-compatible HTTP API (/v1/chat/completions, /v1/models)

* - Reject model mismatches: validate req.model against the active model
    and return 404 model_not_found instead of silently ignoring it
  - Add x-ironclaw-streaming: simulated response header so clients know
    streaming is not true token-by-token delivery
  - Use SSE event type "error" for mid-stream LLM failures so clients can
    distinguish errors from content chunks
  - Mark docker-compose credentials as dev-only
  - Add integration tests for model mismatch, streaming header, and body
    size limit (axum's default 2MB)

* fix: address Copilot review feedback on OpenAI-compat API

- Wire chat_rate_limiter into /v1/chat/completions handler
- Execute LLM before starting SSE stream so failures return proper HTTP
  errors instead of SSE error events
- Validate tool-role messages require tool_call_id and name fields
- Surface list_models() errors in models_handler via map_llm_error
- Reject unknown roles with 400 instead of defaulting to User

Co-Authored-By: Claude Opus 4.6 

---------

Co-authored-by: firat.sertgoz 
Co-authored-by: Claude Opus 4.6 
---
 FEATURE_PARITY.md                  |    2 +-
 docker-compose.yml                 |   20 +
 src/channels/web/mod.rs            |    9 +
 src/channels/web/openai_compat.rs  | 1094 ++++++++++++++++++++++++++++
 src/channels/web/server.rs         |    8 +
 src/channels/web/ws.rs             |    1 +
 src/main.rs                        |    1 +
 tests/openai_compat_integration.rs |  478 ++++++++++++
 tests/ws_gateway_integration.rs    |    1 +
 9 files changed, 1613 insertions(+), 1 deletion(-)
 create mode 100644 docker-compose.yml
 create mode 100644 src/channels/web/openai_compat.rs
 create mode 100644 tests/openai_compat_integration.rs

diff --git a/FEATURE_PARITY.md b/FEATURE_PARITY.md
index a5ce7092..b6265291 100644
--- a/FEATURE_PARITY.md
+++ b/FEATURE_PARITY.md
@@ -37,7 +37,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
 | Session management/routing | ✅ | ✅ | SessionManager exists |
 | Configuration hot-reload | ✅ | ❌ | |
 | Network modes (loopback/LAN/remote) | ✅ | 🚧 | HTTP only |
-| OpenAI-compatible HTTP API | ✅ | ❌ | /v1/chat/completions |
+| OpenAI-compatible HTTP API | ✅ | ✅ | /v1/chat/completions |
 | Canvas hosting | ✅ | ❌ | Agent-driven UI |
 | Gateway lock (PID-based) | ✅ | ❌ | |
 | launchd/systemd integration | ✅ | ❌ | |
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 00000000..9b82fcdb
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,20 @@
+# Local development only — do NOT use these credentials in production.
+services:
+  postgres:
+    image: pgvector/pgvector:pg16
+    ports:
+      - "5432:5432"
+    environment:
+      POSTGRES_DB: ironclaw
+      POSTGRES_USER: ironclaw
+      POSTGRES_PASSWORD: ironclaw  # dev-only, change for any non-local deployment
+    volumes:
+      - pgdata:/var/lib/postgresql/data
+    healthcheck:
+      test: ["CMD-SHELL", "pg_isready -U ironclaw"]
+      interval: 5s
+      timeout: 3s
+      retries: 5
+
+volumes:
+  pgdata:
diff --git a/src/channels/web/mod.rs b/src/channels/web/mod.rs
index 80a5242b..ac6ef2f6 100644
--- a/src/channels/web/mod.rs
+++ b/src/channels/web/mod.rs
@@ -16,6 +16,7 @@
 
 pub mod auth;
 pub mod log_layer;
+pub mod openai_compat;
 pub mod server;
 pub mod sse;
 pub mod types;
@@ -81,6 +82,7 @@ impl GatewayChannel {
             user_id: config.user_id.clone(),
             shutdown_tx: tokio::sync::RwLock::new(None),
             ws_tracker: Some(Arc::new(ws::WsConnectionTracker::new())),
+            llm_provider: None,
             chat_rate_limiter: server::RateLimiter::new(30, 60),
         });
 
@@ -107,6 +109,7 @@ impl GatewayChannel {
             user_id: self.state.user_id.clone(),
             shutdown_tx: tokio::sync::RwLock::new(None),
             ws_tracker: self.state.ws_tracker.clone(),
+            llm_provider: self.state.llm_provider.clone(),
             chat_rate_limiter: server::RateLimiter::new(30, 60),
         };
         mutate(&mut new_state);
@@ -171,6 +174,12 @@ impl GatewayChannel {
         self
     }
 
+    /// Inject the LLM provider for OpenAI-compatible API proxy.
+    pub fn with_llm_provider(mut self, llm: Arc) -> Self {
+        self.rebuild_state(|s| s.llm_provider = Some(llm));
+        self
+    }
+
     /// Get the auth token (for printing to console on startup).
     pub fn auth_token(&self) -> &str {
         &self.auth_token
diff --git a/src/channels/web/openai_compat.rs b/src/channels/web/openai_compat.rs
new file mode 100644
index 00000000..7185ca7b
--- /dev/null
+++ b/src/channels/web/openai_compat.rs
@@ -0,0 +1,1094 @@
+//! OpenAI-compatible HTTP API (`/v1/chat/completions`, `/v1/models`).
+//!
+//! This module provides a direct LLM proxy through the web gateway so any
+//! standard OpenAI client library can use IronClaw as a backend by simply
+//! changing the `base_url`.
+
+use std::sync::Arc;
+
+use axum::{
+    Json,
+    extract::State,
+    http::{HeaderValue, StatusCode},
+    response::{
+        IntoResponse, Response,
+        sse::{Event, KeepAlive, Sse},
+    },
+};
+use serde::{Deserialize, Serialize};
+
+use crate::llm::{
+    ChatMessage, CompletionRequest, FinishReason, Role, ToolCall, ToolCompletionRequest,
+    ToolDefinition,
+};
+
+use super::server::GatewayState;
+
+// ---------------------------------------------------------------------------
+// OpenAI request types
+// ---------------------------------------------------------------------------
+
+#[derive(Debug, Deserialize)]
+pub struct OpenAiChatRequest {
+    pub model: String,
+    pub messages: Vec,
+    #[serde(default)]
+    pub temperature: Option,
+    #[serde(default)]
+    pub max_tokens: Option,
+    #[serde(default)]
+    pub stream: Option,
+    #[serde(default)]
+    pub tools: Option>,
+    #[serde(default)]
+    pub tool_choice: Option,
+    #[serde(default)]
+    pub stop: Option,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct OpenAiMessage {
+    pub role: String,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub content: Option,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub name: Option,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub tool_call_id: Option,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub tool_calls: Option>,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct OpenAiTool {
+    #[serde(rename = "type")]
+    pub tool_type: String,
+    pub function: OpenAiFunction,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct OpenAiFunction {
+    pub name: String,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub description: Option,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub parameters: Option,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct OpenAiToolCall {
+    pub id: String,
+    #[serde(rename = "type")]
+    pub call_type: String,
+    pub function: OpenAiToolCallFunction,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct OpenAiToolCallFunction {
+    pub name: String,
+    pub arguments: String,
+}
+
+// ---------------------------------------------------------------------------
+// OpenAI response types (non-streaming)
+// ---------------------------------------------------------------------------
+
+#[derive(Debug, Serialize)]
+pub struct OpenAiChatResponse {
+    pub id: String,
+    pub object: &'static str,
+    pub created: u64,
+    pub model: String,
+    pub choices: Vec,
+    pub usage: OpenAiUsage,
+}
+
+#[derive(Debug, Serialize)]
+pub struct OpenAiChoice {
+    pub index: u32,
+    pub message: OpenAiMessage,
+    pub finish_reason: String,
+}
+
+#[derive(Debug, Serialize)]
+pub struct OpenAiUsage {
+    pub prompt_tokens: u32,
+    pub completion_tokens: u32,
+    pub total_tokens: u32,
+}
+
+// ---------------------------------------------------------------------------
+// OpenAI response types (streaming)
+// ---------------------------------------------------------------------------
+
+#[derive(Debug, Serialize)]
+pub struct OpenAiChatChunk {
+    pub id: String,
+    pub object: &'static str,
+    pub created: u64,
+    pub model: String,
+    pub choices: Vec,
+}
+
+#[derive(Debug, Serialize)]
+pub struct OpenAiChunkChoice {
+    pub index: u32,
+    pub delta: OpenAiDelta,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub finish_reason: Option,
+}
+
+#[derive(Debug, Serialize)]
+pub struct OpenAiDelta {
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub role: Option,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub content: Option,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub tool_calls: Option>,
+}
+
+#[derive(Debug, Serialize)]
+pub struct OpenAiToolCallDelta {
+    pub index: u32,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub id: Option,
+    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
+    pub call_type: Option,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub function: Option,
+}
+
+#[derive(Debug, Serialize)]
+pub struct OpenAiToolCallFunctionDelta {
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub name: Option,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub arguments: Option,
+}
+
+// ---------------------------------------------------------------------------
+// Error response
+// ---------------------------------------------------------------------------
+
+#[derive(Debug, Serialize)]
+pub struct OpenAiErrorResponse {
+    pub error: OpenAiErrorDetail,
+}
+
+#[derive(Debug, Serialize)]
+pub struct OpenAiErrorDetail {
+    pub message: String,
+    #[serde(rename = "type")]
+    pub error_type: String,
+    pub param: Option,
+    pub code: Option,
+}
+
+// ---------------------------------------------------------------------------
+// Conversion functions
+// ---------------------------------------------------------------------------
+
+fn parse_role(s: &str) -> Result {
+    match s {
+        "system" => Ok(Role::System),
+        "user" => Ok(Role::User),
+        "assistant" => Ok(Role::Assistant),
+        "tool" => Ok(Role::Tool),
+        _ => Err(format!("Unknown role: '{}'", s)),
+    }
+}
+
+pub fn convert_messages(messages: &[OpenAiMessage]) -> Result, String> {
+    messages
+        .iter()
+        .enumerate()
+        .map(|(i, m)| {
+            let role = parse_role(&m.role).map_err(|e| format!("messages[{}]: {}", i, e))?;
+            match role {
+                Role::Tool => {
+                    let tool_call_id = m.tool_call_id.as_deref().ok_or_else(|| {
+                        format!("messages[{}]: tool message requires 'tool_call_id'", i)
+                    })?;
+                    let name = m
+                        .name
+                        .as_deref()
+                        .ok_or_else(|| format!("messages[{}]: tool message requires 'name'", i))?;
+                    Ok(ChatMessage::tool_result(
+                        tool_call_id,
+                        name,
+                        m.content.as_deref().unwrap_or(""),
+                    ))
+                }
+                Role::Assistant => {
+                    if let Some(ref tcs) = m.tool_calls {
+                        let calls: Vec = tcs
+                            .iter()
+                            .map(|tc| ToolCall {
+                                id: tc.id.clone(),
+                                name: tc.function.name.clone(),
+                                arguments: serde_json::from_str(&tc.function.arguments)
+                                    .unwrap_or(serde_json::Value::Object(Default::default())),
+                            })
+                            .collect();
+                        Ok(ChatMessage::assistant_with_tool_calls(
+                            m.content.clone(),
+                            calls,
+                        ))
+                    } else {
+                        Ok(ChatMessage::assistant(m.content.as_deref().unwrap_or("")))
+                    }
+                }
+                _ => Ok(ChatMessage {
+                    role,
+                    content: m.content.as_deref().unwrap_or("").to_string(),
+                    tool_call_id: None,
+                    name: m.name.clone(),
+                    tool_calls: None,
+                }),
+            }
+        })
+        .collect()
+}
+
+pub fn convert_tools(tools: &[OpenAiTool]) -> Vec {
+    tools
+        .iter()
+        .filter(|t| t.tool_type == "function")
+        .map(|t| ToolDefinition {
+            name: t.function.name.clone(),
+            description: t.function.description.clone().unwrap_or_default(),
+            parameters: t
+                .function
+                .parameters
+                .clone()
+                .unwrap_or(serde_json::json!({"type": "object", "properties": {}})),
+        })
+        .collect()
+}
+
+fn convert_tool_calls_to_openai(calls: &[ToolCall]) -> Vec {
+    calls
+        .iter()
+        .map(|tc| OpenAiToolCall {
+            id: tc.id.clone(),
+            call_type: "function".to_string(),
+            function: OpenAiToolCallFunction {
+                name: tc.name.clone(),
+                arguments: serde_json::to_string(&tc.arguments).unwrap_or_default(),
+            },
+        })
+        .collect()
+}
+
+pub fn finish_reason_str(reason: FinishReason) -> String {
+    match reason {
+        FinishReason::Stop => "stop".to_string(),
+        FinishReason::Length => "length".to_string(),
+        FinishReason::ToolUse => "tool_calls".to_string(),
+        FinishReason::ContentFilter => "content_filter".to_string(),
+        FinishReason::Unknown => "stop".to_string(),
+    }
+}
+
+fn normalize_tool_choice(val: &serde_json::Value) -> Option {
+    match val {
+        serde_json::Value::String(s) => Some(s.clone()),
+        serde_json::Value::Object(obj) => {
+            // { "type": "function", "function": { "name": "foo" } } → "required"
+            if obj.contains_key("function") {
+                Some("required".to_string())
+            } else {
+                obj.get("type")
+                    .and_then(|v| v.as_str())
+                    .map(|s| s.to_string())
+            }
+        }
+        _ => None,
+    }
+}
+
+fn map_llm_error(err: crate::error::LlmError) -> (StatusCode, Json) {
+    let (status, error_type, code) = match &err {
+        crate::error::LlmError::AuthFailed { .. }
+        | crate::error::LlmError::SessionExpired { .. } => (
+            StatusCode::UNAUTHORIZED,
+            "authentication_error",
+            "auth_error",
+        ),
+        crate::error::LlmError::RateLimited { .. } => (
+            StatusCode::TOO_MANY_REQUESTS,
+            "rate_limit_error",
+            "rate_limit",
+        ),
+        crate::error::LlmError::ContextLengthExceeded { .. } => (
+            StatusCode::BAD_REQUEST,
+            "invalid_request_error",
+            "context_length_exceeded",
+        ),
+        crate::error::LlmError::ModelNotAvailable { .. } => (
+            StatusCode::NOT_FOUND,
+            "invalid_request_error",
+            "model_not_found",
+        ),
+        _ => (
+            StatusCode::INTERNAL_SERVER_ERROR,
+            "server_error",
+            "internal_error",
+        ),
+    };
+
+    (
+        status,
+        Json(OpenAiErrorResponse {
+            error: OpenAiErrorDetail {
+                message: err.to_string(),
+                error_type: error_type.to_string(),
+                param: None,
+                code: Some(code.to_string()),
+            },
+        }),
+    )
+}
+
+fn openai_error(
+    status: StatusCode,
+    message: impl Into,
+    error_type: &str,
+) -> (StatusCode, Json) {
+    (
+        status,
+        Json(OpenAiErrorResponse {
+            error: OpenAiErrorDetail {
+                message: message.into(),
+                error_type: error_type.to_string(),
+                param: None,
+                code: None,
+            },
+        }),
+    )
+}
+
+fn chat_completion_id() -> String {
+    format!("chatcmpl-{}", uuid::Uuid::new_v4().simple())
+}
+
+fn unix_timestamp() -> u64 {
+    std::time::SystemTime::now()
+        .duration_since(std::time::UNIX_EPOCH)
+        .unwrap_or_default()
+        .as_secs()
+}
+
+/// Extract stop sequences from the flexible `stop` field.
+fn parse_stop(val: &serde_json::Value) -> Option> {
+    match val {
+        serde_json::Value::String(s) => Some(vec![s.clone()]),
+        serde_json::Value::Array(arr) => {
+            let strs: Vec = arr
+                .iter()
+                .filter_map(|v| v.as_str().map(String::from))
+                .collect();
+            if strs.is_empty() { None } else { Some(strs) }
+        }
+        _ => None,
+    }
+}
+
+// ---------------------------------------------------------------------------
+// Handlers
+// ---------------------------------------------------------------------------
+
+pub async fn chat_completions_handler(
+    State(state): State>,
+    Json(req): Json,
+) -> Result)> {
+    if !state.chat_rate_limiter.check() {
+        return Err(openai_error(
+            StatusCode::TOO_MANY_REQUESTS,
+            "Rate limit exceeded. Please try again later.",
+            "rate_limit_error",
+        ));
+    }
+
+    let llm = state.llm_provider.as_ref().ok_or_else(|| {
+        openai_error(
+            StatusCode::SERVICE_UNAVAILABLE,
+            "LLM provider not configured",
+            "server_error",
+        )
+    })?;
+
+    if req.messages.is_empty() {
+        return Err(openai_error(
+            StatusCode::BAD_REQUEST,
+            "messages must not be empty",
+            "invalid_request_error",
+        ));
+    }
+
+    // Validate the requested model matches the active model.
+    // Per-request model switching is not yet supported (see GH issue).
+    let active_model = llm.active_model_name();
+    if req.model != active_model {
+        return Err((
+            StatusCode::NOT_FOUND,
+            Json(OpenAiErrorResponse {
+                error: OpenAiErrorDetail {
+                    message: format!(
+                        "Model '{}' not found. The active model is '{}'.",
+                        req.model, active_model
+                    ),
+                    error_type: "invalid_request_error".to_string(),
+                    param: Some("model".to_string()),
+                    code: Some("model_not_found".to_string()),
+                },
+            }),
+        ));
+    }
+
+    let has_tools = req.tools.as_ref().is_some_and(|t| !t.is_empty());
+    let stream = req.stream.unwrap_or(false);
+
+    if stream {
+        return handle_streaming(llm.clone(), req, has_tools)
+            .await
+            .map(IntoResponse::into_response);
+    }
+
+    // --- Non-streaming path ---
+
+    let messages = convert_messages(&req.messages)
+        .map_err(|e| openai_error(StatusCode::BAD_REQUEST, e, "invalid_request_error"))?;
+    let model_name = llm.active_model_name();
+    let id = chat_completion_id();
+    let created = unix_timestamp();
+
+    if has_tools {
+        let tools = convert_tools(req.tools.as_deref().unwrap_or(&[]));
+        let mut tool_req = ToolCompletionRequest::new(messages, tools);
+        if let Some(t) = req.temperature {
+            tool_req = tool_req.with_temperature(t);
+        }
+        if let Some(mt) = req.max_tokens {
+            tool_req = tool_req.with_max_tokens(mt);
+        }
+        if let Some(ref tc) = req.tool_choice {
+            if let Some(choice) = normalize_tool_choice(tc) {
+                tool_req = tool_req.with_tool_choice(choice);
+            }
+        }
+
+        let resp = llm
+            .complete_with_tools(tool_req)
+            .await
+            .map_err(map_llm_error)?;
+
+        let tool_calls_openai = if resp.tool_calls.is_empty() {
+            None
+        } else {
+            Some(convert_tool_calls_to_openai(&resp.tool_calls))
+        };
+
+        let response = OpenAiChatResponse {
+            id,
+            object: "chat.completion",
+            created,
+            model: model_name,
+            choices: vec![OpenAiChoice {
+                index: 0,
+                message: OpenAiMessage {
+                    role: "assistant".to_string(),
+                    content: resp.content.clone(),
+                    name: None,
+                    tool_call_id: None,
+                    tool_calls: tool_calls_openai,
+                },
+                finish_reason: finish_reason_str(resp.finish_reason),
+            }],
+            usage: OpenAiUsage {
+                prompt_tokens: resp.input_tokens,
+                completion_tokens: resp.output_tokens,
+                total_tokens: resp.input_tokens + resp.output_tokens,
+            },
+        };
+
+        Ok(Json(response).into_response())
+    } else {
+        let mut comp_req = CompletionRequest::new(messages);
+        if let Some(t) = req.temperature {
+            comp_req = comp_req.with_temperature(t);
+        }
+        if let Some(mt) = req.max_tokens {
+            comp_req = comp_req.with_max_tokens(mt);
+        }
+        if let Some(ref stop_val) = req.stop {
+            comp_req.stop_sequences = parse_stop(stop_val);
+        }
+
+        let resp = llm.complete(comp_req).await.map_err(map_llm_error)?;
+
+        let response = OpenAiChatResponse {
+            id,
+            object: "chat.completion",
+            created,
+            model: model_name,
+            choices: vec![OpenAiChoice {
+                index: 0,
+                message: OpenAiMessage {
+                    role: "assistant".to_string(),
+                    content: Some(resp.content),
+                    name: None,
+                    tool_call_id: None,
+                    tool_calls: None,
+                },
+                finish_reason: finish_reason_str(resp.finish_reason),
+            }],
+            usage: OpenAiUsage {
+                prompt_tokens: resp.input_tokens,
+                completion_tokens: resp.output_tokens,
+                total_tokens: resp.input_tokens + resp.output_tokens,
+            },
+        };
+
+        Ok(Json(response).into_response())
+    }
+}
+
+/// Handle streaming responses.
+///
+/// The current `LlmProvider` returns complete responses (no streaming method).
+/// We execute the LLM call first, then simulate chunked delivery by splitting
+/// the response into word-boundary chunks. This ensures LLM failures return
+/// proper HTTP errors instead of SSE error events. True token streaming can be
+/// added later by extending `LlmProvider` with a `complete_stream()` method.
+async fn handle_streaming(
+    llm: Arc,
+    req: OpenAiChatRequest,
+    has_tools: bool,
+) -> Result)> {
+    let messages = convert_messages(&req.messages)
+        .map_err(|e| openai_error(StatusCode::BAD_REQUEST, e, "invalid_request_error"))?;
+
+    let model_name = llm.active_model_name();
+    let id = chat_completion_id();
+    let created = unix_timestamp();
+
+    // Execute the LLM call before starting the SSE stream.
+    // Since streaming is simulated (LlmProvider returns complete responses),
+    // this lets us return proper HTTP errors on failure.
+    enum LlmResult {
+        Simple(crate::llm::CompletionResponse),
+        WithTools(crate::llm::ToolCompletionResponse),
+    }
+
+    let llm_result = if has_tools {
+        let tools = convert_tools(req.tools.as_deref().unwrap_or(&[]));
+        let mut tool_req = ToolCompletionRequest::new(messages, tools);
+        if let Some(t) = req.temperature {
+            tool_req = tool_req.with_temperature(t);
+        }
+        if let Some(mt) = req.max_tokens {
+            tool_req = tool_req.with_max_tokens(mt);
+        }
+        if let Some(ref tc) = req.tool_choice {
+            if let Some(choice) = normalize_tool_choice(tc) {
+                tool_req = tool_req.with_tool_choice(choice);
+            }
+        }
+        LlmResult::WithTools(
+            llm.complete_with_tools(tool_req)
+                .await
+                .map_err(map_llm_error)?,
+        )
+    } else {
+        let mut comp_req = CompletionRequest::new(messages);
+        if let Some(t) = req.temperature {
+            comp_req = comp_req.with_temperature(t);
+        }
+        if let Some(mt) = req.max_tokens {
+            comp_req = comp_req.with_max_tokens(mt);
+        }
+        if let Some(ref stop_val) = req.stop {
+            comp_req.stop_sequences = parse_stop(stop_val);
+        }
+        LlmResult::Simple(llm.complete(comp_req).await.map_err(map_llm_error)?)
+    };
+
+    // LLM succeeded — emit the response as SSE chunks
+    let (tx, rx) = tokio::sync::mpsc::channel::>(64);
+
+    tokio::spawn(async move {
+        // Send initial chunk with role
+        let role_chunk = OpenAiChatChunk {
+            id: id.clone(),
+            object: "chat.completion.chunk",
+            created,
+            model: model_name.clone(),
+            choices: vec![OpenAiChunkChoice {
+                index: 0,
+                delta: OpenAiDelta {
+                    role: Some("assistant".to_string()),
+                    content: None,
+                    tool_calls: None,
+                },
+                finish_reason: None,
+            }],
+        };
+        let data = serde_json::to_string(&role_chunk).unwrap_or_default();
+        let _ = tx.send(Ok(Event::default().data(data))).await;
+
+        match llm_result {
+            LlmResult::WithTools(resp) => {
+                // Stream content chunks
+                if let Some(ref content) = resp.content {
+                    stream_content_chunks(&tx, &id, created, &model_name, content).await;
+                }
+
+                // Stream tool calls
+                if !resp.tool_calls.is_empty() {
+                    let deltas: Vec = resp
+                        .tool_calls
+                        .iter()
+                        .enumerate()
+                        .map(|(i, tc)| OpenAiToolCallDelta {
+                            index: i as u32,
+                            id: Some(tc.id.clone()),
+                            call_type: Some("function".to_string()),
+                            function: Some(OpenAiToolCallFunctionDelta {
+                                name: Some(tc.name.clone()),
+                                arguments: Some(
+                                    serde_json::to_string(&tc.arguments).unwrap_or_default(),
+                                ),
+                            }),
+                        })
+                        .collect();
+
+                    let chunk = OpenAiChatChunk {
+                        id: id.clone(),
+                        object: "chat.completion.chunk",
+                        created,
+                        model: model_name.clone(),
+                        choices: vec![OpenAiChunkChoice {
+                            index: 0,
+                            delta: OpenAiDelta {
+                                role: None,
+                                content: None,
+                                tool_calls: Some(deltas),
+                            },
+                            finish_reason: None,
+                        }],
+                    };
+                    let data = serde_json::to_string(&chunk).unwrap_or_default();
+                    let _ = tx.send(Ok(Event::default().data(data))).await;
+                }
+
+                // Final chunk with finish_reason
+                send_finish_chunk(&tx, &id, created, &model_name, resp.finish_reason).await;
+            }
+            LlmResult::Simple(resp) => {
+                stream_content_chunks(&tx, &id, created, &model_name, &resp.content).await;
+                send_finish_chunk(&tx, &id, created, &model_name, resp.finish_reason).await;
+            }
+        }
+
+        // Send [DONE] sentinel
+        let _ = tx.send(Ok(Event::default().data("[DONE]"))).await;
+    });
+
+    let stream = tokio_stream::wrappers::ReceiverStream::new(rx);
+    let sse = Sse::new(stream).keep_alive(KeepAlive::new().text(""));
+    let mut response = sse.into_response();
+    response.headers_mut().insert(
+        "x-ironclaw-streaming",
+        HeaderValue::from_static("simulated"),
+    );
+    Ok(response)
+}
+
+/// Split content into word-boundary chunks and send as SSE events.
+async fn stream_content_chunks(
+    tx: &tokio::sync::mpsc::Sender>,
+    id: &str,
+    created: u64,
+    model: &str,
+    content: &str,
+) {
+    // Split on word boundaries, grouping ~20 chars per chunk
+    let mut buf = String::new();
+    for word in content.split_inclusive(char::is_whitespace) {
+        buf.push_str(word);
+        if buf.len() >= 20 {
+            let chunk = OpenAiChatChunk {
+                id: id.to_string(),
+                object: "chat.completion.chunk",
+                created,
+                model: model.to_string(),
+                choices: vec![OpenAiChunkChoice {
+                    index: 0,
+                    delta: OpenAiDelta {
+                        role: None,
+                        content: Some(buf.clone()),
+                        tool_calls: None,
+                    },
+                    finish_reason: None,
+                }],
+            };
+            let data = serde_json::to_string(&chunk).unwrap_or_default();
+            if tx.send(Ok(Event::default().data(data))).await.is_err() {
+                return;
+            }
+            buf.clear();
+        }
+    }
+    // Flush remaining
+    if !buf.is_empty() {
+        let chunk = OpenAiChatChunk {
+            id: id.to_string(),
+            object: "chat.completion.chunk",
+            created,
+            model: model.to_string(),
+            choices: vec![OpenAiChunkChoice {
+                index: 0,
+                delta: OpenAiDelta {
+                    role: None,
+                    content: Some(buf),
+                    tool_calls: None,
+                },
+                finish_reason: None,
+            }],
+        };
+        let data = serde_json::to_string(&chunk).unwrap_or_default();
+        let _ = tx.send(Ok(Event::default().data(data))).await;
+    }
+}
+
+async fn send_finish_chunk(
+    tx: &tokio::sync::mpsc::Sender>,
+    id: &str,
+    created: u64,
+    model: &str,
+    reason: FinishReason,
+) {
+    let chunk = OpenAiChatChunk {
+        id: id.to_string(),
+        object: "chat.completion.chunk",
+        created,
+        model: model.to_string(),
+        choices: vec![OpenAiChunkChoice {
+            index: 0,
+            delta: OpenAiDelta {
+                role: None,
+                content: None,
+                tool_calls: None,
+            },
+            finish_reason: Some(finish_reason_str(reason)),
+        }],
+    };
+    let data = serde_json::to_string(&chunk).unwrap_or_default();
+    let _ = tx.send(Ok(Event::default().data(data))).await;
+}
+
+pub async fn models_handler(
+    State(state): State>,
+) -> Result, (StatusCode, Json)> {
+    let llm = state.llm_provider.as_ref().ok_or_else(|| {
+        openai_error(
+            StatusCode::SERVICE_UNAVAILABLE,
+            "LLM provider not configured",
+            "server_error",
+        )
+    })?;
+
+    let model_name = llm.active_model_name();
+    let created = unix_timestamp();
+
+    // Try to fetch available models from the provider
+    let models = match llm.list_models().await {
+        Ok(names) if !names.is_empty() => names
+            .into_iter()
+            .map(|name| {
+                serde_json::json!({
+                    "id": name,
+                    "object": "model",
+                    "created": created,
+                    "owned_by": "ironclaw"
+                })
+            })
+            .collect(),
+        Ok(_) => {
+            // Empty list: fall back to active model
+            vec![serde_json::json!({
+                "id": model_name,
+                "object": "model",
+                "created": created,
+                "owned_by": "ironclaw"
+            })]
+        }
+        Err(e) => return Err(map_llm_error(e)),
+    };
+
+    Ok(Json(serde_json::json!({
+        "object": "list",
+        "data": models
+    })))
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_parse_role() {
+        assert_eq!(parse_role("system").unwrap(), Role::System);
+        assert_eq!(parse_role("user").unwrap(), Role::User);
+        assert_eq!(parse_role("assistant").unwrap(), Role::Assistant);
+        assert_eq!(parse_role("tool").unwrap(), Role::Tool);
+    }
+
+    #[test]
+    fn test_parse_role_unknown_rejected() {
+        let err = parse_role("unknown").unwrap_err();
+        assert!(err.contains("Unknown role"));
+        assert!(err.contains("unknown"));
+    }
+
+    #[test]
+    fn test_finish_reason_str() {
+        assert_eq!(finish_reason_str(FinishReason::Stop), "stop");
+        assert_eq!(finish_reason_str(FinishReason::Length), "length");
+        assert_eq!(finish_reason_str(FinishReason::ToolUse), "tool_calls");
+        assert_eq!(
+            finish_reason_str(FinishReason::ContentFilter),
+            "content_filter"
+        );
+        assert_eq!(finish_reason_str(FinishReason::Unknown), "stop");
+    }
+
+    #[test]
+    fn test_convert_messages_basic() {
+        let msgs = vec![
+            OpenAiMessage {
+                role: "system".to_string(),
+                content: Some("You are helpful.".to_string()),
+                name: None,
+                tool_call_id: None,
+                tool_calls: None,
+            },
+            OpenAiMessage {
+                role: "user".to_string(),
+                content: Some("Hello".to_string()),
+                name: None,
+                tool_call_id: None,
+                tool_calls: None,
+            },
+        ];
+
+        let converted = convert_messages(&msgs).unwrap();
+        assert_eq!(converted.len(), 2);
+        assert_eq!(converted[0].role, Role::System);
+        assert_eq!(converted[0].content, "You are helpful.");
+        assert_eq!(converted[1].role, Role::User);
+        assert_eq!(converted[1].content, "Hello");
+    }
+
+    #[test]
+    fn test_convert_messages_with_tool_results() {
+        let msgs = vec![OpenAiMessage {
+            role: "tool".to_string(),
+            content: Some("42".to_string()),
+            name: Some("calculator".to_string()),
+            tool_call_id: Some("call_123".to_string()),
+            tool_calls: None,
+        }];
+
+        let converted = convert_messages(&msgs).unwrap();
+        assert_eq!(converted.len(), 1);
+        assert_eq!(converted[0].role, Role::Tool);
+        assert_eq!(converted[0].content, "42");
+        assert_eq!(converted[0].tool_call_id.as_deref(), Some("call_123"));
+        assert_eq!(converted[0].name.as_deref(), Some("calculator"));
+    }
+
+    #[test]
+    fn test_convert_tools() {
+        let tools = vec![OpenAiTool {
+            tool_type: "function".to_string(),
+            function: OpenAiFunction {
+                name: "get_weather".to_string(),
+                description: Some("Get weather for a location".to_string()),
+                parameters: Some(serde_json::json!({
+                    "type": "object",
+                    "properties": {
+                        "location": { "type": "string" }
+                    },
+                    "required": ["location"]
+                })),
+            },
+        }];
+
+        let converted = convert_tools(&tools);
+        assert_eq!(converted.len(), 1);
+        assert_eq!(converted[0].name, "get_weather");
+        assert_eq!(converted[0].description, "Get weather for a location");
+    }
+
+    #[test]
+    fn test_convert_tool_calls_to_openai() {
+        let calls = vec![ToolCall {
+            id: "call_abc".to_string(),
+            name: "search".to_string(),
+            arguments: serde_json::json!({"query": "rust"}),
+        }];
+
+        let converted = convert_tool_calls_to_openai(&calls);
+        assert_eq!(converted.len(), 1);
+        assert_eq!(converted[0].id, "call_abc");
+        assert_eq!(converted[0].call_type, "function");
+        assert_eq!(converted[0].function.name, "search");
+        assert!(converted[0].function.arguments.contains("rust"));
+    }
+
+    #[test]
+    fn test_normalize_tool_choice() {
+        // String variant
+        let v = serde_json::json!("auto");
+        assert_eq!(normalize_tool_choice(&v), Some("auto".to_string()));
+
+        // Object with function
+        let v = serde_json::json!({"type": "function", "function": {"name": "foo"}});
+        assert_eq!(normalize_tool_choice(&v), Some("required".to_string()));
+
+        // Object with type only
+        let v = serde_json::json!({"type": "none"});
+        assert_eq!(normalize_tool_choice(&v), Some("none".to_string()));
+
+        // Null
+        let v = serde_json::Value::Null;
+        assert_eq!(normalize_tool_choice(&v), None);
+    }
+
+    #[test]
+    fn test_openai_request_deserialize_minimal() {
+        let json = r#"{"model":"gpt-4","messages":[{"role":"user","content":"Hi"}]}"#;
+        let req: OpenAiChatRequest = serde_json::from_str(json).unwrap();
+        assert_eq!(req.model, "gpt-4");
+        assert_eq!(req.messages.len(), 1);
+        assert_eq!(req.stream, None);
+        assert_eq!(req.temperature, None);
+    }
+
+    #[test]
+    fn test_openai_request_deserialize_streaming() {
+        let json = r#"{"model":"gpt-4","messages":[{"role":"user","content":"Hi"}],"stream":true,"temperature":0.7}"#;
+        let req: OpenAiChatRequest = serde_json::from_str(json).unwrap();
+        assert_eq!(req.stream, Some(true));
+        assert_eq!(req.temperature, Some(0.7));
+    }
+
+    #[test]
+    fn test_openai_response_serialize() {
+        let resp = OpenAiChatResponse {
+            id: "chatcmpl-test".to_string(),
+            object: "chat.completion",
+            created: 1234567890,
+            model: "test-model".to_string(),
+            choices: vec![OpenAiChoice {
+                index: 0,
+                message: OpenAiMessage {
+                    role: "assistant".to_string(),
+                    content: Some("Hello!".to_string()),
+                    name: None,
+                    tool_call_id: None,
+                    tool_calls: None,
+                },
+                finish_reason: "stop".to_string(),
+            }],
+            usage: OpenAiUsage {
+                prompt_tokens: 10,
+                completion_tokens: 5,
+                total_tokens: 15,
+            },
+        };
+
+        let json = serde_json::to_value(&resp).unwrap();
+        assert_eq!(json["object"], "chat.completion");
+        assert_eq!(json["choices"][0]["finish_reason"], "stop");
+        assert_eq!(json["choices"][0]["message"]["content"], "Hello!");
+        assert_eq!(json["usage"]["total_tokens"], 15);
+    }
+
+    #[test]
+    fn test_openai_message_with_null_content() {
+        let json = r#"{"role":"assistant","content":null,"tool_calls":[{"id":"call_1","type":"function","function":{"name":"search","arguments":"{\"q\":\"test\"}"}}]}"#;
+        let msg: OpenAiMessage = serde_json::from_str(json).unwrap();
+        assert_eq!(msg.role, "assistant");
+        assert!(msg.content.is_none());
+        assert!(msg.tool_calls.is_some());
+        assert_eq!(msg.tool_calls.as_ref().unwrap().len(), 1);
+    }
+
+    #[test]
+    fn test_convert_messages_unknown_role_rejected() {
+        let msgs = vec![OpenAiMessage {
+            role: "moderator".to_string(),
+            content: Some("Hi".to_string()),
+            name: None,
+            tool_call_id: None,
+            tool_calls: None,
+        }];
+        let err = convert_messages(&msgs).unwrap_err();
+        assert!(err.contains("messages[0]"));
+        assert!(err.contains("Unknown role"));
+    }
+
+    #[test]
+    fn test_convert_messages_tool_missing_fields() {
+        // Missing tool_call_id
+        let msgs = vec![OpenAiMessage {
+            role: "tool".to_string(),
+            content: Some("result".to_string()),
+            name: Some("calc".to_string()),
+            tool_call_id: None,
+            tool_calls: None,
+        }];
+        let err = convert_messages(&msgs).unwrap_err();
+        assert!(err.contains("tool_call_id"));
+
+        // Missing name
+        let msgs = vec![OpenAiMessage {
+            role: "tool".to_string(),
+            content: Some("result".to_string()),
+            name: None,
+            tool_call_id: Some("call_1".to_string()),
+            tool_calls: None,
+        }];
+        let err = convert_messages(&msgs).unwrap_err();
+        assert!(err.contains("'name'"));
+    }
+
+    #[test]
+    fn test_parse_stop_string() {
+        let v = serde_json::json!("STOP");
+        assert_eq!(parse_stop(&v), Some(vec!["STOP".to_string()]));
+    }
+
+    #[test]
+    fn test_parse_stop_array() {
+        let v = serde_json::json!(["STOP", "END"]);
+        assert_eq!(
+            parse_stop(&v),
+            Some(vec!["STOP".to_string(), "END".to_string()])
+        );
+    }
+
+    #[test]
+    fn test_parse_stop_null() {
+        let v = serde_json::Value::Null;
+        assert_eq!(parse_stop(&v), None);
+    }
+}
diff --git a/src/channels/web/server.rs b/src/channels/web/server.rs
index 8298a0bb..540b8174 100644
--- a/src/channels/web/server.rs
+++ b/src/channels/web/server.rs
@@ -137,6 +137,8 @@ pub struct GatewayState {
     pub shutdown_tx: tokio::sync::RwLock>>,
     /// WebSocket connection tracker.
     pub ws_tracker: Option>,
+    /// LLM provider for OpenAI-compatible API proxy.
+    pub llm_provider: Option>,
     /// Rate limiter for chat endpoints (30 messages per 60 seconds).
     pub chat_rate_limiter: RateLimiter,
 }
@@ -235,6 +237,12 @@ pub async fn start_server(
         )
         // Gateway control plane
         .route("/api/gateway/status", get(gateway_status_handler))
+        // OpenAI-compatible API
+        .route(
+            "/v1/chat/completions",
+            post(super::openai_compat::chat_completions_handler),
+        )
+        .route("/v1/models", get(super::openai_compat::models_handler))
         .route_layer(middleware::from_fn_with_state(
             auth_state.clone(),
             auth_middleware,
diff --git a/src/channels/web/ws.rs b/src/channels/web/ws.rs
index 9dde5a15..d6ebc0f0 100644
--- a/src/channels/web/ws.rs
+++ b/src/channels/web/ws.rs
@@ -485,6 +485,7 @@ mod tests {
             user_id: "test".to_string(),
             shutdown_tx: tokio::sync::RwLock::new(None),
             ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
+            llm_provider: None,
             chat_rate_limiter: crate::channels::web::server::RateLimiter::new(30, 60),
         }
     }
diff --git a/src/main.rs b/src/main.rs
index 4d8e08c1..7b981979 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -997,6 +997,7 @@ async fn main() -> anyhow::Result<()> {
         if let Some(ref jm) = container_job_manager {
             gw = gw.with_job_manager(Arc::clone(jm));
         }
+        gw = gw.with_llm_provider(Arc::clone(&llm));
         if config.sandbox.enabled {
             gw = gw.with_prompt_queue(Arc::clone(&prompt_queue));
 
diff --git a/tests/openai_compat_integration.rs b/tests/openai_compat_integration.rs
new file mode 100644
index 00000000..d0927a2c
--- /dev/null
+++ b/tests/openai_compat_integration.rs
@@ -0,0 +1,478 @@
+//! Integration tests for the OpenAI-compatible API endpoints.
+//!
+//! Uses a mock LLM provider so no real API key is needed.
+
+use std::net::SocketAddr;
+use std::sync::Arc;
+use std::time::Duration;
+
+use async_trait::async_trait;
+use rust_decimal::Decimal;
+
+use ironclaw::channels::web::server::{GatewayState, start_server};
+use ironclaw::channels::web::sse::SseManager;
+use ironclaw::channels::web::ws::WsConnectionTracker;
+use ironclaw::error::LlmError;
+use ironclaw::llm::{
+    CompletionRequest, CompletionResponse, FinishReason, LlmProvider, ToolCompletionRequest,
+    ToolCompletionResponse,
+};
+
+const AUTH_TOKEN: &str = "test-openai-token";
+
+// ---------------------------------------------------------------------------
+// Mock LLM provider
+// ---------------------------------------------------------------------------
+
+struct MockLlmProvider;
+
+#[async_trait]
+impl LlmProvider for MockLlmProvider {
+    fn model_name(&self) -> &str {
+        "mock-model-v1"
+    }
+
+    fn cost_per_token(&self) -> (Decimal, Decimal) {
+        (Decimal::ZERO, Decimal::ZERO)
+    }
+
+    async fn complete(&self, req: CompletionRequest) -> Result {
+        // Echo the last user message back
+        let user_msg = req
+            .messages
+            .iter()
+            .rev()
+            .find(|m| m.role == ironclaw::llm::Role::User)
+            .map(|m| m.content.clone())
+            .unwrap_or_else(|| "no user message".to_string());
+
+        Ok(CompletionResponse {
+            content: format!("Mock response to: {}", user_msg),
+            input_tokens: 10,
+            output_tokens: 5,
+            finish_reason: FinishReason::Stop,
+            response_id: None,
+        })
+    }
+
+    async fn complete_with_tools(
+        &self,
+        req: ToolCompletionRequest,
+    ) -> Result {
+        // If tools are provided, return a tool call
+        if let Some(tool) = req.tools.first() {
+            Ok(ToolCompletionResponse {
+                content: None,
+                tool_calls: vec![ironclaw::llm::ToolCall {
+                    id: "call_mock_001".to_string(),
+                    name: tool.name.clone(),
+                    arguments: serde_json::json!({"test": true}),
+                }],
+                input_tokens: 15,
+                output_tokens: 8,
+                finish_reason: FinishReason::ToolUse,
+                response_id: None,
+            })
+        } else {
+            Ok(ToolCompletionResponse {
+                content: Some("No tools available".to_string()),
+                tool_calls: vec![],
+                input_tokens: 10,
+                output_tokens: 4,
+                finish_reason: FinishReason::Stop,
+                response_id: None,
+            })
+        }
+    }
+
+    async fn list_models(&self) -> Result, LlmError> {
+        Ok(vec![
+            "mock-model-v1".to_string(),
+            "mock-model-v2".to_string(),
+        ])
+    }
+}
+
+// ---------------------------------------------------------------------------
+// Test helpers
+// ---------------------------------------------------------------------------
+
+async fn start_test_server() -> (SocketAddr, Arc) {
+    let state = Arc::new(GatewayState {
+        msg_tx: tokio::sync::RwLock::new(None),
+        sse: SseManager::new(),
+        workspace: None,
+        session_manager: None,
+        log_broadcaster: None,
+        extension_manager: None,
+        tool_registry: None,
+        store: None,
+        job_manager: None,
+        prompt_queue: None,
+        user_id: "test-user".to_string(),
+        shutdown_tx: tokio::sync::RwLock::new(None),
+        ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
+        llm_provider: Some(Arc::new(MockLlmProvider)),
+        chat_rate_limiter: ironclaw::channels::web::server::RateLimiter::new(30, 60),
+    });
+
+    let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
+    let bound_addr = start_server(addr, state.clone(), AUTH_TOKEN.to_string())
+        .await
+        .expect("Failed to start test server");
+
+    (bound_addr, state)
+}
+
+fn client() -> reqwest::Client {
+    reqwest::Client::builder()
+        .timeout(Duration::from_secs(10))
+        .build()
+        .unwrap()
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+#[tokio::test]
+async fn test_chat_completions_basic() {
+    let (addr, _state) = start_test_server().await;
+    let url = format!("http://{}/v1/chat/completions", addr);
+
+    let resp = client()
+        .post(&url)
+        .bearer_auth(AUTH_TOKEN)
+        .json(&serde_json::json!({
+            "model": "mock-model-v1",
+            "messages": [
+                {"role": "user", "content": "Hello world"}
+            ]
+        }))
+        .send()
+        .await
+        .unwrap();
+
+    assert_eq!(resp.status(), 200);
+
+    let body: serde_json::Value = resp.json().await.unwrap();
+    assert_eq!(body["object"], "chat.completion");
+    assert_eq!(body["model"], "mock-model-v1");
+    assert_eq!(body["choices"][0]["finish_reason"], "stop");
+
+    let content = body["choices"][0]["message"]["content"].as_str().unwrap();
+    assert!(
+        content.contains("Hello world"),
+        "Expected echo, got: {}",
+        content
+    );
+
+    // Check usage
+    assert_eq!(body["usage"]["prompt_tokens"], 10);
+    assert_eq!(body["usage"]["completion_tokens"], 5);
+    assert_eq!(body["usage"]["total_tokens"], 15);
+}
+
+#[tokio::test]
+async fn test_chat_completions_with_system_message() {
+    let (addr, _state) = start_test_server().await;
+    let url = format!("http://{}/v1/chat/completions", addr);
+
+    let resp = client()
+        .post(&url)
+        .bearer_auth(AUTH_TOKEN)
+        .json(&serde_json::json!({
+            "model": "mock-model-v1",
+            "messages": [
+                {"role": "system", "content": "You are helpful."},
+                {"role": "user", "content": "What is 2+2?"}
+            ],
+            "temperature": 0.5,
+            "max_tokens": 100
+        }))
+        .send()
+        .await
+        .unwrap();
+
+    assert_eq!(resp.status(), 200);
+    let body: serde_json::Value = resp.json().await.unwrap();
+    let content = body["choices"][0]["message"]["content"].as_str().unwrap();
+    assert!(content.contains("2+2"));
+}
+
+#[tokio::test]
+async fn test_chat_completions_with_tools() {
+    let (addr, _state) = start_test_server().await;
+    let url = format!("http://{}/v1/chat/completions", addr);
+
+    let resp = client()
+        .post(&url)
+        .bearer_auth(AUTH_TOKEN)
+        .json(&serde_json::json!({
+            "model": "mock-model-v1",
+            "messages": [
+                {"role": "user", "content": "What's the weather?"}
+            ],
+            "tools": [{
+                "type": "function",
+                "function": {
+                    "name": "get_weather",
+                    "description": "Get the weather",
+                    "parameters": {
+                        "type": "object",
+                        "properties": {
+                            "location": {"type": "string"}
+                        }
+                    }
+                }
+            }]
+        }))
+        .send()
+        .await
+        .unwrap();
+
+    assert_eq!(resp.status(), 200);
+    let body: serde_json::Value = resp.json().await.unwrap();
+
+    assert_eq!(body["choices"][0]["finish_reason"], "tool_calls");
+
+    let tool_calls = &body["choices"][0]["message"]["tool_calls"];
+    assert!(tool_calls.is_array());
+    assert_eq!(tool_calls[0]["id"], "call_mock_001");
+    assert_eq!(tool_calls[0]["type"], "function");
+    assert_eq!(tool_calls[0]["function"]["name"], "get_weather");
+}
+
+#[tokio::test]
+async fn test_chat_completions_streaming() {
+    let (addr, _state) = start_test_server().await;
+    let url = format!("http://{}/v1/chat/completions", addr);
+
+    let resp = client()
+        .post(&url)
+        .bearer_auth(AUTH_TOKEN)
+        .json(&serde_json::json!({
+            "model": "mock-model-v1",
+            "messages": [
+                {"role": "user", "content": "Stream test"}
+            ],
+            "stream": true
+        }))
+        .send()
+        .await
+        .unwrap();
+
+    assert_eq!(resp.status(), 200);
+
+    // Check simulated streaming header
+    assert_eq!(
+        resp.headers()
+            .get("x-ironclaw-streaming")
+            .and_then(|v| v.to_str().ok()),
+        Some("simulated"),
+        "Expected x-ironclaw-streaming: simulated header"
+    );
+
+    let text = resp.text().await.unwrap();
+
+    // Should contain SSE data lines
+    assert!(
+        text.contains("data:"),
+        "Expected SSE data lines, got: {}",
+        text
+    );
+    // Should end with [DONE]
+    assert!(
+        text.contains("[DONE]"),
+        "Expected [DONE] sentinel, got: {}",
+        text
+    );
+    // Should contain the role chunk
+    assert!(
+        text.contains("\"role\":\"assistant\""),
+        "Expected role chunk, got: {}",
+        text
+    );
+
+    // Collect all content from the chunks
+    let mut full_content = String::new();
+    for line in text.lines() {
+        if let Some(data) = line.strip_prefix("data:") {
+            let data = data.trim();
+            if data == "[DONE]" {
+                continue;
+            }
+            if let Ok(chunk) = serde_json::from_str::(data) {
+                if let Some(content) = chunk["choices"][0]["delta"]["content"].as_str() {
+                    full_content.push_str(content);
+                }
+            }
+        }
+    }
+    assert!(
+        full_content.contains("Stream test"),
+        "Expected reassembled content to contain 'Stream test', got: '{}'",
+        full_content
+    );
+}
+
+#[tokio::test]
+async fn test_chat_completions_empty_messages() {
+    let (addr, _state) = start_test_server().await;
+    let url = format!("http://{}/v1/chat/completions", addr);
+
+    let resp = client()
+        .post(&url)
+        .bearer_auth(AUTH_TOKEN)
+        .json(&serde_json::json!({
+            "model": "mock-model-v1",
+            "messages": []
+        }))
+        .send()
+        .await
+        .unwrap();
+
+    assert_eq!(resp.status(), 400);
+    let body: serde_json::Value = resp.json().await.unwrap();
+    assert!(body["error"]["message"].as_str().unwrap().contains("empty"));
+}
+
+#[tokio::test]
+async fn test_chat_completions_model_mismatch() {
+    let (addr, _state) = start_test_server().await;
+    let url = format!("http://{}/v1/chat/completions", addr);
+
+    let resp = client()
+        .post(&url)
+        .bearer_auth(AUTH_TOKEN)
+        .json(&serde_json::json!({
+            "model": "gpt-4",
+            "messages": [{"role": "user", "content": "Hi"}]
+        }))
+        .send()
+        .await
+        .unwrap();
+
+    assert_eq!(resp.status(), 404);
+    let body: serde_json::Value = resp.json().await.unwrap();
+    assert_eq!(body["error"]["code"], "model_not_found");
+    assert!(
+        body["error"]["message"]
+            .as_str()
+            .unwrap()
+            .contains("mock-model-v1")
+    );
+}
+
+#[tokio::test]
+async fn test_chat_completions_no_auth() {
+    let (addr, _state) = start_test_server().await;
+    let url = format!("http://{}/v1/chat/completions", addr);
+
+    let resp = client()
+        .post(&url)
+        // No auth header
+        .json(&serde_json::json!({
+            "model": "mock-model-v1",
+            "messages": [{"role": "user", "content": "Hi"}]
+        }))
+        .send()
+        .await
+        .unwrap();
+
+    assert_eq!(resp.status(), 401);
+}
+
+#[tokio::test]
+async fn test_models_endpoint() {
+    let (addr, _state) = start_test_server().await;
+    let url = format!("http://{}/v1/models", addr);
+
+    let resp = client()
+        .get(&url)
+        .bearer_auth(AUTH_TOKEN)
+        .send()
+        .await
+        .unwrap();
+
+    assert_eq!(resp.status(), 200);
+    let body: serde_json::Value = resp.json().await.unwrap();
+
+    assert_eq!(body["object"], "list");
+    let data = body["data"].as_array().unwrap();
+    assert_eq!(data.len(), 2);
+    assert_eq!(data[0]["id"], "mock-model-v1");
+    assert_eq!(data[1]["id"], "mock-model-v2");
+    assert_eq!(data[0]["object"], "model");
+}
+
+#[tokio::test]
+async fn test_models_no_auth() {
+    let (addr, _state) = start_test_server().await;
+    let url = format!("http://{}/v1/models", addr);
+
+    let resp = client().get(&url).send().await.unwrap();
+    assert_eq!(resp.status(), 401);
+}
+
+#[tokio::test]
+async fn test_no_llm_provider_returns_503() {
+    // Create state WITHOUT llm_provider
+    let state = Arc::new(GatewayState {
+        msg_tx: tokio::sync::RwLock::new(None),
+        sse: SseManager::new(),
+        workspace: None,
+        session_manager: None,
+        log_broadcaster: None,
+        extension_manager: None,
+        tool_registry: None,
+        store: None,
+        job_manager: None,
+        prompt_queue: None,
+        user_id: "test-user".to_string(),
+        shutdown_tx: tokio::sync::RwLock::new(None),
+        ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
+        llm_provider: None, // No LLM!
+        chat_rate_limiter: ironclaw::channels::web::server::RateLimiter::new(30, 60),
+    });
+
+    let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
+    let bound_addr = start_server(addr, state, AUTH_TOKEN.to_string())
+        .await
+        .unwrap();
+
+    let url = format!("http://{}/v1/chat/completions", bound_addr);
+    let resp = client()
+        .post(&url)
+        .bearer_auth(AUTH_TOKEN)
+        .json(&serde_json::json!({
+            "model": "mock-model-v1",
+            "messages": [{"role": "user", "content": "Hi"}]
+        }))
+        .send()
+        .await
+        .unwrap();
+
+    assert_eq!(resp.status(), 503);
+}
+
+#[tokio::test]
+async fn test_chat_completions_body_too_large() {
+    let (addr, _state) = start_test_server().await;
+    let url = format!("http://{}/v1/chat/completions", addr);
+
+    // Build a payload over 1 MB (the gateway's DefaultBodyLimit)
+    let big_content = "x".repeat(2 * 1024 * 1024);
+    let resp = client()
+        .post(&url)
+        .bearer_auth(AUTH_TOKEN)
+        .json(&serde_json::json!({
+            "model": "mock-model-v1",
+            "messages": [{"role": "user", "content": big_content}]
+        }))
+        .send()
+        .await
+        .unwrap();
+
+    assert_eq!(resp.status(), 413);
+}
diff --git a/tests/ws_gateway_integration.rs b/tests/ws_gateway_integration.rs
index 6888a7f3..d2dbd756 100644
--- a/tests/ws_gateway_integration.rs
+++ b/tests/ws_gateway_integration.rs
@@ -51,6 +51,7 @@ async fn start_test_server() -> (
         user_id: "test-user".to_string(),
         shutdown_tx: tokio::sync::RwLock::new(None),
         ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
+        llm_provider: None,
         chat_rate_limiter: ironclaw::channels::web::server::RateLimiter::new(30, 60),
     });
 

From 5df0d13b59d50288c5c9a3848e3896423c5c9cce Mon Sep 17 00:00:00 2001
From: Zaki Manian 
Date: Fri, 13 Feb 2026 10:21:50 -0800
Subject: [PATCH 24/33] Bump MSRV to 1.92, add GCP deployment files (#40)

* Bump MSRV to 1.92 and add GCP deployment files

rig-core 0.30 uses let_chains (stabilized post-1.87), which breaks
builds on Rust 1.85. Bump rust-version in Cargo.toml and both
Dockerfiles to 1.92 (verified working).

Add cloud deployment scaffolding:
- Dockerfile: multi-stage build for the main agent container
- deploy/cloud-sql-proxy.service: systemd unit for Cloud SQL Auth Proxy
- deploy/ironclaw.service: systemd unit for the IronClaw container
- deploy/setup.sh: VM bootstrap script (Docker, proxy, services)
- deploy/env.example: reference environment configuration

Co-Authored-By: Claude Opus 4.6 

* Address review feedback: harden deploy scaffolding

- Add comment explaining GATEWAY_HOST=0.0.0.0 and when to use 127.0.0.1
- Document /opt/ironclaw ownership model (root-owned, Docker reads as root)
- Switch cloud-sql-proxy service from User=root to DynamicUser=yes

Co-Authored-By: Claude Opus 4.6 

* fix: Resolve clippy lints (Rust 1.93) and fix CI test workflow

- Fix 97 collapsible_if warnings using let-chains syntax (auto-fixed)
- Fix ptr_arg: change &PathBuf to &Path in pairing store functions
- Fix suspicious_open_options: add .truncate(false) to OpenOptions
- Fix too_many_arguments: add clippy allow on execute_status
- Fix unnecessary_unwrap: use if-let in repository.rs hybrid_search
- Gate unused EchoTool with #[cfg(test)]
- Add PairingStore argument to ChannelStoreData::new() test call sites
- Add skip guard for bundled channel test when WASM artifacts unavailable
- Split CI test workflow to exclude PostgreSQL-dependent integration tests

Co-Authored-By: Claude Opus 4.6 

* fix: Address review feedback from ilblackdragon

- Add root check to setup.sh (exits with error if not root)
- Add warning comment to env.example about placeholder passwords
- Dockerfile.worker already uses rust:1.92 (no change needed)
- PR #41 overlap noted; will rebase after #41 merges

Co-Authored-By: Claude Opus 4.6 

* fix: resolve 47 collapsible_if clippy warnings

Collapse nested if statements across the codebase to satisfy
clippy::collapsible_if on Rust 1.93.

Co-Authored-By: Claude Opus 4.6 

---------

Co-authored-by: Claude Opus 4.6 
---
 Cargo.toml                            |   2 +-
 Dockerfile                            |  46 ++++
 Dockerfile.worker                     |   4 +-
 deploy/cloud-sql-proxy.service        |  13 ++
 deploy/env.example                    |  27 +++
 deploy/ironclaw.service               |  20 ++
 deploy/setup.sh                       |  68 ++++++
 src/agent/agent_loop.rs               | 281 ++++++++++++-----------
 src/agent/routine_engine.rs           |   5 +-
 src/agent/self_repair.rs              |  36 +--
 src/agent/session.rs                  |  10 +-
 src/agent/session_manager.rs          |   8 +-
 src/agent/submission.rs               |  27 ++-
 src/agent/worker.rs                   |  10 +-
 src/channels/wasm/wrapper.rs          |  61 +++--
 src/channels/web/auth.rs              |  22 +-
 src/channels/web/openai_compat.rs     |  16 +-
 src/channels/web/server.rs            | 309 +++++++++++++-------------
 src/cli/config.rs                     |   8 +-
 src/cli/tool.rs                       | 129 ++++++-----
 src/config.rs                         |  28 +--
 src/evaluation/success.rs             |  11 +-
 src/extensions/manager.rs             |  54 ++---
 src/llm/nearai.rs                     |  54 ++---
 src/llm/nearai_chat.rs                |   8 +-
 src/llm/reasoning.rs                  |  29 ++-
 src/llm/session.rs                    |  42 ++--
 src/main.rs                           |  62 +++---
 src/orchestrator/api.rs               |  20 +-
 src/orchestrator/job_manager.rs       |  76 +++----
 src/safety/leak_detector.rs           |   8 +-
 src/sandbox/container.rs              |  13 +-
 src/sandbox/proxy/http.rs             |  18 +-
 src/sandbox/proxy/policy.rs           |   9 +-
 src/secrets/keychain.rs               |   2 +-
 src/secrets/store.rs                  |  24 +-
 src/setup/channels.rs                 |  46 ++--
 src/setup/prompts.rs                  |   9 +-
 src/setup/wizard.rs                   |  29 ++-
 src/tools/builder/testing.rs          |  54 ++---
 src/tools/builtin/http.rs             |  12 +-
 src/tools/builtin/job.rs              |  24 +-
 src/tools/builtin/shell.rs            |  12 +-
 src/tools/mcp/auth.rs                 |   8 +-
 src/tools/mcp/client.rs               |  74 +++---
 src/tools/registry.rs                 |   8 +-
 src/tools/wasm/capabilities.rs        |  31 +--
 src/tools/wasm/credential_injector.rs |  21 +-
 src/tools/wasm/wrapper.rs             |  14 +-
 src/worker/claude_bridge.rs           |  18 +-
 src/workspace/mod.rs                  |  26 +--
 src/workspace/search.rs               |  10 +-
 tests/openai_compat_integration.rs    |   8 +-
 53 files changed, 1055 insertions(+), 909 deletions(-)
 create mode 100644 Dockerfile
 create mode 100644 deploy/cloud-sql-proxy.service
 create mode 100644 deploy/env.example
 create mode 100644 deploy/ironclaw.service
 create mode 100755 deploy/setup.sh

diff --git a/Cargo.toml b/Cargo.toml
index bcb9b095..8ae00a3d 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -2,7 +2,7 @@
 name = "ironclaw"
 version = "0.1.3"
 edition = "2024"
-rust-version = "1.85"
+rust-version = "1.92"
 description = "Secure personal AI assistant that protects your data and expands its capabilities on the fly"
 authors = ["NEAR AI "]
 license = "MIT OR Apache-2.0"
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 00000000..34d4d484
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,46 @@
+# Multi-stage Dockerfile for the IronClaw agent (cloud deployment).
+#
+# Build:
+#   docker build --platform linux/amd64 -t ironclaw:latest .
+#
+# Run:
+#   docker run --env-file .env -p 3000:3000 ironclaw:latest
+
+# Stage 1: Build
+FROM rust:1.92-slim-bookworm AS builder
+
+RUN apt-get update && apt-get install -y --no-install-recommends \
+    pkg-config libssl-dev cmake gcc g++ \
+    && rm -rf /var/lib/apt/lists/*
+
+WORKDIR /app
+
+# Copy manifests first for layer caching
+COPY Cargo.toml Cargo.lock ./
+
+# Copy source and build artifacts
+COPY src/ src/
+COPY migrations/ migrations/
+COPY wit/ wit/
+
+RUN cargo build --release --bin ironclaw
+
+# Stage 2: Runtime
+FROM debian:bookworm-slim
+
+RUN apt-get update && apt-get install -y --no-install-recommends \
+    ca-certificates libssl3 \
+    && rm -rf /var/lib/apt/lists/*
+
+COPY --from=builder /app/target/release/ironclaw /usr/local/bin/ironclaw
+COPY --from=builder /app/migrations /app/migrations
+
+# Non-root user
+RUN useradd -m -u 1000 -s /bin/bash ironclaw
+USER ironclaw
+
+EXPOSE 3000
+
+ENV RUST_LOG=ironclaw=info
+
+ENTRYPOINT ["ironclaw"]
diff --git a/Dockerfile.worker b/Dockerfile.worker
index 3909abaa..6c58a5e5 100644
--- a/Dockerfile.worker
+++ b/Dockerfile.worker
@@ -9,7 +9,7 @@
 # The image includes common development tools so workers can build software,
 # run tests, and execute shell commands.
 
-FROM rust:1.85-bookworm AS builder
+FROM rust:1.92-bookworm AS builder
 
 WORKDIR /build
 COPY . .
@@ -40,7 +40,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
 ENV RUSTUP_HOME=/usr/local/rustup \
     CARGO_HOME=/usr/local/cargo \
     PATH=/usr/local/cargo/bin:$PATH
-RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain 1.85.0 \
+RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain 1.92.0 \
     && chmod -R a+r /usr/local/rustup /usr/local/cargo
 
 # Install Claude Code CLI (for claude-bridge mode)
diff --git a/deploy/cloud-sql-proxy.service b/deploy/cloud-sql-proxy.service
new file mode 100644
index 00000000..346a4e72
--- /dev/null
+++ b/deploy/cloud-sql-proxy.service
@@ -0,0 +1,13 @@
+[Unit]
+Description=Cloud SQL Auth Proxy
+After=network.target
+
+[Service]
+Type=simple
+DynamicUser=yes
+ExecStart=/usr/local/bin/cloud-sql-proxy ironclaw-prod:us-central1:ironclaw-db --port=5432
+Restart=always
+RestartSec=5
+
+[Install]
+WantedBy=multi-user.target
diff --git a/deploy/env.example b/deploy/env.example
new file mode 100644
index 00000000..1c7dac9e
--- /dev/null
+++ b/deploy/env.example
@@ -0,0 +1,27 @@
+# WARNING: Replace all CHANGE_ME values before deploying.
+# Do not use placeholder passwords in production.
+DATABASE_URL=postgres://ironclaw:CHANGE_ME@localhost:5432/ironclaw
+
+# NEAR AI
+NEARAI_SESSION_TOKEN=CHANGE_ME
+NEARAI_MODEL=claude-3-5-sonnet-20241022
+NEARAI_BASE_URL=https://cloud-api.near.ai
+NEARAI_AUTH_URL=https://private.near.ai
+NEARAI_API_MODE=chat_completions
+
+# Agent
+AGENT_NAME=ironclaw
+CLI_ENABLED=false
+
+# Web Gateway
+GATEWAY_ENABLED=true
+# 0.0.0.0 binds to all interfaces (required for Docker --network=host).
+# Use 127.0.0.1 if running outside Docker or for local-only access.
+GATEWAY_HOST=0.0.0.0
+GATEWAY_PORT=3000
+GATEWAY_AUTH_TOKEN=CHANGE_ME
+
+# Disabled for initial deploy
+SANDBOX_ENABLED=false
+HEARTBEAT_ENABLED=false
+EMBEDDING_ENABLED=false
diff --git a/deploy/ironclaw.service b/deploy/ironclaw.service
new file mode 100644
index 00000000..b5aa0a4e
--- /dev/null
+++ b/deploy/ironclaw.service
@@ -0,0 +1,20 @@
+[Unit]
+Description=IronClaw AI Assistant
+After=cloud-sql-proxy.service docker.service
+Requires=cloud-sql-proxy.service
+
+[Service]
+Type=simple
+ExecStartPre=/usr/bin/docker pull us-central1-docker.pkg.dev/ironclaw-prod/ironclaw/agent:latest
+ExecStart=/usr/bin/docker run --rm \
+  --name ironclaw \
+  --env-file /opt/ironclaw/.env \
+  --network=host \
+  us-central1-docker.pkg.dev/ironclaw-prod/ironclaw/agent:latest \
+  --no-onboard
+ExecStop=/usr/bin/docker stop ironclaw
+Restart=always
+RestartSec=10
+
+[Install]
+WantedBy=multi-user.target
diff --git a/deploy/setup.sh b/deploy/setup.sh
new file mode 100755
index 00000000..0bec03a0
--- /dev/null
+++ b/deploy/setup.sh
@@ -0,0 +1,68 @@
+#!/usr/bin/env bash
+# VM bootstrap script for IronClaw on GCP Compute Engine.
+#
+# Run on a fresh Debian 12 VM after SSH:
+#   sudo bash setup.sh
+#
+# Prerequisites:
+#   - VM has the ironclaw-vm service account attached
+#   - Cloud SQL Auth Proxy accessible via IAM
+#   - Artifact Registry image pushed
+
+set -euo pipefail
+
+# Must run as root
+if [ "$(id -u)" -ne 0 ]; then
+  echo "ERROR: This script must be run as root (sudo bash setup.sh)"
+  exit 1
+fi
+
+echo "==> Installing Docker"
+apt-get update
+apt-get install -y docker.io
+systemctl enable docker
+systemctl start docker
+
+echo "==> Installing Cloud SQL Auth Proxy"
+curl -fsSL -o /usr/local/bin/cloud-sql-proxy \
+  https://storage.googleapis.com/cloud-sql-connectors/cloud-sql-proxy/v2.14.3/cloud-sql-proxy.linux.amd64
+chmod +x /usr/local/bin/cloud-sql-proxy
+
+echo "==> Installing systemd services"
+cp /tmp/deploy/cloud-sql-proxy.service /etc/systemd/system/
+cp /tmp/deploy/ironclaw.service /etc/systemd/system/
+systemctl daemon-reload
+
+echo "==> Starting Cloud SQL Auth Proxy"
+systemctl enable cloud-sql-proxy
+systemctl start cloud-sql-proxy
+
+echo "==> Configuring Docker registry auth"
+# The VM service account provides Artifact Registry access
+gcloud auth configure-docker us-central1-docker.pkg.dev --quiet
+
+echo "==> Creating config directory"
+# Owned by root, readable only by root. Docker reads --env-file as root
+# before dropping to uid 1000 (ironclaw) inside the container.
+mkdir -p /opt/ironclaw
+chmod 700 /opt/ironclaw
+
+if [ ! -f /opt/ironclaw/.env ]; then
+  echo "WARNING: /opt/ironclaw/.env does not exist."
+  echo "Create it with your configuration before starting IronClaw."
+  echo "See deploy/env.example for the required variables."
+  echo ""
+  echo "Then run: systemctl enable ironclaw && systemctl start ironclaw"
+else
+  chmod 600 /opt/ironclaw/.env
+  echo "==> Starting IronClaw"
+  systemctl enable ironclaw
+  systemctl start ironclaw
+fi
+
+echo "==> Setup complete"
+echo ""
+echo "Verify with:"
+echo "  systemctl status cloud-sql-proxy"
+echo "  systemctl status ironclaw"
+echo "  docker logs ironclaw"
diff --git a/src/agent/agent_loop.rs b/src/agent/agent_loop.rs
index 6c28d95d..9afd7bc2 100644
--- a/src/agent/agent_loop.rs
+++ b/src/agent/agent_loop.rs
@@ -654,19 +654,17 @@ impl Agent {
         }
 
         // Restore response chain from conversation metadata
-        if let Some(store) = self.store() {
-            if let Ok(Some(metadata)) = store.get_conversation_metadata(thread_uuid).await {
-                if let Some(rid) = metadata
-                    .get("last_response_id")
-                    .and_then(|v| v.as_str())
-                    .map(String::from)
-                {
-                    thread.last_response_id = Some(rid.clone());
-                    self.llm()
-                        .seed_response_chain(&thread_uuid.to_string(), rid);
-                    tracing::debug!("Restored response chain for thread {}", thread_uuid);
-                }
-            }
+        if let Some(store) = self.store()
+            && let Ok(Some(metadata)) = store.get_conversation_metadata(thread_uuid).await
+            && let Some(rid) = metadata
+                .get("last_response_id")
+                .and_then(|v| v.as_str())
+                .map(String::from)
+        {
+            thread.last_response_id = Some(rid.clone());
+            self.llm()
+                .seed_response_chain(&thread_uuid.to_string(), rid);
+            tracing::debug!("Restored response chain for thread {}", thread_uuid);
         }
 
         // Insert into session and register with session manager
@@ -954,13 +952,12 @@ impl Agent {
                 return;
             }
 
-            if let Some(ref resp) = response {
-                if let Err(e) = store
+            if let Some(ref resp) = response
+                && let Err(e) = store
                     .add_conversation_message(thread_id, "assistant", resp)
                     .await
-                {
-                    tracing::warn!("Failed to persist assistant message: {}", e);
-                }
+            {
+                tracing::warn!("Failed to persist assistant message: {}", e);
             }
         });
     }
@@ -1058,14 +1055,14 @@ impl Agent {
             // Check if interrupted
             {
                 let sess = session.lock().await;
-                if let Some(thread) = sess.threads.get(&thread_id) {
-                    if thread.state == ThreadState::Interrupted {
-                        return Err(crate::error::JobError::ContextError {
-                            id: thread_id,
-                            reason: "Interrupted".to_string(),
-                        }
-                        .into());
+                if let Some(thread) = sess.threads.get(&thread_id)
+                    && thread.state == ThreadState::Interrupted
+                {
+                    return Err(crate::error::JobError::ContextError {
+                        id: thread_id,
+                        reason: "Interrupted".to_string(),
                     }
+                    .into());
                 }
             }
 
@@ -1140,11 +1137,11 @@ impl Agent {
                     // Record tool calls in the thread
                     {
                         let mut sess = session.lock().await;
-                        if let Some(thread) = sess.threads.get_mut(&thread_id) {
-                            if let Some(turn) = thread.last_turn_mut() {
-                                for tc in &tool_calls {
-                                    turn.record_tool_call(&tc.name, tc.arguments.clone());
-                                }
+                        if let Some(thread) = sess.threads.get_mut(&thread_id)
+                            && let Some(turn) = thread.last_turn_mut()
+                        {
+                            for tc in &tool_calls {
+                                turn.record_tool_call(&tc.name, tc.arguments.clone());
                             }
                         }
                     }
@@ -1152,54 +1149,48 @@ impl Agent {
                     // Execute each tool (with approval checking)
                     for tc in tool_calls {
                         // Check if tool requires approval
-                        if let Some(tool) = self.tools().get(&tc.name).await {
-                            if tool.requires_approval() {
-                                // Check if auto-approved for this session
-                                let mut is_auto_approved = {
-                                    let sess = session.lock().await;
-                                    sess.is_tool_auto_approved(&tc.name)
+                        if let Some(tool) = self.tools().get(&tc.name).await
+                            && tool.requires_approval()
+                        {
+                            // Check if auto-approved for this session
+                            let mut is_auto_approved = {
+                                let sess = session.lock().await;
+                                sess.is_tool_auto_approved(&tc.name)
+                            };
+
+                            // For shell commands, override auto-approval for
+                            // destructive patterns that should always require
+                            // explicit per-invocation approval.
+                            if is_auto_approved
+                                && tc.name == "shell"
+                                && let Some(cmd) = tc
+                                    .arguments
+                                    .as_str()
+                                    .and_then(|s| serde_json::from_str::(s).ok())
+                                    .and_then(|v| {
+                                        v.get("command").and_then(|c| c.as_str().map(String::from))
+                                    })
+                                && crate::tools::builtin::shell::requires_explicit_approval(&cmd)
+                            {
+                                tracing::info!(
+                                    "Shell command '{}' requires explicit approval despite auto-approve",
+                                    cmd.chars().take(80).collect::()
+                                );
+                                is_auto_approved = false;
+                            }
+
+                            if !is_auto_approved {
+                                // Need approval - store pending request and return
+                                let pending = PendingApproval {
+                                    request_id: Uuid::new_v4(),
+                                    tool_name: tc.name.clone(),
+                                    parameters: tc.arguments.clone(),
+                                    description: tool.description().to_string(),
+                                    tool_call_id: tc.id.clone(),
+                                    context_messages: context_messages.clone(),
                                 };
 
-                                // For shell commands, override auto-approval for
-                                // destructive patterns that should always require
-                                // explicit per-invocation approval.
-                                if is_auto_approved && tc.name == "shell" {
-                                    if let Some(cmd) = tc
-                                        .arguments
-                                        .as_str()
-                                        .and_then(|s| {
-                                            serde_json::from_str::(s).ok()
-                                        })
-                                        .and_then(|v| {
-                                            v.get("command")
-                                                .and_then(|c| c.as_str().map(String::from))
-                                        })
-                                    {
-                                        if crate::tools::builtin::shell::requires_explicit_approval(
-                                            &cmd,
-                                        ) {
-                                            tracing::info!(
-                                                "Shell command '{}' requires explicit approval despite auto-approve",
-                                                cmd.chars().take(80).collect::()
-                                            );
-                                            is_auto_approved = false;
-                                        }
-                                    }
-                                }
-
-                                if !is_auto_approved {
-                                    // Need approval - store pending request and return
-                                    let pending = PendingApproval {
-                                        request_id: Uuid::new_v4(),
-                                        tool_name: tc.name.clone(),
-                                        parameters: tc.arguments.clone(),
-                                        description: tool.description().to_string(),
-                                        tool_call_id: tc.id.clone(),
-                                        context_messages: context_messages.clone(),
-                                    };
-
-                                    return Ok(AgenticLoopResult::NeedApproval { pending });
-                                }
+                                return Ok(AgenticLoopResult::NeedApproval { pending });
                             }
                         }
 
@@ -1230,34 +1221,34 @@ impl Agent {
                             )
                             .await;
 
-                        if let Ok(ref output) = tool_result {
-                            if !output.is_empty() {
-                                let _ = self
-                                    .channels
-                                    .send_status(
-                                        &message.channel,
-                                        StatusUpdate::ToolResult {
-                                            name: tc.name.clone(),
-                                            preview: truncate_for_preview(output, 200),
-                                        },
-                                        &message.metadata,
-                                    )
-                                    .await;
-                            }
+                        if let Ok(ref output) = tool_result
+                            && !output.is_empty()
+                        {
+                            let _ = self
+                                .channels
+                                .send_status(
+                                    &message.channel,
+                                    StatusUpdate::ToolResult {
+                                        name: tc.name.clone(),
+                                        preview: truncate_for_preview(output, 200),
+                                    },
+                                    &message.metadata,
+                                )
+                                .await;
                         }
 
                         // Record result in thread
                         {
                             let mut sess = session.lock().await;
-                            if let Some(thread) = sess.threads.get_mut(&thread_id) {
-                                if let Some(turn) = thread.last_turn_mut() {
-                                    match &tool_result {
-                                        Ok(output) => {
-                                            turn.record_tool_result(serde_json::json!(output));
-                                        }
-                                        Err(e) => {
-                                            turn.record_tool_error(e.to_string());
-                                        }
+                            if let Some(thread) = sess.threads.get_mut(&thread_id)
+                                && let Some(turn) = thread.last_turn_mut()
+                            {
+                                match &tool_result {
+                                    Ok(output) => {
+                                        turn.record_tool_result(serde_json::json!(output));
+                                    }
+                                    Err(e) => {
+                                        turn.record_tool_error(e.to_string());
                                     }
                                 }
                             }
@@ -1640,17 +1631,17 @@ impl Agent {
         };
 
         // Verify request ID if provided
-        if let Some(req_id) = request_id {
-            if req_id != pending.request_id {
-                // Put it back and return error
-                let mut sess = session.lock().await;
-                if let Some(thread) = sess.threads.get_mut(&thread_id) {
-                    thread.await_approval(pending);
-                }
-                return Ok(SubmissionResult::error(
-                    "Request ID mismatch. Use the correct request ID.",
-                ));
+        if let Some(req_id) = request_id
+            && req_id != pending.request_id
+        {
+            // Put it back and return error
+            let mut sess = session.lock().await;
+            if let Some(thread) = sess.threads.get_mut(&thread_id) {
+                thread.await_approval(pending);
             }
+            return Ok(SubmissionResult::error(
+                "Request ID mismatch. Use the correct request ID.",
+            ));
         }
 
         if approved {
@@ -1704,20 +1695,20 @@ impl Agent {
                 )
                 .await;
 
-            if let Ok(ref output) = tool_result {
-                if !output.is_empty() {
-                    let _ = self
-                        .channels
-                        .send_status(
-                            &message.channel,
-                            StatusUpdate::ToolResult {
-                                name: pending.tool_name.clone(),
-                                preview: truncate_for_preview(output, 200),
-                            },
-                            &message.metadata,
-                        )
-                        .await;
-                }
+            if let Ok(ref output) = tool_result
+                && !output.is_empty()
+            {
+                let _ = self
+                    .channels
+                    .send_status(
+                        &message.channel,
+                        StatusUpdate::ToolResult {
+                            name: pending.tool_name.clone(),
+                            preview: truncate_for_preview(output, 200),
+                        },
+                        &message.metadata,
+                    )
+                    .await;
             }
 
             // Build context including the tool result
@@ -1726,15 +1717,15 @@ impl Agent {
             // Record result in thread
             {
                 let mut sess = session.lock().await;
-                if let Some(thread) = sess.threads.get_mut(&thread_id) {
-                    if let Some(turn) = thread.last_turn_mut() {
-                        match &tool_result {
-                            Ok(output) => {
-                                turn.record_tool_result(serde_json::json!(output));
-                            }
-                            Err(e) => {
-                                turn.record_tool_error(e.to_string());
-                            }
+                if let Some(thread) = sess.threads.get_mut(&thread_id)
+                    && let Some(turn) = thread.last_turn_mut()
+                {
+                    match &tool_result {
+                        Ok(output) => {
+                            turn.record_tool_result(serde_json::json!(output));
+                        }
+                        Err(e) => {
+                            turn.record_tool_error(e.to_string());
                         }
                     }
                 }
@@ -2094,15 +2085,15 @@ impl Agent {
         }
 
         // Persist new job to database (fire-and-forget)
-        if let Some(store) = self.store() {
-            if let Ok(ctx) = self.context_manager.get_context(job_id).await {
-                let store = store.clone();
-                tokio::spawn(async move {
-                    if let Err(e) = store.save_job(&ctx).await {
-                        tracing::warn!("Failed to persist new job {}: {}", job_id, e);
-                    }
-                });
-            }
+        if let Some(store) = self.store()
+            && let Ok(ctx) = self.context_manager.get_context(job_id).await
+        {
+            let store = store.clone();
+            tokio::spawn(async move {
+                if let Err(e) = store.save_job(&ctx).await {
+                    tracing::warn!("Failed to persist new job {}: {}", job_id, e);
+                }
+            });
         }
 
         // Schedule for execution
@@ -2182,10 +2173,10 @@ impl Agent {
 
         let mut output = String::from("Jobs:\n");
         for job_id in jobs {
-            if let Ok(ctx) = self.context_manager.get_context(job_id).await {
-                if ctx.user_id == user_id {
-                    output.push_str(&format!("  {} - {} ({:?})\n", job_id, ctx.title, ctx.state));
-                }
+            if let Ok(ctx) = self.context_manager.get_context(job_id).await
+                && ctx.user_id == user_id
+            {
+                output.push_str(&format!("  {} - {} ({:?})\n", job_id, ctx.title, ctx.state));
             }
         }
 
diff --git a/src/agent/routine_engine.rs b/src/agent/routine_engine.rs
index ed4037c9..e88a0a9d 100644
--- a/src/agent/routine_engine.rs
+++ b/src/agent/routine_engine.rs
@@ -103,10 +103,9 @@ impl RoutineEngine {
             if let Trigger::Event {
                 channel: Some(ch), ..
             } = &routine.trigger
+                && ch != &message.channel
             {
-                if ch != &message.channel {
-                    continue;
-                }
+                continue;
             }
 
             // Regex match
diff --git a/src/agent/self_repair.rs b/src/agent/self_repair.rs
index 4d514405..ace75fe8 100644
--- a/src/agent/self_repair.rs
+++ b/src/agent/self_repair.rs
@@ -119,25 +119,25 @@ impl SelfRepair for DefaultSelfRepair {
         let mut stuck_jobs = Vec::new();
 
         for job_id in stuck_ids {
-            if let Ok(ctx) = self.context_manager.get_context(job_id).await {
-                if ctx.state == JobState::Stuck {
-                    let stuck_duration = ctx
-                        .started_at
-                        .map(|start| {
-                            let now = Utc::now();
-                            let duration = now.signed_duration_since(start);
-                            Duration::from_secs(duration.num_seconds().max(0) as u64)
-                        })
-                        .unwrap_or_default();
+            if let Ok(ctx) = self.context_manager.get_context(job_id).await
+                && ctx.state == JobState::Stuck
+            {
+                let stuck_duration = ctx
+                    .started_at
+                    .map(|start| {
+                        let now = Utc::now();
+                        let duration = now.signed_duration_since(start);
+                        Duration::from_secs(duration.num_seconds().max(0) as u64)
+                    })
+                    .unwrap_or_default();
 
-                    stuck_jobs.push(StuckJob {
-                        job_id,
-                        last_activity: ctx.started_at.unwrap_or(ctx.created_at),
-                        stuck_duration,
-                        last_error: None,
-                        repair_attempts: ctx.repair_attempts,
-                    });
-                }
+                stuck_jobs.push(StuckJob {
+                    job_id,
+                    last_activity: ctx.started_at.unwrap_or(ctx.created_at),
+                    stuck_duration,
+                    last_error: None,
+                    repair_attempts: ctx.repair_attempts,
+                });
             }
         }
 
diff --git a/src/agent/session.rs b/src/agent/session.rs
index fbfb4aa3..e77149ec 100644
--- a/src/agent/session.rs
+++ b/src/agent/session.rs
@@ -346,11 +346,11 @@ impl Thread {
                 let mut turn = Turn::new(turn_number, &msg.content);
 
                 // Check if next is assistant response
-                if let Some(next) = iter.peek() {
-                    if next.role == crate::llm::Role::Assistant {
-                        let response = iter.next().expect("peeked");
-                        turn.complete(&response.content);
-                    }
+                if let Some(next) = iter.peek()
+                    && next.role == crate::llm::Role::Assistant
+                {
+                    let response = iter.next().expect("peeked");
+                    turn.complete(&response.content);
                 }
 
                 self.turns.push(turn);
diff --git a/src/agent/session_manager.rs b/src/agent/session_manager.rs
index 2ea6bfa1..db0be886 100644
--- a/src/agent/session_manager.rs
+++ b/src/agent/session_manager.rs
@@ -199,10 +199,10 @@ impl SessionManager {
         {
             let sessions = self.sessions.read().await;
             for user_id in &stale_users {
-                if let Some(session) = sessions.get(user_id) {
-                    if let Ok(sess) = session.try_lock() {
-                        stale_thread_ids.extend(sess.threads.keys());
-                    }
+                if let Some(session) = sessions.get(user_id)
+                    && let Ok(sess) = session.try_lock()
+                {
+                    stale_thread_ids.extend(sess.threads.keys());
                 }
             }
         }
diff --git a/src/agent/submission.rs b/src/agent/submission.rs
index 11f86263..a2b6b4d7 100644
--- a/src/agent/submission.rs
+++ b/src/agent/submission.rs
@@ -93,27 +93,26 @@ impl SubmissionParser {
         // /thread  - switch thread
         if let Some(rest) = lower.strip_prefix("/thread ") {
             let rest = rest.trim();
-            if rest != "new" {
-                if let Ok(id) = Uuid::parse_str(rest) {
-                    return Submission::SwitchThread { thread_id: id };
-                }
+            if rest != "new"
+                && let Ok(id) = Uuid::parse_str(rest)
+            {
+                return Submission::SwitchThread { thread_id: id };
             }
         }
 
         // /resume  - resume from checkpoint
-        if let Some(rest) = lower.strip_prefix("/resume ") {
-            if let Ok(id) = Uuid::parse_str(rest.trim()) {
-                return Submission::Resume { checkpoint_id: id };
-            }
+        if let Some(rest) = lower.strip_prefix("/resume ")
+            && let Ok(id) = Uuid::parse_str(rest.trim())
+        {
+            return Submission::Resume { checkpoint_id: id };
         }
 
         // Try structured JSON approval (from web gateway's /api/chat/approval endpoint)
-        if trimmed.starts_with('{') {
-            if let Ok(submission) = serde_json::from_str::(trimmed) {
-                if matches!(submission, Submission::ExecApproval { .. }) {
-                    return submission;
-                }
-            }
+        if trimmed.starts_with('{')
+            && let Ok(submission) = serde_json::from_str::(trimmed)
+            && matches!(submission, Submission::ExecApproval { .. })
+        {
+            return submission;
         }
 
         // Approval responses (simple yes/no/always for pending approvals)
diff --git a/src/agent/worker.rs b/src/agent/worker.rs
index 65455839..bf8ec895 100644
--- a/src/agent/worker.rs
+++ b/src/agent/worker.rs
@@ -227,11 +227,11 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
             }
 
             // Check for cancellation
-            if let Ok(ctx) = self.context_manager().get_context(self.job_id).await {
-                if ctx.state == JobState::Cancelled {
-                    tracing::info!("Worker for job {} detected cancellation", self.job_id);
-                    return Ok(());
-                }
+            if let Ok(ctx) = self.context_manager().get_context(self.job_id).await
+                && ctx.state == JobState::Cancelled
+            {
+                tracing::info!("Worker for job {} detected cancellation", self.job_id);
+                return Ok(());
             }
 
             iteration += 1;
diff --git a/src/channels/wasm/wrapper.rs b/src/channels/wasm/wrapper.rs
index da96255f..5d34e40c 100644
--- a/src/channels/wasm/wrapper.rs
+++ b/src/channels/wasm/wrapper.rs
@@ -134,13 +134,13 @@ impl ChannelStoreData {
         if result.contains('{') && result.contains('}') {
             // Only warn if it looks like an unresolved placeholder (not JSON braces)
             let brace_pattern = regex::Regex::new(r"\{[A-Z_]+\}").ok();
-            if let Some(re) = brace_pattern {
-                if re.is_match(&result) {
-                    tracing::warn!(
-                        context = %context,
-                        "String may contain unresolved credential placeholders"
-                    );
-                }
+            if let Some(re) = brace_pattern
+                && re.is_match(&result)
+            {
+                tracing::warn!(
+                    context = %context,
+                    "String may contain unresolved credential placeholders"
+                );
             }
         }
 
@@ -338,13 +338,13 @@ impl near::agent::channel_host::Host for ChannelStoreData {
 
             // Enforce max response body size to prevent memory exhaustion.
             let max_response = max_response_bytes;
-            if let Some(cl) = response.content_length() {
-                if cl as usize > max_response {
-                    return Err(format!(
-                        "Response body too large: {} bytes exceeds limit of {} bytes",
-                        cl, max_response
-                    ));
-                }
+            if let Some(cl) = response.content_length()
+                && cl as usize > max_response
+            {
+                return Err(format!(
+                    "Response body too large: {} bytes exceeds limit of {} bytes",
+                    cl, max_response
+                ));
             }
             let body = response
                 .bytes()
@@ -1495,8 +1495,8 @@ impl WasmChannel {
                         match result {
                             Ok(emitted_messages) => {
                                 // Process any emitted messages
-                                if !emitted_messages.is_empty() {
-                                    if let Err(e) = Self::dispatch_emitted_messages(
+                                if !emitted_messages.is_empty()
+                                    && let Err(e) = Self::dispatch_emitted_messages(
                                         &channel_name,
                                         emitted_messages,
                                         &message_tx,
@@ -1508,7 +1508,6 @@ impl WasmChannel {
                                             "Failed to dispatch emitted messages from poll"
                                         );
                                     }
-                                }
                             }
                             Err(e) => {
                                 tracing::warn!(
@@ -1738,22 +1737,22 @@ impl Channel for WasmChannel {
         *self.endpoints.write().await = endpoints;
 
         // Start polling if configured
-        if let Some(poll_config) = &config.poll {
-            if poll_config.enabled {
-                let interval = self
-                    .capabilities
-                    .validate_poll_interval(poll_config.interval_ms)
-                    .map_err(|e| ChannelError::StartupFailed {
-                        name: self.name.clone(),
-                        reason: e,
-                    })?;
+        if let Some(poll_config) = &config.poll
+            && poll_config.enabled
+        {
+            let interval = self
+                .capabilities
+                .validate_poll_interval(poll_config.interval_ms)
+                .map_err(|e| ChannelError::StartupFailed {
+                    name: self.name.clone(),
+                    reason: e,
+                })?;
 
-                // Create shutdown channel for polling and store the sender to keep it alive
-                let (poll_shutdown_tx, poll_shutdown_rx) = oneshot::channel();
-                *self.poll_shutdown_tx.write().await = Some(poll_shutdown_tx);
+            // Create shutdown channel for polling and store the sender to keep it alive
+            let (poll_shutdown_tx, poll_shutdown_rx) = oneshot::channel();
+            *self.poll_shutdown_tx.write().await = Some(poll_shutdown_tx);
 
-                self.start_polling(Duration::from_millis(interval as u64), poll_shutdown_rx);
-            }
+            self.start_polling(Duration::from_millis(interval as u64), poll_shutdown_rx);
         }
 
         tracing::info!(
diff --git a/src/channels/web/auth.rs b/src/channels/web/auth.rs
index 34fc9f07..23d1ddfc 100644
--- a/src/channels/web/auth.rs
+++ b/src/channels/web/auth.rs
@@ -25,23 +25,21 @@ pub async fn auth_middleware(
     next: Next,
 ) -> Response {
     // Try Authorization header first (constant-time comparison)
-    if let Some(auth_header) = headers.get("authorization") {
-        if let Ok(value) = auth_header.to_str() {
-            if let Some(token) = value.strip_prefix("Bearer ") {
-                if bool::from(token.as_bytes().ct_eq(auth.token.as_bytes())) {
-                    return next.run(request).await;
-                }
-            }
-        }
+    if let Some(auth_header) = headers.get("authorization")
+        && let Ok(value) = auth_header.to_str()
+        && let Some(token) = value.strip_prefix("Bearer ")
+        && bool::from(token.as_bytes().ct_eq(auth.token.as_bytes()))
+    {
+        return next.run(request).await;
     }
 
     // Fall back to query parameter for SSE EventSource (constant-time comparison)
     if let Some(query) = request.uri().query() {
         for pair in query.split('&') {
-            if let Some(token) = pair.strip_prefix("token=") {
-                if bool::from(token.as_bytes().ct_eq(auth.token.as_bytes())) {
-                    return next.run(request).await;
-                }
+            if let Some(token) = pair.strip_prefix("token=")
+                && bool::from(token.as_bytes().ct_eq(auth.token.as_bytes()))
+            {
+                return next.run(request).await;
             }
         }
     }
diff --git a/src/channels/web/openai_compat.rs b/src/channels/web/openai_compat.rs
index 7185ca7b..b2dfa007 100644
--- a/src/channels/web/openai_compat.rs
+++ b/src/channels/web/openai_compat.rs
@@ -473,10 +473,10 @@ pub async fn chat_completions_handler(
         if let Some(mt) = req.max_tokens {
             tool_req = tool_req.with_max_tokens(mt);
         }
-        if let Some(ref tc) = req.tool_choice {
-            if let Some(choice) = normalize_tool_choice(tc) {
-                tool_req = tool_req.with_tool_choice(choice);
-            }
+        if let Some(ref tc) = req.tool_choice
+            && let Some(choice) = normalize_tool_choice(tc)
+        {
+            tool_req = tool_req.with_tool_choice(choice);
         }
 
         let resp = llm
@@ -591,10 +591,10 @@ async fn handle_streaming(
         if let Some(mt) = req.max_tokens {
             tool_req = tool_req.with_max_tokens(mt);
         }
-        if let Some(ref tc) = req.tool_choice {
-            if let Some(choice) = normalize_tool_choice(tc) {
-                tool_req = tool_req.with_tool_choice(choice);
-            }
+        if let Some(ref tc) = req.tool_choice
+            && let Some(choice) = normalize_tool_choice(tc)
+        {
+            tool_req = tool_req.with_tool_choice(choice);
         }
         LlmResult::WithTools(
             llm.complete_with_tools(tool_req)
diff --git a/src/channels/web/server.rs b/src/channels/web/server.rs
index 540b8174..68bf6edf 100644
--- a/src/channels/web/server.rs
+++ b/src/channels/web/server.rs
@@ -525,10 +525,10 @@ pub async fn clear_auth_mode(state: &GatewayState) {
     if let Some(ref sm) = state.session_manager {
         let session = sm.get_or_create_session(&state.user_id).await;
         let mut sess = session.lock().await;
-        if let Some(thread_id) = sess.active_thread {
-            if let Some(thread) = sess.threads.get_mut(&thread_id) {
-                thread.pending_auth = None;
-            }
+        if let Some(thread_id) = sess.active_thread
+            && let Some(thread) = sess.threads.get_mut(&thread_id)
+        {
+            thread.pending_auth = None;
         }
     }
 }
@@ -626,69 +626,69 @@ async fn chat_history_handler(
     // Verify the thread belongs to the authenticated user before returning any data.
     // In-memory threads are already scoped by user via session_manager, but DB
     // lookups could expose another user's conversation if the UUID is guessed.
-    if query.thread_id.is_some() {
-        if let Some(ref store) = state.store {
-            let owned = store
-                .conversation_belongs_to_user(thread_id, &state.user_id)
-                .await
-                .unwrap_or(false);
-            if !owned && !sess.threads.contains_key(&thread_id) {
-                return Err((StatusCode::NOT_FOUND, "Thread not found".to_string()));
-            }
+    if query.thread_id.is_some()
+        && let Some(ref store) = state.store
+    {
+        let owned = store
+            .conversation_belongs_to_user(thread_id, &state.user_id)
+            .await
+            .unwrap_or(false);
+        if !owned && !sess.threads.contains_key(&thread_id) {
+            return Err((StatusCode::NOT_FOUND, "Thread not found".to_string()));
         }
     }
 
     // For paginated requests (before cursor set), always go to DB
-    if before_cursor.is_some() {
-        if let Some(ref store) = state.store {
-            let (messages, has_more) = store
-                .list_conversation_messages_paginated(thread_id, before_cursor, limit as i64)
-                .await
-                .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
+    if before_cursor.is_some()
+        && let Some(ref store) = state.store
+    {
+        let (messages, has_more) = store
+            .list_conversation_messages_paginated(thread_id, before_cursor, limit as i64)
+            .await
+            .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
 
-            let oldest_timestamp = messages.first().map(|m| m.created_at.to_rfc3339());
-            let turns = build_turns_from_db_messages(&messages);
-            return Ok(Json(HistoryResponse {
-                thread_id,
-                turns,
-                has_more,
-                oldest_timestamp,
-            }));
-        }
+        let oldest_timestamp = messages.first().map(|m| m.created_at.to_rfc3339());
+        let turns = build_turns_from_db_messages(&messages);
+        return Ok(Json(HistoryResponse {
+            thread_id,
+            turns,
+            has_more,
+            oldest_timestamp,
+        }));
     }
 
     // Try in-memory first (freshest data for active threads)
-    if let Some(thread) = sess.threads.get(&thread_id) {
-        if !thread.turns.is_empty() {
-            let turns: Vec = thread
-                .turns
-                .iter()
-                .map(|t| TurnInfo {
-                    turn_number: t.turn_number,
-                    user_input: t.user_input.clone(),
-                    response: t.response.clone(),
-                    state: format!("{:?}", t.state),
-                    started_at: t.started_at.to_rfc3339(),
-                    completed_at: t.completed_at.map(|dt| dt.to_rfc3339()),
-                    tool_calls: t
-                        .tool_calls
-                        .iter()
-                        .map(|tc| ToolCallInfo {
-                            name: tc.name.clone(),
-                            has_result: tc.result.is_some(),
-                            has_error: tc.error.is_some(),
-                        })
-                        .collect(),
-                })
-                .collect();
+    if let Some(thread) = sess.threads.get(&thread_id)
+        && !thread.turns.is_empty()
+    {
+        let turns: Vec = thread
+            .turns
+            .iter()
+            .map(|t| TurnInfo {
+                turn_number: t.turn_number,
+                user_input: t.user_input.clone(),
+                response: t.response.clone(),
+                state: format!("{:?}", t.state),
+                started_at: t.started_at.to_rfc3339(),
+                completed_at: t.completed_at.map(|dt| dt.to_rfc3339()),
+                tool_calls: t
+                    .tool_calls
+                    .iter()
+                    .map(|tc| ToolCallInfo {
+                        name: tc.name.clone(),
+                        has_result: tc.result.is_some(),
+                        has_error: tc.error.is_some(),
+                    })
+                    .collect(),
+            })
+            .collect();
 
-            return Ok(Json(HistoryResponse {
-                thread_id,
-                turns,
-                has_more: false,
-                oldest_timestamp: None,
-            }));
-        }
+        return Ok(Json(HistoryResponse {
+            thread_id,
+            turns,
+            has_more: false,
+            oldest_timestamp: None,
+        }));
     }
 
     // Fall back to DB for historical threads not in memory (paginated)
@@ -738,12 +738,12 @@ fn build_turns_from_db_messages(messages: &[crate::history::ConversationMessage]
             };
 
             // Check if next message is an assistant response
-            if let Some(next) = iter.peek() {
-                if next.role == "assistant" {
-                    let assistant_msg = iter.next().expect("peeked");
-                    turn.response = Some(assistant_msg.content.clone());
-                    turn.completed_at = Some(assistant_msg.created_at.to_rfc3339());
-                }
+            if let Some(next) = iter.peek()
+                && next.role == "assistant"
+            {
+                let assistant_msg = iter.next().expect("peeked");
+                turn.response = Some(assistant_msg.content.clone());
+                turn.completed_at = Some(assistant_msg.created_at.to_rfc3339());
             }
 
             // Incomplete turn (user message without response)
@@ -1126,65 +1126,65 @@ async fn jobs_detail_handler(
         .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?;
 
     // Try sandbox job from DB first, scoped to the authenticated user.
-    if let Some(ref store) = state.store {
-        if let Ok(Some(job)) = store.get_sandbox_job(job_id).await {
-            if job.user_id != state.user_id {
-                return Err((StatusCode::NOT_FOUND, "Job not found".to_string()));
-            }
-            let browse_id = std::path::Path::new(&job.project_dir)
-                .file_name()
-                .map(|n| n.to_string_lossy().to_string())
-                .unwrap_or_else(|| job.id.to_string());
-
-            let ui_state = match job.status.as_str() {
-                "creating" => "pending",
-                "running" => "in_progress",
-                s => s,
-            };
-
-            let elapsed_secs = job.started_at.map(|start| {
-                let end = job.completed_at.unwrap_or_else(chrono::Utc::now);
-                (end - start).num_seconds().max(0) as u64
-            });
-
-            // Synthesize transitions from timestamps.
-            let mut transitions = Vec::new();
-            if let Some(started) = job.started_at {
-                transitions.push(TransitionInfo {
-                    from: "creating".to_string(),
-                    to: "running".to_string(),
-                    timestamp: started.to_rfc3339(),
-                    reason: None,
-                });
-            }
-            if let Some(completed) = job.completed_at {
-                transitions.push(TransitionInfo {
-                    from: "running".to_string(),
-                    to: job.status.clone(),
-                    timestamp: completed.to_rfc3339(),
-                    reason: job.failure_reason.clone(),
-                });
-            }
-
-            return Ok(Json(JobDetailResponse {
-                id: job.id,
-                title: job.task.clone(),
-                description: String::new(),
-                state: ui_state.to_string(),
-                user_id: job.user_id.clone(),
-                created_at: job.created_at.to_rfc3339(),
-                started_at: job.started_at.map(|dt| dt.to_rfc3339()),
-                completed_at: job.completed_at.map(|dt| dt.to_rfc3339()),
-                elapsed_secs,
-                project_dir: Some(job.project_dir.clone()),
-                browse_url: Some(format!("/projects/{}/", browse_id)),
-                job_mode: {
-                    let mode = store.get_sandbox_job_mode(job.id).await.ok().flatten();
-                    mode.filter(|m| m != "worker")
-                },
-                transitions,
-            }));
+    if let Some(ref store) = state.store
+        && let Ok(Some(job)) = store.get_sandbox_job(job_id).await
+    {
+        if job.user_id != state.user_id {
+            return Err((StatusCode::NOT_FOUND, "Job not found".to_string()));
         }
+        let browse_id = std::path::Path::new(&job.project_dir)
+            .file_name()
+            .map(|n| n.to_string_lossy().to_string())
+            .unwrap_or_else(|| job.id.to_string());
+
+        let ui_state = match job.status.as_str() {
+            "creating" => "pending",
+            "running" => "in_progress",
+            s => s,
+        };
+
+        let elapsed_secs = job.started_at.map(|start| {
+            let end = job.completed_at.unwrap_or_else(chrono::Utc::now);
+            (end - start).num_seconds().max(0) as u64
+        });
+
+        // Synthesize transitions from timestamps.
+        let mut transitions = Vec::new();
+        if let Some(started) = job.started_at {
+            transitions.push(TransitionInfo {
+                from: "creating".to_string(),
+                to: "running".to_string(),
+                timestamp: started.to_rfc3339(),
+                reason: None,
+            });
+        }
+        if let Some(completed) = job.completed_at {
+            transitions.push(TransitionInfo {
+                from: "running".to_string(),
+                to: job.status.clone(),
+                timestamp: completed.to_rfc3339(),
+                reason: job.failure_reason.clone(),
+            });
+        }
+
+        return Ok(Json(JobDetailResponse {
+            id: job.id,
+            title: job.task.clone(),
+            description: String::new(),
+            state: ui_state.to_string(),
+            user_id: job.user_id.clone(),
+            created_at: job.created_at.to_rfc3339(),
+            started_at: job.started_at.map(|dt| dt.to_rfc3339()),
+            completed_at: job.completed_at.map(|dt| dt.to_rfc3339()),
+            elapsed_secs,
+            project_dir: Some(job.project_dir.clone()),
+            browse_url: Some(format!("/projects/{}/", browse_id)),
+            job_mode: {
+                let mode = store.get_sandbox_job_mode(job.id).await.ok().flatten();
+                mode.filter(|m| m != "worker")
+            },
+            transitions,
+        }));
     }
 
     Err((StatusCode::NOT_FOUND, "Job not found".to_string()))
@@ -1198,35 +1198,35 @@ async fn jobs_cancel_handler(
         .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?;
 
     // Try sandbox job cancellation, scoped to the authenticated user.
-    if let Some(ref store) = state.store {
-        if let Ok(Some(job)) = store.get_sandbox_job(job_id).await {
-            if job.user_id != state.user_id {
-                return Err((StatusCode::NOT_FOUND, "Job not found".to_string()));
-            }
-            if job.status == "running" || job.status == "creating" {
-                // Stop the container if we have a job manager.
-                if let Some(ref jm) = state.job_manager {
-                    if let Err(e) = jm.stop_job(job_id).await {
-                        tracing::warn!(job_id = %job_id, error = %e, "Failed to stop container during cancellation");
-                    }
-                }
-                store
-                    .update_sandbox_job_status(
-                        job_id,
-                        "failed",
-                        Some(false),
-                        Some("Cancelled by user"),
-                        None,
-                        Some(chrono::Utc::now()),
-                    )
-                    .await
-                    .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
-            }
-            return Ok(Json(serde_json::json!({
-                "status": "cancelled",
-                "job_id": job_id,
-            })));
+    if let Some(ref store) = state.store
+        && let Ok(Some(job)) = store.get_sandbox_job(job_id).await
+    {
+        if job.user_id != state.user_id {
+            return Err((StatusCode::NOT_FOUND, "Job not found".to_string()));
         }
+        if job.status == "running" || job.status == "creating" {
+            // Stop the container if we have a job manager.
+            if let Some(ref jm) = state.job_manager
+                && let Err(e) = jm.stop_job(job_id).await
+            {
+                tracing::warn!(job_id = %job_id, error = %e, "Failed to stop container during cancellation");
+            }
+            store
+                .update_sandbox_job_status(
+                    job_id,
+                    "failed",
+                    Some(false),
+                    Some("Cancelled by user"),
+                    None,
+                    Some(chrono::Utc::now()),
+                )
+                .await
+                .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
+        }
+        return Ok(Json(serde_json::json!({
+            "status": "cancelled",
+            "job_id": job_id,
+        })));
     }
 
     Err((StatusCode::NOT_FOUND, "Job not found".to_string()))
@@ -1334,14 +1334,13 @@ async fn jobs_prompt_handler(
         .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?;
 
     // Verify user owns this job.
-    if let Some(ref store) = state.store {
-        if !store
+    if let Some(ref store) = state.store
+        && !store
             .sandbox_job_belongs_to_user(job_id, &state.user_id)
             .await
             .unwrap_or(false)
-        {
-            return Err((StatusCode::NOT_FOUND, "Job not found".to_string()));
-        }
+    {
+        return Err((StatusCode::NOT_FOUND, "Job not found".to_string()));
     }
 
     let content = body
diff --git a/src/cli/config.rs b/src/cli/config.rs
index 894ea710..07788dca 100644
--- a/src/cli/config.rs
+++ b/src/cli/config.rs
@@ -107,10 +107,10 @@ async fn list_settings(
     println!();
 
     for (key, value) in all {
-        if let Some(ref f) = filter {
-            if !key.starts_with(f) {
-                continue;
-            }
+        if let Some(ref f) = filter
+            && !key.starts_with(f)
+        {
+            continue;
         }
 
         let display_value = if value.len() > 60 {
diff --git a/src/cli/tool.rs b/src/cli/tool.rs
index 316ffa2c..f49c3a8d 100644
--- a/src/cli/tool.rs
+++ b/src/cli/tool.rs
@@ -420,11 +420,11 @@ async fn extract_crate_name(cargo_toml: &Path) -> anyhow::Result {
     // Simple TOML parsing for [package] name
     for line in content.lines() {
         let line = line.trim();
-        if line.starts_with("name") {
-            if let Some((_, value)) = line.split_once('=') {
-                let name = value.trim().trim_matches('"').trim_matches('\'');
-                return Ok(name.to_string());
-            }
+        if line.starts_with("name")
+            && let Some((_, value)) = line.split_once('=')
+        {
+            let name = value.trim().trim_matches('"').trim_matches('\'');
+            return Ok(name.to_string());
         }
     }
 
@@ -488,10 +488,10 @@ async fn list_tools(dir: Option, verbose: bool) -> anyhow::Result<()> {
 
             if has_caps {
                 let caps_path = path.with_extension("capabilities.json");
-                if let Ok(content) = fs::read_to_string(&caps_path).await {
-                    if let Ok(caps) = CapabilitiesFile::from_json(&content) {
-                        print_capabilities_summary(&caps);
-                    }
+                if let Ok(content) = fs::read_to_string(&caps_path).await
+                    && let Ok(caps) = CapabilitiesFile::from_json(&content)
+                {
+                    print_capabilities_summary(&caps);
                 }
             }
             println!();
@@ -604,16 +604,16 @@ fn print_capabilities_summary(caps: &CapabilitiesFile) {
         }
     }
 
-    if let Some(ref secrets) = caps.secrets {
-        if !secrets.allowed_names.is_empty() {
-            parts.push(format!("secrets: {}", secrets.allowed_names.len()));
-        }
+    if let Some(ref secrets) = caps.secrets
+        && !secrets.allowed_names.is_empty()
+    {
+        parts.push(format!("secrets: {}", secrets.allowed_names.len()));
     }
 
-    if let Some(ref ws) = caps.workspace {
-        if !ws.allowed_prefixes.is_empty() {
-            parts.push("workspace: read".to_string());
-        }
+    if let Some(ref ws) = caps.workspace
+        && !ws.allowed_prefixes.is_empty()
+    {
+        parts.push("workspace: read".to_string());
     }
 
     if !parts.is_empty() {
@@ -650,30 +650,30 @@ fn print_capabilities_detail(caps: &CapabilitiesFile) {
         }
     }
 
-    if let Some(ref secrets) = caps.secrets {
-        if !secrets.allowed_names.is_empty() {
-            println!("  Secrets (existence check only):");
-            for name in &secrets.allowed_names {
-                println!("    {}", name);
-            }
+    if let Some(ref secrets) = caps.secrets
+        && !secrets.allowed_names.is_empty()
+    {
+        println!("  Secrets (existence check only):");
+        for name in &secrets.allowed_names {
+            println!("    {}", name);
         }
     }
 
-    if let Some(ref tool_invoke) = caps.tool_invoke {
-        if !tool_invoke.aliases.is_empty() {
-            println!("  Tool aliases:");
-            for (alias, real_name) in &tool_invoke.aliases {
-                println!("    {} -> {}", alias, real_name);
-            }
+    if let Some(ref tool_invoke) = caps.tool_invoke
+        && !tool_invoke.aliases.is_empty()
+    {
+        println!("  Tool aliases:");
+        for (alias, real_name) in &tool_invoke.aliases {
+            println!("    {} -> {}", alias, real_name);
         }
     }
 
-    if let Some(ref ws) = caps.workspace {
-        if !ws.allowed_prefixes.is_empty() {
-            println!("  Workspace read prefixes:");
-            for prefix in &ws.allowed_prefixes {
-                println!("    {}", prefix);
-            }
+    if let Some(ref ws) = caps.workspace
+        && !ws.allowed_prefixes.is_empty()
+    {
+        println!("  Workspace read prefixes:");
+        for prefix in &ws.allowed_prefixes {
+            println!("    {}", prefix);
         }
     }
 }
@@ -752,37 +752,36 @@ async fn auth_tool(name: String, dir: Option, user_id: String) -> anyho
     }
 
     // Check for environment variable
-    if let Some(ref env_var) = auth.env_var {
-        if let Ok(token) = std::env::var(env_var) {
-            if !token.is_empty() {
-                println!("  Found {} in environment.", env_var);
-                println!();
+    if let Some(ref env_var) = auth.env_var
+        && let Ok(token) = std::env::var(env_var)
+        && !token.is_empty()
+    {
+        println!("  Found {} in environment.", env_var);
+        println!();
 
-                // Validate if endpoint is provided
-                if let Some(ref validation) = auth.validation_endpoint {
-                    print!("  Validating token...");
-                    std::io::stdout().flush()?;
+        // Validate if endpoint is provided
+        if let Some(ref validation) = auth.validation_endpoint {
+            print!("  Validating token...");
+            std::io::stdout().flush()?;
 
-                    match validate_token(&token, validation, &auth.secret_name).await {
-                        Ok(()) => {
-                            println!(" ✓");
-                        }
-                        Err(e) => {
-                            println!(" ✗");
-                            println!("  Validation failed: {}", e);
-                            println!();
-                            println!("  Falling back to manual entry...");
-                            return auth_tool_manual(&secrets_store, &user_id, &auth).await;
-                        }
-                    }
+            match validate_token(&token, validation, &auth.secret_name).await {
+                Ok(()) => {
+                    println!(" ✓");
+                }
+                Err(e) => {
+                    println!(" ✗");
+                    println!("  Validation failed: {}", e);
+                    println!();
+                    println!("  Falling back to manual entry...");
+                    return auth_tool_manual(&secrets_store, &user_id, &auth).await;
                 }
-
-                // Save the token
-                save_token(&secrets_store, &user_id, &auth, &token).await?;
-                print_success(display_name);
-                return Ok(());
             }
         }
+
+        // Save the token
+        save_token(&secrets_store, &user_id, &auth, &token).await?;
+        print_success(display_name);
+        return Ok(());
     }
 
     // Check for OAuth configuration
@@ -923,9 +922,9 @@ async fn auth_tool_oauth(
             reader.read_line(&mut request_line).await?;
 
             // Parse GET /callback?code=xxx HTTP/1.1
-            if let Some(path) = request_line.split_whitespace().nth(1) {
-                if path.starts_with("/callback") {
-                    if let Some(query) = path.split('?').nth(1) {
+            if let Some(path) = request_line.split_whitespace().nth(1)
+                && path.starts_with("/callback")
+                    && let Some(query) = path.split('?').nth(1) {
                         for param in query.split('&') {
                             let parts: Vec<&str> = param.splitn(2, '=').collect();
                             if parts.len() == 2 && parts[0] == "code" {
@@ -962,8 +961,6 @@ async fn auth_tool_oauth(
                             return Err(anyhow::anyhow!("Authorization denied by user"));
                         }
                     }
-                }
-            }
 
             let response = "HTTP/1.1 404 Not Found\r\n\r\n";
             let _ = socket.write_all(response.as_bytes()).await;
diff --git a/src/config.rs b/src/config.rs
index 6ecd7cc8..285a2914 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -107,13 +107,13 @@ impl TunnelConfig {
         let public_url = optional_env("TUNNEL_URL")?
             .or_else(|| settings.tunnel.public_url.clone().filter(|s| !s.is_empty()));
 
-        if let Some(ref url) = public_url {
-            if !url.starts_with("https://") {
-                return Err(ConfigError::InvalidValue {
-                    key: "TUNNEL_URL".to_string(),
-                    message: "must start with https:// (webhooks require HTTPS)".to_string(),
-                });
-            }
+        if let Some(ref url) = public_url
+            && !url.starts_with("https://")
+        {
+            return Err(ConfigError::InvalidValue {
+                key: "TUNNEL_URL".to_string(),
+                message: "must start with https:// (webhooks require HTTPS)".to_string(),
+            });
         }
 
         Ok(Self { public_url })
@@ -806,13 +806,13 @@ impl SecretsConfig {
 
         let enabled = master_key.is_some();
 
-        if let Some(ref key) = master_key {
-            if key.expose_secret().len() < 32 {
-                return Err(ConfigError::InvalidValue {
-                    key: "SECRETS_MASTER_KEY".to_string(),
-                    message: "must be at least 32 bytes for AES-256-GCM".to_string(),
-                });
-            }
+        if let Some(ref key) = master_key
+            && key.expose_secret().len() < 32
+        {
+            return Err(ConfigError::InvalidValue {
+                key: "SECRETS_MASTER_KEY".to_string(),
+                message: "must be at least 32 bytes for AES-256-GCM".to_string(),
+            });
         }
 
         Ok(Self {
diff --git a/src/evaluation/success.rs b/src/evaluation/success.rs
index 41e55bb8..31c78146 100644
--- a/src/evaluation/success.rs
+++ b/src/evaluation/success.rs
@@ -144,12 +144,11 @@ impl SuccessEvaluator for RuleBasedEvaluator {
 
         // Check for critical errors
         for action in actions.iter().filter(|a| !a.success) {
-            if let Some(ref error) = action.error {
-                if error.to_lowercase().contains("critical")
-                    || error.to_lowercase().contains("fatal")
-                {
-                    issues.push(format!("Critical error in {}: {}", action.tool_name, error));
-                }
+            if let Some(ref error) = action.error
+                && (error.to_lowercase().contains("critical")
+                    || error.to_lowercase().contains("fatal"))
+            {
+                issues.push(format!("Critical error in {}: {}", action.tool_name, error));
             }
         }
 
diff --git a/src/extensions/manager.rs b/src/extensions/manager.rs
index d87b9605..d8ecefc6 100644
--- a/src/extensions/manager.rs
+++ b/src/extensions/manager.rs
@@ -490,13 +490,13 @@ impl ExtensionManager {
         }
 
         // Check Content-Length header before downloading the full body
-        if let Some(len) = response.content_length() {
-            if len as usize > MAX_WASM_SIZE {
-                return Err(ExtensionError::InstallFailed(format!(
-                    "WASM binary too large ({} bytes, max {} bytes)",
-                    len, MAX_WASM_SIZE
-                )));
-            }
+        if let Some(len) = response.content_length()
+            && len as usize > MAX_WASM_SIZE
+        {
+            return Err(ExtensionError::InstallFailed(format!(
+                "WASM binary too large ({} bytes, max {} bytes)",
+                len, MAX_WASM_SIZE
+            )));
         }
 
         let bytes = response
@@ -766,27 +766,27 @@ impl ExtensionManager {
         };
 
         // Check env var first
-        if let Some(ref env_var) = auth.env_var {
-            if let Ok(value) = std::env::var(env_var) {
-                // Store the env var value as a secret
-                let params = CreateSecretParams::new(&auth.secret_name, &value)
-                    .with_provider(name.to_string());
-                self.secrets
-                    .create(&self.user_id, params)
-                    .await
-                    .map_err(|e| ExtensionError::AuthFailed(e.to_string()))?;
+        if let Some(ref env_var) = auth.env_var
+            && let Ok(value) = std::env::var(env_var)
+        {
+            // Store the env var value as a secret
+            let params =
+                CreateSecretParams::new(&auth.secret_name, &value).with_provider(name.to_string());
+            self.secrets
+                .create(&self.user_id, params)
+                .await
+                .map_err(|e| ExtensionError::AuthFailed(e.to_string()))?;
 
-                return Ok(AuthResult {
-                    name: name.to_string(),
-                    kind: ExtensionKind::WasmTool,
-                    auth_url: None,
-                    callback_type: None,
-                    instructions: None,
-                    setup_url: None,
-                    awaiting_token: false,
-                    status: "authenticated".to_string(),
-                });
-            }
+            return Ok(AuthResult {
+                name: name.to_string(),
+                kind: ExtensionKind::WasmTool,
+                auth_url: None,
+                callback_type: None,
+                instructions: None,
+                setup_url: None,
+                awaiting_token: false,
+                status: "authenticated".to_string(),
+            });
         }
 
         // Check if already authenticated
diff --git a/src/llm/nearai.rs b/src/llm/nearai.rs
index 193510bd..ed32d2fc 100644
--- a/src/llm/nearai.rs
+++ b/src/llm/nearai.rs
@@ -209,20 +209,20 @@ impl NearAiProvider {
             data: Option>,
         }
 
-        if let Ok(resp) = serde_json::from_str::(&response_text) {
-            if let Some(entries) = resp.models.or(resp.data) {
-                let models: Vec = entries
-                    .into_iter()
-                    .filter_map(|e| {
-                        e.get_name().map(|name| ModelInfo {
-                            name,
-                            provider: None,
-                        })
+        if let Ok(resp) = serde_json::from_str::(&response_text)
+            && let Some(entries) = resp.models.or(resp.data)
+        {
+            let models: Vec = entries
+                .into_iter()
+                .filter_map(|e| {
+                    e.get_name().map(|name| ModelInfo {
+                        name,
+                        provider: None,
                     })
-                    .collect();
-                if !models.is_empty() {
-                    return Ok(models);
-                }
+                })
+                .collect();
+            if !models.is_empty() {
+                return Ok(models);
             }
         }
 
@@ -694,21 +694,21 @@ impl LlmProvider for NearAiProvider {
                         }
                     }
                 }
-            } else if item.item_type == "function_call" {
-                if let (Some(name), Some(call_id)) = (&item.name, &item.call_id) {
-                    // Parse arguments JSON string into Value
-                    let arguments = item
-                        .arguments
-                        .as_ref()
-                        .and_then(|s| serde_json::from_str(s).ok())
-                        .unwrap_or(serde_json::Value::Object(Default::default()));
+            } else if item.item_type == "function_call"
+                && let (Some(name), Some(call_id)) = (&item.name, &item.call_id)
+            {
+                // Parse arguments JSON string into Value
+                let arguments = item
+                    .arguments
+                    .as_ref()
+                    .and_then(|s| serde_json::from_str(s).ok())
+                    .unwrap_or(serde_json::Value::Object(Default::default()));
 
-                    tool_calls.push(ToolCall {
-                        id: call_id.clone(),
-                        name: name.clone(),
-                        arguments,
-                    });
-                }
+                tool_calls.push(ToolCall {
+                    id: call_id.clone(),
+                    name: name.clone(),
+                    arguments,
+                });
             }
         }
 
diff --git a/src/llm/nearai_chat.rs b/src/llm/nearai_chat.rs
index c51828bb..5ca5e95e 100644
--- a/src/llm/nearai_chat.rs
+++ b/src/llm/nearai_chat.rs
@@ -395,10 +395,10 @@ fn flatten_tool_messages(messages: Vec) -> Vec = Vec::new();
-                if let Some(ref text) = msg.content {
-                    if !text.is_empty() {
-                        parts.push(text.clone());
-                    }
+                if let Some(ref text) = msg.content
+                    && !text.is_empty()
+                {
+                    parts.push(text.clone());
                 }
                 for tc in calls {
                     parts.push(format!(
diff --git a/src/llm/reasoning.rs b/src/llm/reasoning.rs
index 7cc511d8..41d4db1c 100644
--- a/src/llm/reasoning.rs
+++ b/src/llm/reasoning.rs
@@ -581,21 +581,20 @@ fn recover_tool_calls_from_content(
             }
 
             // Try JSON first: {"name":"x","arguments":{}}
-            if let Ok(parsed) = serde_json::from_str::(inner) {
-                if let Some(name) = parsed.get("name").and_then(|v| v.as_str()) {
-                    if tool_names.contains(name) {
-                        let arguments = parsed
-                            .get("arguments")
-                            .cloned()
-                            .unwrap_or(serde_json::Value::Object(Default::default()));
-                        calls.push(ToolCall {
-                            id: format!("recovered_{}", calls.len()),
-                            name: name.to_string(),
-                            arguments,
-                        });
-                        continue;
-                    }
-                }
+            if let Ok(parsed) = serde_json::from_str::(inner)
+                && let Some(name) = parsed.get("name").and_then(|v| v.as_str())
+                && tool_names.contains(name)
+            {
+                let arguments = parsed
+                    .get("arguments")
+                    .cloned()
+                    .unwrap_or(serde_json::Value::Object(Default::default()));
+                calls.push(ToolCall {
+                    id: format!("recovered_{}", calls.len()),
+                    name: name.to_string(),
+                    arguments,
+                });
+                continue;
             }
 
             // Bare tool name (e.g. "tool_list")
diff --git a/src/llm/session.rs b/src/llm/session.rs
index 9b27b650..1350a5ac 100644
--- a/src/llm/session.rs
+++ b/src/llm/session.rs
@@ -83,16 +83,16 @@ impl SessionManager {
         };
 
         // Try to load existing session synchronously during construction
-        if let Ok(data) = std::fs::read_to_string(&manager.config.session_path) {
-            if let Ok(session) = serde_json::from_str::(&data) {
-                // We can't await here, so we use try_write
-                if let Ok(mut guard) = manager.token.try_write() {
-                    *guard = Some(SecretString::from(session.session_token));
-                    tracing::info!(
-                        "Loaded session token from {}",
-                        manager.config.session_path.display()
-                    );
-                }
+        if let Ok(data) = std::fs::read_to_string(&manager.config.session_path)
+            && let Ok(session) = serde_json::from_str::(&data)
+        {
+            // We can't await here, so we use try_write
+            if let Ok(mut guard) = manager.token.try_write() {
+                *guard = Some(SecretString::from(session.session_token));
+                tracing::info!(
+                    "Loaded session token from {}",
+                    manager.config.session_path.display()
+                );
             }
         }
 
@@ -356,8 +356,8 @@ impl SessionManager {
                 })?;
 
                 // Parse GET /auth/callback?token=xxx&session_id=xxx&expires_at=xxx&is_new_user=xxx HTTP/1.1
-                if let Some(path) = request_line.split_whitespace().nth(1) {
-                    if path.starts_with("/auth/callback") {
+                if let Some(path) = request_line.split_whitespace().nth(1)
+                    && path.starts_with("/auth/callback") {
                         // Parse query parameters
                         if let Some(query) = path.split('?').nth(1) {
                             let mut token = None;
@@ -453,7 +453,6 @@ impl SessionManager {
                             }
                         }
                     }
-                }
 
                 // Not the callback we're looking for, send 404
                 let response = "HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n";
@@ -642,15 +641,14 @@ pub async fn create_session_manager(config: SessionConfig) -> Arc anyhow::Result<()> {
     let _ = dotenvy::dotenv();
 
     // Enhanced first-run detection
-    if !cli.no_onboard {
-        if let Some(reason) = check_onboard_needed().await {
-            println!("Onboarding needed: {}", reason);
-            println!();
-            let mut wizard = SetupWizard::new();
-            wizard.run().await?;
-        }
+    if !cli.no_onboard
+        && let Some(reason) = check_onboard_needed().await
+    {
+        println!("Onboarding needed: {}", reason);
+        println!();
+        let mut wizard = SetupWizard::new();
+        wizard.run().await?;
     }
 
     // Load bootstrap config (4 fields that must live on disk)
@@ -801,13 +801,13 @@ async fn main() -> anyhow::Result<()> {
 
                                 // Inject owner_id for Telegram so the bot only responds
                                 // to the bound user account.
-                                if channel_name == "telegram" {
-                                    if let Some(owner_id) = config.channels.telegram_owner_id {
-                                        config_updates.insert(
-                                            "owner_id".to_string(),
-                                            serde_json::json!(owner_id),
-                                        );
-                                    }
+                                if channel_name == "telegram"
+                                    && let Some(owner_id) = config.channels.telegram_owner_id
+                                {
+                                    config_updates.insert(
+                                        "owner_id".to_string(),
+                                        serde_json::json!(owner_id),
+                                    );
                                 }
 
                                 if !config_updates.is_empty() {
@@ -898,23 +898,23 @@ async fn main() -> anyhow::Result<()> {
     // Extract its routes for the unified server; the channel itself just
     // provides the mpsc stream.
     let mut webhook_server_addr: Option = None;
-    if !cli.cli_only {
-        if let Some(ref http_config) = config.channels.http {
-            let http_channel = HttpChannel::new(http_config.clone());
-            webhook_routes.push(http_channel.routes());
-            let (host, port) = http_channel.addr();
-            webhook_server_addr = Some(
-                format!("{}:{}", host, port)
-                    .parse()
-                    .expect("HttpConfig host:port must be a valid SocketAddr"),
-            );
-            channels.add(Box::new(http_channel));
-            tracing::info!(
-                "HTTP channel enabled on {}:{}",
-                http_config.host,
-                http_config.port
-            );
-        }
+    if !cli.cli_only
+        && let Some(ref http_config) = config.channels.http
+    {
+        let http_channel = HttpChannel::new(http_config.clone());
+        webhook_routes.push(http_channel.routes());
+        let (host, port) = http_channel.addr();
+        webhook_server_addr = Some(
+            format!("{}:{}", host, port)
+                .parse()
+                .expect("HttpConfig host:port must be a valid SocketAddr"),
+        );
+        channels.add(Box::new(http_channel));
+        tracing::info!(
+            "HTTP channel enabled on {}:{}",
+            http_config.host,
+            http_config.port
+        );
     }
 
     // Start the unified webhook server if any routes were registered.
diff --git a/src/orchestrator/api.rs b/src/orchestrator/api.rs
index b209403a..8923bc55 100644
--- a/src/orchestrator/api.rs
+++ b/src/orchestrator/api.rs
@@ -339,16 +339,16 @@ async fn get_prompt_handler(
     Path(job_id): Path,
 ) -> Result<(StatusCode, Json), StatusCode> {
     let mut queue = state.prompt_queue.lock().await;
-    if let Some(prompts) = queue.get_mut(&job_id) {
-        if let Some(prompt) = prompts.pop_front() {
-            return Ok((
-                StatusCode::OK,
-                Json(serde_json::json!({
-                    "content": prompt.content,
-                    "done": prompt.done,
-                })),
-            ));
-        }
+    if let Some(prompts) = queue.get_mut(&job_id)
+        && let Some(prompt) = prompts.pop_front()
+    {
+        return Ok((
+            StatusCode::OK,
+            Json(serde_json::json!({
+                "content": prompt.content,
+                "done": prompt.done,
+            })),
+        ));
     }
 
     // Return 204 with an empty body. The Json wrapper requires some value
diff --git a/src/orchestrator/job_manager.rs b/src/orchestrator/job_manager.rs
index 227af6a3..b1d20306 100644
--- a/src/orchestrator/job_manager.rs
+++ b/src/orchestrator/job_manager.rs
@@ -229,17 +229,17 @@ impl ContainerJobManager {
                 .unwrap_or_else(|| PathBuf::from("."))
                 .join(".ironclaw")
                 .join("projects");
-            if let Ok(canonical_base) = projects_base.canonicalize() {
-                if !canonical.starts_with(&canonical_base) {
-                    return Err(OrchestratorError::ContainerCreationFailed {
-                        job_id,
-                        reason: format!(
-                            "project directory {} is outside allowed base {}",
-                            canonical.display(),
-                            canonical_base.display()
-                        ),
-                    });
-                }
+            if let Ok(canonical_base) = projects_base.canonicalize()
+                && !canonical.starts_with(&canonical_base)
+            {
+                return Err(OrchestratorError::ContainerCreationFailed {
+                    job_id,
+                    reason: format!(
+                        "project directory {} is outside allowed base {}",
+                        canonical.display(),
+                        canonical_base.display()
+                    ),
+                });
             }
             binds.push(format!("{}:/workspace:rw", canonical.display()));
             env_vec.push("IRONCLAW_WORKSPACE=/workspace".to_string());
@@ -442,36 +442,36 @@ impl ContainerJobManager {
             let containers = self.containers.read().await;
             containers.get(&job_id).map(|h| h.container_id.clone())
         };
-        if let Some(cid) = container_id {
-            if !cid.is_empty() {
-                match connect_docker().await {
-                    Ok(docker) => {
-                        if let Err(e) = docker
-                            .stop_container(
-                                &cid,
-                                Some(bollard::container::StopContainerOptions { t: 5 }),
-                            )
-                            .await
-                        {
-                            tracing::warn!(job_id = %job_id, error = %e, "Failed to stop completed container");
-                        }
-                        if let Err(e) = docker
-                            .remove_container(
-                                &cid,
-                                Some(bollard::container::RemoveContainerOptions {
-                                    force: true,
-                                    ..Default::default()
-                                }),
-                            )
-                            .await
-                        {
-                            tracing::warn!(job_id = %job_id, error = %e, "Failed to remove completed container");
-                        }
+        if let Some(cid) = container_id
+            && !cid.is_empty()
+        {
+            match connect_docker().await {
+                Ok(docker) => {
+                    if let Err(e) = docker
+                        .stop_container(
+                            &cid,
+                            Some(bollard::container::StopContainerOptions { t: 5 }),
+                        )
+                        .await
+                    {
+                        tracing::warn!(job_id = %job_id, error = %e, "Failed to stop completed container");
                     }
-                    Err(e) => {
-                        tracing::warn!(job_id = %job_id, error = %e, "Failed to connect to Docker for container cleanup");
+                    if let Err(e) = docker
+                        .remove_container(
+                            &cid,
+                            Some(bollard::container::RemoveContainerOptions {
+                                force: true,
+                                ..Default::default()
+                            }),
+                        )
+                        .await
+                    {
+                        tracing::warn!(job_id = %job_id, error = %e, "Failed to remove completed container");
                     }
                 }
+                Err(e) => {
+                    tracing::warn!(job_id = %job_id, error = %e, "Failed to connect to Docker for container cleanup");
+                }
             }
         }
         self.token_store.revoke(job_id).await;
diff --git a/src/safety/leak_detector.rs b/src/safety/leak_detector.rs
index 85e83ad1..6ac6ae00 100644
--- a/src/safety/leak_detector.rs
+++ b/src/safety/leak_detector.rs
@@ -147,10 +147,10 @@ impl LeakDetector {
         // Build prefix matcher for patterns that start with a known prefix
         let mut prefixes = Vec::new();
         for (idx, pattern) in patterns.iter().enumerate() {
-            if let Some(prefix) = extract_literal_prefix(pattern.regex.as_str()) {
-                if prefix.len() >= 3 {
-                    prefixes.push((prefix, idx));
-                }
+            if let Some(prefix) = extract_literal_prefix(pattern.regex.as_str())
+                && prefix.len() >= 3
+            {
+                prefixes.push((prefix, idx));
             }
         }
 
diff --git a/src/sandbox/container.rs b/src/sandbox/container.rs
index da9e9145..1ef0fa0f 100644
--- a/src/sandbox/container.rs
+++ b/src/sandbox/container.rs
@@ -494,10 +494,10 @@ impl ContainerRunner {
 /// 3. `~/.docker/run/docker.sock` (Docker Desktop on macOS)
 pub async fn connect_docker() -> Result {
     // First try bollard defaults (checks DOCKER_HOST, then /var/run/docker.sock)
-    if let Ok(docker) = Docker::connect_with_local_defaults() {
-        if docker.ping().await.is_ok() {
-            return Ok(docker);
-        }
+    if let Ok(docker) = Docker::connect_with_local_defaults()
+        && docker.ping().await.is_ok()
+    {
+        return Ok(docker);
     }
 
     // Try Docker Desktop socket (macOS)
@@ -507,10 +507,9 @@ pub async fn connect_docker() -> Result {
             let sock_str = desktop_sock.to_string_lossy();
             if let Ok(docker) =
                 Docker::connect_with_socket(&sock_str, 120, bollard::API_DEFAULT_VERSION)
+                && docker.ping().await.is_ok()
             {
-                if docker.ping().await.is_ok() {
-                    return Ok(docker);
-                }
+                return Ok(docker);
             }
         }
     }
diff --git a/src/sandbox/proxy/http.rs b/src/sandbox/proxy/http.rs
index 6b0a3e82..3205841b 100644
--- a/src/sandbox/proxy/http.rs
+++ b/src/sandbox/proxy/http.rs
@@ -259,11 +259,11 @@ async fn handle_connect(
 
     let decision = state.decider.decide(&network_req).await;
 
-    if !decision.is_allowed() {
-        if let NetworkDecision::Deny { reason } = decision {
-            tracing::info!("Proxy: blocked CONNECT {} - {}", host, reason);
-            return error_response(StatusCode::FORBIDDEN, reason);
-        }
+    if !decision.is_allowed()
+        && let NetworkDecision::Deny { reason } = decision
+    {
+        tracing::info!("Proxy: blocked CONNECT {} - {}", host, reason);
+        return error_response(StatusCode::FORBIDDEN, reason);
     }
 
     tracing::debug!("Proxy: allowing CONNECT to {}", host);
@@ -294,10 +294,10 @@ async fn forward_request(
 
     // Copy headers (except hop-by-hop headers)
     for (name, value) in req.headers() {
-        if !is_hop_by_hop_header(name.as_str()) {
-            if let Ok(v) = value.to_str() {
-                builder = builder.header(name.as_str(), v);
-            }
+        if !is_hop_by_hop_header(name.as_str())
+            && let Ok(v) = value.to_str()
+        {
+            builder = builder.header(name.as_str(), v);
         }
     }
 
diff --git a/src/sandbox/proxy/policy.rs b/src/sandbox/proxy/policy.rs
index f3d967a0..2e2a45b9 100644
--- a/src/sandbox/proxy/policy.rs
+++ b/src/sandbox/proxy/policy.rs
@@ -109,12 +109,11 @@ impl NetworkPolicyDecider for DefaultPolicyDecider {
     async fn decide(&self, request: &NetworkRequest) -> NetworkDecision {
         // First check if the domain is allowed
         let validation = self.allowlist.is_allowed(&request.host);
-        if !validation.is_allowed() {
-            if let crate::sandbox::proxy::allowlist::DomainValidationResult::Denied(reason) =
+        if !validation.is_allowed()
+            && let crate::sandbox::proxy::allowlist::DomainValidationResult::Denied(reason) =
                 validation
-            {
-                return NetworkDecision::Deny { reason };
-            }
+        {
+            return NetworkDecision::Deny { reason };
         }
 
         // Check if we need to inject credentials
diff --git a/src/secrets/keychain.rs b/src/secrets/keychain.rs
index 1b90a106..7dccc86a 100644
--- a/src/secrets/keychain.rs
+++ b/src/secrets/keychain.rs
@@ -261,7 +261,7 @@ pub use platform::{delete_master_key, get_master_key, has_master_key, store_mast
 
 /// Parse a hex string to bytes.
 fn hex_to_bytes(hex: &str) -> Result, SecretError> {
-    if hex.len() % 2 != 0 {
+    if !hex.len().is_multiple_of(2) {
         return Err(SecretError::KeychainError(
             "Invalid hex string length".to_string(),
         ));
diff --git a/src/secrets/store.rs b/src/secrets/store.rs
index 934b07bf..442bbcb9 100644
--- a/src/secrets/store.rs
+++ b/src/secrets/store.rs
@@ -149,10 +149,10 @@ impl SecretsStore for PostgresSecretsStore {
                 let secret = row_to_secret(&r);
 
                 // Check expiration
-                if let Some(expires_at) = secret.expires_at {
-                    if expires_at < Utc::now() {
-                        return Err(SecretError::Expired);
-                    }
+                if let Some(expires_at) = secret.expires_at
+                    && expires_at < Utc::now()
+                {
+                    return Err(SecretError::Expired);
                 }
 
                 Ok(secret)
@@ -272,10 +272,10 @@ impl SecretsStore for PostgresSecretsStore {
             }
 
             // Simple glob: * matches any suffix
-            if let Some(prefix) = pattern.strip_suffix('*') {
-                if secret_name.starts_with(prefix) {
-                    return Ok(true);
-                }
+            if let Some(prefix) = pattern.strip_suffix('*')
+                && secret_name.starts_with(prefix)
+            {
+                return Ok(true);
             }
         }
 
@@ -430,10 +430,10 @@ pub mod testing {
                 if pattern == secret_name {
                     return Ok(true);
                 }
-                if let Some(prefix) = pattern.strip_suffix('*') {
-                    if secret_name.starts_with(prefix) {
-                        return Ok(true);
-                    }
+                if let Some(prefix) = pattern.strip_suffix('*')
+                    && secret_name.starts_with(prefix)
+                {
+                    return Ok(true);
                 }
             }
             Ok(false)
diff --git a/src/setup/channels.rs b/src/setup/channels.rs
index 7728ff0d..3de5cb23 100644
--- a/src/setup/channels.rs
+++ b/src/setup/channels.rs
@@ -252,32 +252,32 @@ async fn bind_telegram_owner(token: &SecretString) -> Result, String
 
         // Find the first message with a sender
         for update in &body.result {
-            if let Some(ref msg) = update.message {
-                if let Some(ref from) = msg.from {
-                    let display_name = from
-                        .username
-                        .as_ref()
-                        .map(|u| format!("@{}", u))
-                        .unwrap_or_else(|| from.first_name.clone());
+            if let Some(ref msg) = update.message
+                && let Some(ref from) = msg.from
+            {
+                let display_name = from
+                    .username
+                    .as_ref()
+                    .map(|u| format!("@{}", u))
+                    .unwrap_or_else(|| from.first_name.clone());
 
-                    print_success(&format!(
-                        "Received message from {} (ID: {})",
-                        display_name, from.id
-                    ));
+                print_success(&format!(
+                    "Received message from {} (ID: {})",
+                    display_name, from.id
+                ));
 
-                    // Acknowledge the update so it doesn't pile up
-                    let ack_url = format!(
-                        "https://api.telegram.org/bot{}/getUpdates",
-                        token.expose_secret()
-                    );
-                    let _ = client
-                        .get(&ack_url)
-                        .query(&[("offset", &(update.update_id + 1).to_string())])
-                        .send()
-                        .await;
+                // Acknowledge the update so it doesn't pile up
+                let ack_url = format!(
+                    "https://api.telegram.org/bot{}/getUpdates",
+                    token.expose_secret()
+                );
+                let _ = client
+                    .get(&ack_url)
+                    .query(&[("offset", &(update.update_id + 1).to_string())])
+                    .send()
+                    .await;
 
-                    return Ok(Some(from.id));
-                }
+                return Ok(Some(from.id));
             }
         }
     }
diff --git a/src/setup/prompts.rs b/src/setup/prompts.rs
index b07ae090..7d77c245 100644
--- a/src/setup/prompts.rs
+++ b/src/setup/prompts.rs
@@ -54,10 +54,11 @@ pub fn select_one(prompt: &str, options: &[&str]) -> io::Result {
         }
 
         // Parse number
-        if let Ok(num) = input.parse::() {
-            if num >= 1 && num <= options.len() {
-                return Ok(num - 1);
-            }
+        if let Ok(num) = input.parse::()
+            && num >= 1
+            && num <= options.len()
+        {
+            return Ok(num - 1);
         }
 
         writeln!(
diff --git a/src/setup/wizard.rs b/src/setup/wizard.rs
index 8613992b..632d4272 100644
--- a/src/setup/wizard.rs
+++ b/src/setup/wizard.rs
@@ -344,17 +344,17 @@ impl SetupWizard {
     /// Step 3: NEAR AI authentication.
     async fn step_authentication(&mut self) -> Result<(), SetupError> {
         // Check if we already have a session
-        if let Some(ref session) = self.session_manager {
-            if session.has_token().await {
-                print_info("Existing session found. Validating...");
-                match session.ensure_authenticated().await {
-                    Ok(()) => {
-                        print_success("Session valid");
-                        return Ok(());
-                    }
-                    Err(e) => {
-                        print_info(&format!("Session invalid: {}. Re-authenticating...", e));
-                    }
+        if let Some(ref session) = self.session_manager
+            && session.has_token().await
+        {
+            print_info("Existing session found. Validating...");
+            match session.ensure_authenticated().await {
+                Ok(()) => {
+                    print_success("Session valid");
+                    return Ok(());
+                }
+                Err(e) => {
+                    print_info(&format!("Session invalid: {}. Re-authenticating...", e));
                 }
             }
         }
@@ -642,11 +642,10 @@ impl SetupWizard {
             &installed_names,
         )
         .await?
+            && !installed.is_empty()
         {
-            if !installed.is_empty() {
-                print_success(&format!("Installed channels: {}", installed.join(", ")));
-                discovered_channels = discover_wasm_channels(&channels_dir).await;
-            }
+            print_success(&format!("Installed channels: {}", installed.join(", ")));
+            discovered_channels = discover_wasm_channels(&channels_dir).await;
         }
 
         // Determine if we need secrets context
diff --git a/src/tools/builder/testing.rs b/src/tools/builder/testing.rs
index 84fd4f3e..629e0b2d 100644
--- a/src/tools/builder/testing.rs
+++ b/src/tools/builder/testing.rs
@@ -326,20 +326,20 @@ impl TestHarness {
                 }
 
                 // Verify expected output
-                if let Some(ref expected) = test.expected_output {
-                    if &actual != expected {
-                        return TestResult {
-                            name: test.name.clone(),
-                            passed: false,
-                            duration,
-                            error: Some(format!(
-                                "Output mismatch:\nExpected: {}\nActual: {}",
-                                serde_json::to_string_pretty(expected).unwrap_or_default(),
-                                serde_json::to_string_pretty(&actual).unwrap_or_default()
-                            )),
-                            actual_output: Some(actual),
-                        };
-                    }
+                if let Some(ref expected) = test.expected_output
+                    && &actual != expected
+                {
+                    return TestResult {
+                        name: test.name.clone(),
+                        passed: false,
+                        duration,
+                        error: Some(format!(
+                            "Output mismatch:\nExpected: {}\nActual: {}",
+                            serde_json::to_string_pretty(expected).unwrap_or_default(),
+                            serde_json::to_string_pretty(&actual).unwrap_or_default()
+                        )),
+                        actual_output: Some(actual),
+                    };
                 }
 
                 // Verify expected fields
@@ -357,19 +357,19 @@ impl TestHarness {
                             };
                         }
 
-                        if let Some(ref expected_value) = field.value {
-                            if field_value != Some(expected_value) {
-                                return TestResult {
-                                    name: test.name.clone(),
-                                    passed: false,
-                                    duration,
-                                    error: Some(format!(
-                                        "Field '{}' mismatch: expected {:?}, got {:?}",
-                                        field.path, expected_value, field_value
-                                    )),
-                                    actual_output: Some(actual),
-                                };
-                            }
+                        if let Some(ref expected_value) = field.value
+                            && field_value != Some(expected_value)
+                        {
+                            return TestResult {
+                                name: test.name.clone(),
+                                passed: false,
+                                duration,
+                                error: Some(format!(
+                                    "Field '{}' mismatch: expected {:?}, got {:?}",
+                                    field.path, expected_value, field_value
+                                )),
+                                actual_output: Some(actual),
+                            };
                         }
                     }
                 }
diff --git a/src/tools/builtin/http.rs b/src/tools/builtin/http.rs
index c0ac14d0..3312eda5 100644
--- a/src/tools/builtin/http.rs
+++ b/src/tools/builtin/http.rs
@@ -54,12 +54,12 @@ fn validate_url(url: &str) -> Result {
     }
 
     // Check literal IP addresses
-    if let Ok(ip) = host.parse::() {
-        if is_disallowed_ip(&ip) {
-            return Err(ToolError::NotAuthorized(
-                "private or local IPs are not allowed".to_string(),
-            ));
-        }
+    if let Ok(ip) = host.parse::()
+        && is_disallowed_ip(&ip)
+    {
+        return Err(ToolError::NotAuthorized(
+            "private or local IPs are not allowed".to_string(),
+        ));
     }
 
     // Resolve hostname and check all resolved IPs against the blocklist.
diff --git a/src/tools/builtin/job.rs b/src/tools/builtin/job.rs
index 20fda2ad..86552c34 100644
--- a/src/tools/builtin/job.rs
+++ b/src/tools/builtin/job.rs
@@ -157,18 +157,18 @@ impl CreateJobTool {
         });
 
         // Persist the job mode to DB
-        if mode == JobMode::ClaudeCode {
-            if let Some(store) = self.store.clone() {
-                let job_id_copy = job_id;
-                tokio::spawn(async move {
-                    if let Err(e) = store
-                        .update_sandbox_job_mode(job_id_copy, "claude_code")
-                        .await
-                    {
-                        tracing::warn!(job_id = %job_id_copy, "Failed to set job mode: {}", e);
-                    }
-                });
-            }
+        if mode == JobMode::ClaudeCode
+            && let Some(store) = self.store.clone()
+        {
+            let job_id_copy = job_id;
+            tokio::spawn(async move {
+                if let Err(e) = store
+                    .update_sandbox_job_mode(job_id_copy, "claude_code")
+                    .await
+                {
+                    tracing::warn!(job_id = %job_id_copy, "Failed to set job mode: {}", e);
+                }
+            });
         }
 
         // Create the container job with the pre-determined job_id.
diff --git a/src/tools/builtin/shell.rs b/src/tools/builtin/shell.rs
index 9cd125d6..b8b948e4 100644
--- a/src/tools/builtin/shell.rs
+++ b/src/tools/builtin/shell.rs
@@ -343,12 +343,12 @@ impl ShellTool {
 
         // Use sandbox if configured; fail-closed (never silently fall through
         // to unsandboxed execution when sandbox was intended).
-        if let Some(ref sandbox) = self.sandbox {
-            if sandbox.is_initialized() || sandbox.config().enabled {
-                return self
-                    .execute_sandboxed(sandbox, cmd, &cwd, timeout_duration)
-                    .await;
-            }
+        if let Some(ref sandbox) = self.sandbox
+            && (sandbox.is_initialized() || sandbox.config().enabled)
+        {
+            return self
+                .execute_sandboxed(sandbox, cmd, &cwd, timeout_duration)
+                .await;
         }
 
         // Only execute directly when no sandbox was configured at all.
diff --git a/src/tools/mcp/auth.rs b/src/tools/mcp/auth.rs
index 20766dd1..5e3445b6 100644
--- a/src/tools/mcp/auth.rs
+++ b/src/tools/mcp/auth.rs
@@ -539,9 +539,9 @@ pub async fn wait_for_authorization_callback(
                 .map_err(|e| AuthError::Http(e.to_string()))?;
 
             // Parse GET /callback?code=xxx HTTP/1.1
-            if let Some(path) = request_line.split_whitespace().nth(1) {
-                if path.starts_with("/callback") {
-                    if let Some(query) = path.split('?').nth(1) {
+            if let Some(path) = request_line.split_whitespace().nth(1)
+                && path.starts_with("/callback")
+                    && let Some(query) = path.split('?').nth(1) {
                         // Check for error first
                         if query.contains("error=") {
                             let response = "HTTP/1.1 400 Bad Request\r\n\r\nAuthorization denied";
@@ -578,8 +578,6 @@ pub async fn wait_for_authorization_callback(
                             }
                         }
                     }
-                }
-            }
 
             let response = "HTTP/1.1 404 Not Found\r\n\r\n";
             let _ = socket.write_all(response.as_bytes()).await;
diff --git a/src/tools/mcp/client.rs b/src/tools/mcp/client.rs
index e859f173..1da5879d 100644
--- a/src/tools/mcp/client.rs
+++ b/src/tools/mcp/client.rs
@@ -184,10 +184,10 @@ impl McpClient {
             }
 
             // Add Mcp-Session-Id header if we have a session
-            if let Some(ref session_manager) = self.session_manager {
-                if let Some(session_id) = session_manager.get_session_id(&self.server_name).await {
-                    req_builder = req_builder.header("Mcp-Session-Id", session_id);
-                }
+            if let Some(ref session_manager) = self.session_manager
+                && let Some(session_id) = session_manager.get_session_id(&self.server_name).await
+            {
+                req_builder = req_builder.header("Mcp-Session-Id", session_id);
             }
 
             let response = req_builder
@@ -199,29 +199,26 @@ impl McpClient {
             if response.status() == reqwest::StatusCode::UNAUTHORIZED {
                 if attempt == 0 {
                     // Try to refresh the token
-                    if let Some(ref secrets) = self.secrets {
-                        if let Some(ref config) = self.server_config {
-                            tracing::debug!(
-                                "MCP token expired, attempting refresh for '{}'",
-                                self.server_name
-                            );
-                            match refresh_access_token(config, secrets, &self.user_id).await {
-                                Ok(_) => {
-                                    tracing::info!(
-                                        "MCP token refreshed for '{}'",
-                                        self.server_name
-                                    );
-                                    // Continue to next iteration to retry with new token
-                                    continue;
-                                }
-                                Err(e) => {
-                                    tracing::debug!(
-                                        "Token refresh failed for '{}': {}",
-                                        self.server_name,
-                                        e
-                                    );
-                                    // Fall through to return auth error
-                                }
+                    if let Some(ref secrets) = self.secrets
+                        && let Some(ref config) = self.server_config
+                    {
+                        tracing::debug!(
+                            "MCP token expired, attempting refresh for '{}'",
+                            self.server_name
+                        );
+                        match refresh_access_token(config, secrets, &self.user_id).await {
+                            Ok(_) => {
+                                tracing::info!("MCP token refreshed for '{}'", self.server_name);
+                                // Continue to next iteration to retry with new token
+                                continue;
+                            }
+                            Err(e) => {
+                                tracing::debug!(
+                                    "Token refresh failed for '{}': {}",
+                                    self.server_name,
+                                    e
+                                );
+                                // Fall through to return auth error
                             }
                         }
                     }
@@ -245,16 +242,15 @@ impl McpClient {
     /// Parse the HTTP response into an MCP response.
     async fn parse_response(&self, response: reqwest::Response) -> Result {
         // Extract session ID from response header
-        if let Some(ref session_manager) = self.session_manager {
-            if let Some(session_id) = response
+        if let Some(ref session_manager) = self.session_manager
+            && let Some(session_id) = response
                 .headers()
                 .get("Mcp-Session-Id")
                 .and_then(|v| v.to_str().ok())
-            {
-                session_manager
-                    .update_session_id(&self.server_name, Some(session_id.to_string()))
-                    .await;
-            }
+        {
+            session_manager
+                .update_session_id(&self.server_name, Some(session_id.to_string()))
+                .await;
         }
 
         if !response.status().is_success() {
@@ -316,11 +312,11 @@ impl McpClient {
     /// This should be called once per session to establish capabilities.
     pub async fn initialize(&self) -> Result {
         // Check if already initialized
-        if let Some(ref session_manager) = self.session_manager {
-            if session_manager.is_initialized(&self.server_name).await {
-                // Return cached/default capabilities
-                return Ok(InitializeResult::default());
-            }
+        if let Some(ref session_manager) = self.session_manager
+            && session_manager.is_initialized(&self.server_name).await
+        {
+            // Return cached/default capabilities
+            return Ok(InitializeResult::default());
         }
 
         // Ensure we have a session
diff --git a/src/tools/registry.rs b/src/tools/registry.rs
index ffe84aa9..906e048d 100644
--- a/src/tools/registry.rs
+++ b/src/tools/registry.rs
@@ -96,10 +96,10 @@ impl ToolRegistry {
         if let Ok(mut tools) = self.tools.try_write() {
             tools.insert(name.clone(), tool);
             // Mark as built-in so it can't be shadowed later
-            if PROTECTED_TOOL_NAMES.contains(&name.as_str()) {
-                if let Ok(mut builtins) = self.builtin_names.try_write() {
-                    builtins.insert(name.clone());
-                }
+            if PROTECTED_TOOL_NAMES.contains(&name.as_str())
+                && let Ok(mut builtins) = self.builtin_names.try_write()
+            {
+                builtins.insert(name.clone());
             }
             tracing::debug!("Registered tool: {}", name);
         }
diff --git a/src/tools/wasm/capabilities.rs b/src/tools/wasm/capabilities.rs
index 0f4bd5a0..3b43d15c 100644
--- a/src/tools/wasm/capabilities.rs
+++ b/src/tools/wasm/capabilities.rs
@@ -209,10 +209,10 @@ impl EndpointPattern {
         }
 
         // Check path prefix
-        if let Some(ref prefix) = self.path_prefix {
-            if !url_path.starts_with(prefix) {
-                return false;
-            }
+        if let Some(ref prefix) = self.path_prefix
+            && !url_path.starts_with(prefix)
+        {
+            return false;
         }
 
         // Check method
@@ -237,13 +237,14 @@ impl EndpointPattern {
         }
 
         // Support wildcard: *.example.com matches sub.example.com
-        if let Some(suffix) = self.host.strip_prefix("*.") {
-            if url_host.ends_with(suffix) && url_host.len() > suffix.len() {
-                // Ensure there's a dot before the suffix (or it's the whole thing)
-                let prefix = &url_host[..url_host.len() - suffix.len()];
-                if prefix.ends_with('.') || prefix.is_empty() {
-                    return true;
-                }
+        if let Some(suffix) = self.host.strip_prefix("*.")
+            && url_host.ends_with(suffix)
+            && url_host.len() > suffix.len()
+        {
+            // Ensure there's a dot before the suffix (or it's the whole thing)
+            let prefix = &url_host[..url_host.len() - suffix.len()];
+            if prefix.ends_with('.') || prefix.is_empty() {
+                return true;
             }
         }
 
@@ -291,10 +292,10 @@ impl SecretsCapability {
             if pattern == name {
                 return true;
             }
-            if let Some(prefix) = pattern.strip_suffix('*') {
-                if name.starts_with(prefix) {
-                    return true;
-                }
+            if let Some(prefix) = pattern.strip_suffix('*')
+                && name.starts_with(prefix)
+            {
+                return true;
             }
         }
         false
diff --git a/src/tools/wasm/credential_injector.rs b/src/tools/wasm/credential_injector.rs
index 0d878bbb..d2ffdaa8 100644
--- a/src/tools/wasm/credential_injector.rs
+++ b/src/tools/wasm/credential_injector.rs
@@ -158,10 +158,10 @@ impl CredentialInjector {
             if pattern == name {
                 return true;
             }
-            if let Some(prefix) = pattern.strip_suffix('*') {
-                if name.starts_with(prefix) {
-                    return true;
-                }
+            if let Some(prefix) = pattern.strip_suffix('*')
+                && name.starts_with(prefix)
+            {
+                return true;
             }
         }
         false
@@ -214,12 +214,13 @@ fn host_matches_pattern(host: &str, pattern: &str) -> bool {
     }
 
     // Support wildcard: *.example.com matches sub.example.com
-    if let Some(suffix) = pattern.strip_prefix("*.") {
-        if host.ends_with(suffix) && host.len() > suffix.len() {
-            let prefix = &host[..host.len() - suffix.len()];
-            if prefix.ends_with('.') || prefix.is_empty() {
-                return true;
-            }
+    if let Some(suffix) = pattern.strip_prefix("*.")
+        && host.ends_with(suffix)
+        && host.len() > suffix.len()
+    {
+        let prefix = &host[..host.len() - suffix.len()];
+        if prefix.ends_with('.') || prefix.is_empty() {
+            return true;
         }
     }
 
diff --git a/src/tools/wasm/wrapper.rs b/src/tools/wasm/wrapper.rs
index 65050be0..8e516eb6 100644
--- a/src/tools/wasm/wrapper.rs
+++ b/src/tools/wasm/wrapper.rs
@@ -259,13 +259,13 @@ impl near::agent::host::Host for StoreData {
 
             // Check Content-Length header for early rejection of oversized responses.
             let max_response = max_response_bytes;
-            if let Some(cl) = response.content_length() {
-                if cl as usize > max_response {
-                    return Err(format!(
-                        "Response body too large: {} bytes exceeds limit of {} bytes",
-                        cl, max_response
-                    ));
-                }
+            if let Some(cl) = response.content_length()
+                && cl as usize > max_response
+            {
+                return Err(format!(
+                    "Response body too large: {} bytes exceeds limit of {} bytes",
+                    cl, max_response
+                ));
             }
 
             // Read body with a size cap to prevent memory exhaustion.
diff --git a/src/worker/claude_bridge.rs b/src/worker/claude_bridge.rs
index 102d43f1..1fbc1410 100644
--- a/src/worker/claude_bridge.rs
+++ b/src/worker/claude_bridge.rs
@@ -326,15 +326,15 @@ impl ClaudeBridgeRuntime {
             match serde_json::from_str::(&line) {
                 Ok(event) => {
                     // Capture session_id from system init
-                    if event.event_type == "system" {
-                        if let Some(ref sid) = event.session_id {
-                            session_id = Some(sid.clone());
-                            tracing::info!(
-                                job_id = %self.config.job_id,
-                                session_id = %sid,
-                                "Captured Claude session ID"
-                            );
-                        }
+                    if event.event_type == "system"
+                        && let Some(ref sid) = event.session_id
+                    {
+                        session_id = Some(sid.clone());
+                        tracing::info!(
+                            job_id = %self.config.job_id,
+                            session_id = %sid,
+                            "Captured Claude session ID"
+                        );
                     }
 
                     // Convert to our event payload and forward
diff --git a/src/workspace/mod.rs b/src/workspace/mod.rs
index 548b5718..d0f05d9c 100644
--- a/src/workspace/mod.rs
+++ b/src/workspace/mod.rs
@@ -333,10 +333,10 @@ impl Workspace {
         ];
 
         for (path, header) in identity_files {
-            if let Ok(doc) = self.read(path).await {
-                if !doc.content.is_empty() {
-                    parts.push(format!("{}\n\n{}", header, doc.content));
-                }
+            if let Ok(doc) = self.read(path).await
+                && !doc.content.is_empty()
+            {
+                parts.push(format!("{}\n\n{}", header, doc.content));
             }
         }
 
@@ -345,15 +345,15 @@ impl Workspace {
         let yesterday = today.pred_opt().unwrap_or(today);
 
         for date in [today, yesterday] {
-            if let Ok(doc) = self.daily_log(date).await {
-                if !doc.content.is_empty() {
-                    let header = if date == today {
-                        "## Today's Notes"
-                    } else {
-                        "## Yesterday's Notes"
-                    };
-                    parts.push(format!("{}\n\n{}", header, doc.content));
-                }
+            if let Ok(doc) = self.daily_log(date).await
+                && !doc.content.is_empty()
+            {
+                let header = if date == today {
+                    "## Today's Notes"
+                } else {
+                    "## Yesterday's Notes"
+                };
+                parts.push(format!("{}\n\n{}", header, doc.content));
             }
         }
 
diff --git a/src/workspace/search.rs b/src/workspace/search.rs
index 7cee5539..c9bf058d 100644
--- a/src/workspace/search.rs
+++ b/src/workspace/search.rs
@@ -201,11 +201,11 @@ pub fn reciprocal_rank_fusion(
         .collect();
 
     // Normalize scores to 0-1 range
-    if let Some(max_score) = results.iter().map(|r| r.score).reduce(f32::max) {
-        if max_score > 0.0 {
-            for result in &mut results {
-                result.score /= max_score;
-            }
+    if let Some(max_score) = results.iter().map(|r| r.score).reduce(f32::max)
+        && max_score > 0.0
+    {
+        for result in &mut results {
+            result.score /= max_score;
         }
     }
 
diff --git a/tests/openai_compat_integration.rs b/tests/openai_compat_integration.rs
index d0927a2c..f5d97fc5 100644
--- a/tests/openai_compat_integration.rs
+++ b/tests/openai_compat_integration.rs
@@ -302,10 +302,10 @@ async fn test_chat_completions_streaming() {
             if data == "[DONE]" {
                 continue;
             }
-            if let Ok(chunk) = serde_json::from_str::(data) {
-                if let Some(content) = chunk["choices"][0]["delta"]["content"].as_str() {
-                    full_content.push_str(content);
-                }
+            if let Ok(chunk) = serde_json::from_str::(data)
+                && let Some(content) = chunk["choices"][0]["delta"]["content"].as_str()
+            {
+                full_content.push_str(content);
             }
         }
     }

From 54e9206f0bb45d9aa576c7085e4fc42b5c7547be Mon Sep 17 00:00:00 2001
From: Illia Polosukhin 
Date: Fri, 13 Feb 2026 15:24:07 -0800
Subject: [PATCH 25/33] feat: Move debug log truncation from agent loop to REPL
 channel (#65)

* feat: Move debug log truncation from agent loop to REPL channel

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

Co-Authored-By: Claude Opus 4.6 

* feat: truncating fmt layer for terminal, full logs for web gateway

Instead of truncating debug output at each LLM call site (fragile),
use a custom MakeWriter on the fmt layer that caps each tracing event
at 500 bytes before flushing to stderr. The web gateway WebLogLayer
still receives full untruncated content for /api/logs/events SSE.

Co-Authored-By: Claude Opus 4.6 

* fix: UTF-8 safe truncation in truncate_for_preview, remove double truncation

- Use char_indices() instead of byte-based slicing to find the cut
  point, preventing panics on multi-byte characters (emoji, CJK, etc.)
- Remove redundant truncation in REPL channel (agent loop already
  truncates ToolResult previews to 200 chars)
- Add 9 unit tests covering edge cases: empty, exact length, multi-byte
  UTF-8 (emoji, CJK), mixed scripts, newline collapsing, whitespace

Addresses PR #65 review comments.

Co-Authored-By: Claude Opus 4.6 

---------

Co-authored-by: Claude Opus 4.6 
---
 src/agent/agent_loop.rs |  78 +++++++++-
 src/agent/mod.rs        |   1 +
 src/channels/repl.rs    |  10 +-
 src/lib.rs              |   1 +
 src/llm/nearai_chat.rs  |   5 +-
 src/main.rs             |   6 +-
 src/tracing_fmt.rs      | 319 ++++++++++++++++++++++++++++++++++++++++
 7 files changed, 412 insertions(+), 8 deletions(-)
 create mode 100644 src/tracing_fmt.rs

diff --git a/src/agent/agent_loop.rs b/src/agent/agent_loop.rs
index 9afd7bc2..18a7d6fc 100644
--- a/src/agent/agent_loop.rs
+++ b/src/agent/agent_loop.rs
@@ -28,7 +28,7 @@ use crate::tools::ToolRegistry;
 use crate::workspace::Workspace;
 
 /// Collapse a tool output string into a single-line preview for display.
-fn truncate_for_preview(output: &str, max_chars: usize) -> String {
+pub(crate) fn truncate_for_preview(output: &str, max_chars: usize) -> String {
     let collapsed: String = output
         .chars()
         .take(max_chars + 50)
@@ -37,8 +37,14 @@ fn truncate_for_preview(output: &str, max_chars: usize) -> String {
         .split_whitespace()
         .collect::>()
         .join(" ");
-    if collapsed.len() > max_chars {
-        format!("{}...", &collapsed[..max_chars])
+    // char_indices gives us byte offsets at char boundaries, so the slice is always valid UTF-8.
+    if collapsed.chars().count() > max_chars {
+        let byte_offset = collapsed
+            .char_indices()
+            .nth(max_chars)
+            .map(|(i, _)| i)
+            .unwrap_or(collapsed.len());
+        format!("{}...", &collapsed[..byte_offset])
     } else {
         collapsed
     }
@@ -2627,4 +2633,70 @@ mod tests {
 
         assert!(detect_auth_awaiting("tool_activate", &result).is_none());
     }
+
+    // --- truncate_for_preview tests ---
+
+    use super::truncate_for_preview;
+
+    #[test]
+    fn test_truncate_short_input() {
+        assert_eq!(truncate_for_preview("hello", 10), "hello");
+    }
+
+    #[test]
+    fn test_truncate_empty_input() {
+        assert_eq!(truncate_for_preview("", 10), "");
+    }
+
+    #[test]
+    fn test_truncate_exact_length() {
+        assert_eq!(truncate_for_preview("hello", 5), "hello");
+    }
+
+    #[test]
+    fn test_truncate_over_limit() {
+        let result = truncate_for_preview("hello world, this is long", 10);
+        assert!(result.ends_with("..."));
+        // "hello worl" = 10 chars + "..."
+        assert_eq!(result, "hello worl...");
+    }
+
+    #[test]
+    fn test_truncate_collapses_newlines() {
+        let result = truncate_for_preview("line1\nline2\nline3", 100);
+        assert!(!result.contains('\n'));
+        assert_eq!(result, "line1 line2 line3");
+    }
+
+    #[test]
+    fn test_truncate_collapses_whitespace() {
+        let result = truncate_for_preview("hello   world", 100);
+        assert_eq!(result, "hello world");
+    }
+
+    #[test]
+    fn test_truncate_multibyte_utf8() {
+        // Each emoji is 4 bytes. Truncating at char boundary must not panic.
+        let input = "😀😁😂🤣😃😄😅😆😉😊";
+        let result = truncate_for_preview(input, 5);
+        assert!(result.ends_with("..."));
+        // First 5 chars = 5 emoji
+        assert_eq!(result, "😀😁😂🤣😃...");
+    }
+
+    #[test]
+    fn test_truncate_cjk_characters() {
+        // CJK chars are 3 bytes each in UTF-8.
+        let input = "你好世界测试数据很长的字符串";
+        let result = truncate_for_preview(input, 4);
+        assert_eq!(result, "你好世界...");
+    }
+
+    #[test]
+    fn test_truncate_mixed_multibyte_and_ascii() {
+        let input = "hello 世界 foo";
+        let result = truncate_for_preview(input, 8);
+        // 'h','e','l','l','o',' ','世','界' = 8 chars
+        assert_eq!(result, "hello 世界...");
+    }
 }
diff --git a/src/agent/mod.rs b/src/agent/mod.rs
index 1455c92c..a667b17d 100644
--- a/src/agent/mod.rs
+++ b/src/agent/mod.rs
@@ -26,6 +26,7 @@ pub mod task;
 pub mod undo;
 pub mod worker;
 
+pub(crate) use agent_loop::truncate_for_preview;
 pub use agent_loop::{Agent, AgentDeps};
 pub use compaction::{CompactionResult, ContextCompactor};
 pub use context_monitor::{CompactionStrategy, ContextBreakdown, ContextMonitor};
diff --git a/src/channels/repl.rs b/src/channels/repl.rs
index cbfd1c4a..ea91082b 100644
--- a/src/channels/repl.rs
+++ b/src/channels/repl.rs
@@ -33,9 +33,13 @@ use termimad::MadSkin;
 use tokio::sync::mpsc;
 use tokio_stream::wrappers::ReceiverStream;
 
+use crate::agent::truncate_for_preview;
 use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate};
 use crate::error::ChannelError;
 
+/// Max characters for thinking/status messages in the terminal.
+const CLI_STATUS_MAX: usize = 200;
+
 /// Slash commands available in the REPL.
 const SLASH_COMMANDS: &[&str] = &[
     "/help",
@@ -400,7 +404,8 @@ impl Channel for ReplChannel {
 
         match status {
             StatusUpdate::Thinking(msg) => {
-                eprintln!("  \x1b[90m\u{25CB} {msg}\x1b[0m");
+                let display = truncate_for_preview(&msg, CLI_STATUS_MAX);
+                eprintln!("  \x1b[90m\u{25CB} {display}\x1b[0m");
             }
             StatusUpdate::ToolStarted { name } => {
                 eprintln!("  \x1b[33m\u{25CB} {name}\x1b[0m");
@@ -438,7 +443,8 @@ impl Channel for ReplChannel {
             }
             StatusUpdate::Status(msg) => {
                 if debug || msg.contains("approval") || msg.contains("Approval") {
-                    eprintln!("  \x1b[90m{msg}\x1b[0m");
+                    let display = truncate_for_preview(&msg, CLI_STATUS_MAX);
+                    eprintln!("  \x1b[90m{display}\x1b[0m");
                 }
             }
             StatusUpdate::ApprovalNeeded {
diff --git a/src/lib.rs b/src/lib.rs
index 54313777..7185d3f3 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -58,6 +58,7 @@ pub mod secrets;
 pub mod settings;
 pub mod setup;
 pub mod tools;
+pub mod tracing_fmt;
 pub mod util;
 pub mod worker;
 pub mod workspace;
diff --git a/src/llm/nearai_chat.rs b/src/llm/nearai_chat.rs
index 5ca5e95e..13a36a91 100644
--- a/src/llm/nearai_chat.rs
+++ b/src/llm/nearai_chat.rs
@@ -71,8 +71,9 @@ impl NearAiChatProvider {
 
         tracing::debug!("Sending request to NEAR AI Chat: {}", url);
 
-        // Log the request body for debugging tool call issues
-        if let Ok(json) = serde_json::to_string(body) {
+        if tracing::enabled!(tracing::Level::DEBUG)
+            && let Ok(json) = serde_json::to_string(body)
+        {
             tracing::debug!("NEAR AI Chat request body: {}", json);
         }
 
diff --git a/src/main.rs b/src/main.rs
index 1a933ee6..cc619eb8 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -305,7 +305,11 @@ async fn main() -> anyhow::Result<()> {
 
     tracing_subscriber::registry()
         .with(env_filter)
-        .with(tracing_subscriber::fmt::layer().with_target(false))
+        .with(
+            tracing_subscriber::fmt::layer()
+                .with_target(false)
+                .with_writer(ironclaw::tracing_fmt::TruncatingStderr::default()),
+        )
         .with(WebLogLayer::new(Arc::clone(&log_broadcaster)))
         .init();
 
diff --git a/src/tracing_fmt.rs b/src/tracing_fmt.rs
new file mode 100644
index 00000000..f0d7073a
--- /dev/null
+++ b/src/tracing_fmt.rs
@@ -0,0 +1,319 @@
+//! Truncating terminal writer for tracing.
+//!
+//! Tracing events from LLM providers can dump 10KB+ JSON bodies to stderr.
+//! Rather than truncating at every call site (fragile, easy to miss), we
+//! handle it at the writer level: the fmt layer gets a `TruncatingStderr`
+//! that caps each event before flushing, while the web gateway `WebLogLayer`
+//! still sees the full, untruncated content.
+//!
+//! ```text
+//! tracing::debug!("body: {huge_json}")
+//!        |
+//!        v
+//!   tracing_subscriber::registry()
+//!        |
+//!        +-- fmt::layer().with_writer(TruncatingStderr)  <-- caps at 500B
+//!        |       \-- stderr (truncated)
+//!        |
+//!        \-- WebLogLayer (unchanged)
+//!                \-- SSE broadcast (full)
+//! ```
+
+use std::io::{self, Write};
+
+use tracing_subscriber::fmt::MakeWriter;
+
+/// Maximum bytes per tracing event written to the terminal.
+const TERMINAL_MAX_EVENT_BYTES: usize = 500;
+
+/// A `MakeWriter` that creates per-event buffers which truncate on flush.
+///
+/// Each call to `make_writer()` returns an `EventBuffer`. All `write()`
+/// calls accumulate into the buffer. When the buffer drops (after the fmt
+/// layer finishes writing one event), it flushes to stderr, truncating if
+/// the total exceeds `TERMINAL_MAX_EVENT_BYTES`.
+#[derive(Clone)]
+pub struct TruncatingStderr {
+    max_bytes: usize,
+}
+
+impl Default for TruncatingStderr {
+    fn default() -> Self {
+        Self {
+            max_bytes: TERMINAL_MAX_EVENT_BYTES,
+        }
+    }
+}
+
+impl TruncatingStderr {
+    #[cfg(test)]
+    fn with_max_bytes(max_bytes: usize) -> Self {
+        Self { max_bytes }
+    }
+}
+
+impl<'a> MakeWriter<'a> for TruncatingStderr {
+    type Writer = EventBuffer;
+
+    fn make_writer(&'a self) -> Self::Writer {
+        EventBuffer {
+            buf: Vec::with_capacity(256),
+            max_bytes: self.max_bytes,
+            #[cfg(test)]
+            sink: None,
+        }
+    }
+}
+
+/// Per-event buffer that truncates on drop.
+pub struct EventBuffer {
+    buf: Vec,
+    max_bytes: usize,
+    /// Test-only: capture output instead of writing to stderr.
+    #[cfg(test)]
+    sink: Option>>>,
+}
+
+impl Write for EventBuffer {
+    fn write(&mut self, data: &[u8]) -> io::Result {
+        self.buf.extend_from_slice(data);
+        Ok(data.len())
+    }
+
+    fn flush(&mut self) -> io::Result<()> {
+        Ok(())
+    }
+}
+
+/// Find the last valid UTF-8 char boundary at or before `pos` in `bytes`.
+///
+/// Walks backwards from `pos` until we find a byte that isn't a UTF-8
+/// continuation byte (0x80..0xBF). Returns 0 if the entire prefix is
+/// somehow invalid (shouldn't happen with valid UTF-8 input from tracing).
+fn utf8_floor(bytes: &[u8], pos: usize) -> usize {
+    let mut i = pos;
+    // UTF-8 continuation bytes have the form 10xxxxxx (0x80..0xBF).
+    // Walk backwards past them to find the start of the last character.
+    while i > 0 && bytes[i] & 0xC0 == 0x80 {
+        i -= 1;
+    }
+    i
+}
+
+impl Drop for EventBuffer {
+    fn drop(&mut self) {
+        if self.buf.is_empty() {
+            return;
+        }
+
+        let output = if self.buf.len() <= self.max_bytes {
+            &self.buf[..]
+        } else {
+            // Truncate at a UTF-8 safe boundary
+            let cut = utf8_floor(&self.buf, self.max_bytes);
+            let suffix = format!("...[{}B total]\n", self.buf.len());
+            let mut truncated = Vec::with_capacity(cut + suffix.len());
+            // Strip trailing newline from the cut portion (we add our own via suffix)
+            let cut_slice = &self.buf[..cut];
+            let trimmed = if cut_slice.last() == Some(&b'\n') {
+                &cut_slice[..cut_slice.len() - 1]
+            } else {
+                cut_slice
+            };
+            truncated.extend_from_slice(trimmed);
+            truncated.extend_from_slice(suffix.as_bytes());
+
+            #[cfg(test)]
+            if let Some(ref sink) = self.sink {
+                let mut s = sink.lock().expect("test sink lock poisoned");
+                s.extend_from_slice(&truncated);
+                return;
+            }
+
+            let _ = io::stderr().write_all(&truncated);
+            return;
+        };
+
+        #[cfg(test)]
+        if let Some(ref sink) = self.sink {
+            let mut s = sink.lock().expect("test sink lock poisoned");
+            s.extend_from_slice(output);
+            return;
+        }
+
+        let _ = io::stderr().write_all(output);
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use std::sync::{Arc, Mutex};
+
+    use crate::tracing_fmt::{EventBuffer, TruncatingStderr, utf8_floor};
+
+    use std::io::Write;
+
+    /// Helper: create an EventBuffer that captures output to a shared Vec
+    /// instead of writing to stderr.
+    fn test_buffer(max_bytes: usize) -> (EventBuffer, Arc>>) {
+        let sink = Arc::new(Mutex::new(Vec::new()));
+        let buf = EventBuffer {
+            buf: Vec::new(),
+            max_bytes,
+            sink: Some(Arc::clone(&sink)),
+        };
+        (buf, sink)
+    }
+
+    #[test]
+    fn test_short_event_not_truncated() {
+        let (mut buf, sink) = test_buffer(500);
+        buf.write_all(b"hello world\n").unwrap();
+        drop(buf);
+
+        let output = sink.lock().unwrap();
+        assert_eq!(&*output, b"hello world\n");
+    }
+
+    #[test]
+    fn test_long_event_truncated() {
+        let (mut buf, sink) = test_buffer(20);
+        let data = "abcdefghijklmnopqrstuvwxyz0123456789\n";
+        buf.write_all(data.as_bytes()).unwrap();
+        let total = data.len();
+        drop(buf);
+
+        let output = sink.lock().unwrap();
+        let output_str = String::from_utf8_lossy(&output);
+        // Should contain the suffix with total byte count
+        assert!(
+            output_str.contains(&format!("...[{}B total]", total)),
+            "expected truncation suffix, got: {}",
+            output_str
+        );
+        // Should be shorter than the original
+        assert!(output.len() < total);
+    }
+
+    #[test]
+    fn test_utf8_boundary_safe() {
+        // "Helloé" = [72, 101, 108, 108, 111, 195, 169]
+        //                                         ^-- 2-byte UTF-8 char
+        // If we truncate at 6 bytes, we'd land in the middle of 'é'.
+        // utf8_floor should back up to byte 5 (start of 'é' = 195).
+        let (mut buf, sink) = test_buffer(6);
+        let data = "Helloé world";
+        buf.write_all(data.as_bytes()).unwrap();
+        drop(buf);
+
+        let output = sink.lock().unwrap();
+        let output_str = String::from_utf8(output.clone());
+        assert!(
+            output_str.is_ok(),
+            "output should be valid UTF-8, got bytes: {:?}",
+            &*output
+        );
+        let s = output_str.unwrap();
+        assert!(
+            s.contains("...["),
+            "should be truncated with suffix, got: {}",
+            s
+        );
+        // The truncated prefix must be valid UTF-8 up to the cut point.
+        // "Hello" (5 bytes) is the last valid cut before the 2-byte é.
+        assert!(
+            s.starts_with("Hello"),
+            "should start with 'Hello', got: {}",
+            s
+        );
+    }
+
+    #[test]
+    fn test_utf8_floor_basic() {
+        // ASCII: every byte is a valid boundary
+        assert_eq!(utf8_floor(b"hello", 3), 3);
+
+        // 2-byte UTF-8 char é = [0xC3, 0xA9]
+        // Landing on the continuation byte (0xA9) should back up to 0xC3
+        let bytes = "Hé".as_bytes(); // [72, 0xC3, 0xA9]
+        assert_eq!(utf8_floor(bytes, 2), 1); // backs up to start of é
+
+        // 3-byte UTF-8 char (e.g. あ = [0xE3, 0x81, 0x82])
+        let bytes = "aあ".as_bytes(); // [97, 0xE3, 0x81, 0x82]
+        assert_eq!(utf8_floor(bytes, 2), 1); // backs up past continuation to 0xE3
+        assert_eq!(utf8_floor(bytes, 3), 1); // same: 0x82 is continuation, 0x81 is too
+    }
+
+    #[test]
+    fn test_multiple_writes_accumulated() {
+        let (mut buf, sink) = test_buffer(500);
+        buf.write_all(b"hello ").unwrap();
+        buf.write_all(b"world\n").unwrap();
+        drop(buf);
+
+        let output = sink.lock().unwrap();
+        assert_eq!(&*output, b"hello world\n");
+    }
+
+    #[test]
+    fn test_empty_buffer_no_output() {
+        let (_buf, sink) = test_buffer(500);
+        // drop without writing
+        drop(_buf);
+
+        let output = sink.lock().unwrap();
+        assert!(output.is_empty());
+    }
+
+    #[test]
+    fn test_default_max_bytes() {
+        let writer = TruncatingStderr::default();
+        assert_eq!(writer.max_bytes, 500);
+    }
+
+    #[test]
+    fn test_custom_max_bytes() {
+        let writer = TruncatingStderr::with_max_bytes(100);
+        assert_eq!(writer.max_bytes, 100);
+    }
+
+    #[test]
+    fn test_exactly_at_limit_not_truncated() {
+        let (mut buf, sink) = test_buffer(5);
+        buf.write_all(b"hello").unwrap();
+        drop(buf);
+
+        let output = sink.lock().unwrap();
+        assert_eq!(&*output, b"hello");
+    }
+
+    #[test]
+    fn test_one_over_limit_truncated() {
+        let (mut buf, sink) = test_buffer(5);
+        buf.write_all(b"hello!").unwrap();
+        drop(buf);
+
+        let output = sink.lock().unwrap();
+        let s = String::from_utf8_lossy(&output);
+        assert!(s.contains("...[6B total]"), "got: {}", s);
+    }
+
+    #[test]
+    fn test_4byte_utf8_boundary() {
+        // 4-byte UTF-8 char: 𝄞 (musical symbol) = [0xF0, 0x9D, 0x84, 0x9E]
+        let data = "AB𝄞CD";
+        // bytes: [65, 66, 0xF0, 0x9D, 0x84, 0x9E, 67, 68]
+        // Truncating at byte 4 lands in the middle of the 4-byte char
+        let (mut buf, sink) = test_buffer(4);
+        buf.write_all(data.as_bytes()).unwrap();
+        drop(buf);
+
+        let output = sink.lock().unwrap();
+        let s = String::from_utf8(output.clone());
+        assert!(s.is_ok(), "output must be valid UTF-8, got: {:?}", &*output);
+        let s = s.unwrap();
+        // Should back up to byte 2 (just "AB"), since bytes 2..5 are all part of 𝄞
+        assert!(s.starts_with("AB"), "expected 'AB', got: {}", s);
+        assert!(s.contains("...["), "should be truncated, got: {}", s);
+    }
+}

From e843c18141ad33deb876e6106818703a105a6b85 Mon Sep 17 00:00:00 2001
From: Zaki Manian 
Date: Fri, 13 Feb 2026 18:05:05 -0800
Subject: [PATCH 26/33] feat: add libSQL/Turso embedded database backend (#47)

* feat: add libSQL/Turso database backend with full feature parity

Introduce a Database trait abstraction (~60 async methods) enabling
compile-time backend selection between PostgreSQL and libSQL/Turso.
Convert all modules from concrete Store to Arc, add
LibSqlSecretsStore and LibSqlWasmToolStore implementations, wire
libsql stores throughout CLI and main entry points, and make the
setup wizard backend-agnostic.

Key changes:
- src/db/: Database trait, PostgresDatabase adapter, LibSqlBackend
  with native SQLite-dialect SQL, and idempotent migration system
- src/secrets/store.rs: LibSqlSecretsStore (all 8 trait methods)
- src/tools/wasm/storage.rs: LibSqlWasmToolStore (all 7 trait methods)
- src/main.rs, cli/tool.rs, cli/mcp.rs: backend-conditional wiring
- src/setup/channels.rs: SecretsContext uses Arc
- Feature-gate postgres-only tests and examples

Co-Authored-By: Claude Opus 4.6 

* feat: enable onboarding wizard for libSQL builds

Refactor the setup wizard to work with both postgres and libsql feature
flags. Previously the wizard was gated behind #[cfg(feature = "postgres")]
only, so libsql-only builds would print an error on `ironclaw onboard`.

- Add libsql fields to Settings (database_backend, libsql_path, libsql_url)
- Split wizard database/migration/secrets methods into feature-gated variants
- Add step_database_libsql() with local path and Turso remote replica prompts
- Update setup/mod.rs and main.rs feature gates to any(postgres, libsql)
- Extend check_onboard_needed() to detect libsql database presence

Co-Authored-By: Claude Opus 4.6 

* fix: address PR review feedback for libSQL backend

- P0: Switch libsql_backend to connection-per-operation pattern to fix
  shared Connection concurrency issue across tokio tasks
- P0: Wrap secrets store INSERT+SELECT in transaction to fix TOCTOU race
- P0: Document encryption-at-rest limitations and json_patch divergence
- P1: Fix get_opt_text removing .filter(|s| !s.is_empty()) that conflated
  empty strings with NULL
- P1: Replace datetime('now') with fmt_ts(&Utc::now()) for consistent
  RFC 3339 timestamps across all queries
- P2: Use explicit _rowid column in FTS5 triggers and joins for stability
  across VACUUM operations
- P2: Add tracing::warn when embedding provided but vector search disabled
  in hybrid_search
- Extract shared connect_from_config() helper to deduplicate DB connection
  logic across main.rs, cli/config.rs, and cli/mcp.rs

Co-Authored-By: Claude Opus 4.6 

* fix: add missing JobContext fields and resolve fmt/clippy warnings

Add total_tokens_used and max_tokens fields to JobContext in
libsql_backend.rs, apply cargo fmt, and fix clippy warnings.

Co-Authored-By: Claude Opus 4.6 

* fix: review fixes for libSQL backend (shared connections, panics, indexes)

- Replace .expect() with proper error propagation in 3 call sites
- Share Arc between backend and stores instead of single Connection
- Add connect-per-operation pattern to LibSqlSecretsStore and LibSqlWasmToolStore
- Wrap store() INSERT + SELECT-back in a transaction
- Add ~22 missing indexes for parity with PostgreSQL schema
- Add 18 leak_detection_patterns seed rows matching PostgreSQL V2 migration
- Fix super:: import to use crate:: style
- Gate mask_password_in_url behind #[cfg(feature = "postgres")]
- Rewrite secrets store init with or_else chain for runtime backend selection

Co-Authored-By: Claude Opus 4.6 

* fix: Resolve clippy lints (collapsible_if, too_many_arguments)

Collapse nested if blocks into let_chains to satisfy clippy's
collapsible_if lint (CI uses -D warnings). Suppress too_many_arguments
on libsql_row_to_tool_at since refactoring the positional index
pattern would be a larger change.

Co-Authored-By: Claude Opus 4.6 

---------

Co-authored-by: Claude Opus 4.6 
Co-authored-by: Illia Polosukhin 
---
 CLAUDE.md                      |  100 +-
 Cargo.lock                     |  694 ++++++++-
 Cargo.toml                     |   32 +-
 src/agent/agent_loop.rs        |    6 +-
 src/agent/routine_engine.rs    |    8 +-
 src/agent/scheduler.rs         |    6 +-
 src/agent/self_repair.rs       |    6 +-
 src/agent/worker.rs            |    8 +-
 src/bootstrap.rs               |    2 +-
 src/channels/web/mod.rs        |    4 +-
 src/channels/web/server.rs     |    4 +-
 src/cli/config.rs              |   41 +-
 src/cli/mcp.rs                 |  117 +-
 src/cli/memory.rs              |   27 +-
 src/cli/mod.rs                 |    5 +-
 src/cli/status.rs              |    7 +
 src/cli/tool.rs                |   76 +-
 src/config.rs                  |   87 +-
 src/db/libsql_backend.rs       | 2609 ++++++++++++++++++++++++++++++++
 src/db/libsql_migrations.rs    |  549 +++++++
 src/db/mod.rs                  |  538 +++++++
 src/db/postgres.rs             |  627 ++++++++
 src/error.rs                   |    7 +
 src/extensions/manager.rs      |   12 +-
 src/history/mod.rs             |    6 +-
 src/history/store.rs           |   23 +
 src/lib.rs                     |    1 +
 src/llm/session.rs             |    4 +-
 src/main.rs                    |  220 ++-
 src/orchestrator/api.rs        |    4 +-
 src/secrets/mod.rs             |    6 +-
 src/secrets/store.rs           |  331 ++++
 src/settings.rs                |   12 +
 src/setup/channels.rs          |   19 +-
 src/setup/mod.rs               |    2 +
 src/setup/wizard.rs            |  334 +++-
 src/tools/builtin/job.rs       |    7 +-
 src/tools/builtin/memory.rs    |    2 +-
 src/tools/builtin/routine.rs   |   22 +-
 src/tools/mcp/config.rs        |    8 +-
 src/tools/registry.rs          |    6 +-
 src/tools/wasm/mod.rs          |    9 +-
 src/tools/wasm/storage.rs      |  458 ++++++
 src/workspace/mod.rs           |  247 ++-
 tests/workspace_integration.rs |    1 +
 45 files changed, 6973 insertions(+), 321 deletions(-)
 create mode 100644 src/db/libsql_backend.rs
 create mode 100644 src/db/libsql_migrations.rs
 create mode 100644 src/db/mod.rs
 create mode 100644 src/db/postgres.rs

diff --git a/CLAUDE.md b/CLAUDE.md
index 3d3bd4a3..aeaf1dd2 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -151,6 +151,12 @@ src/
 │       ├── rate_limiter.rs # Per-tool rate limiting
 │       └── storage.rs  # Linear memory persistence
 │
+├── db/                 # Database abstraction layer
+│   ├── mod.rs          # Database trait (~60 async methods)
+│   ├── postgres.rs     # PostgreSQL backend (delegates to Store + Repository)
+│   ├── libsql_backend.rs # libSQL/Turso backend (embedded SQLite)
+│   └── libsql_migrations.rs # SQLite-dialect schema (idempotent)
+│
 ├── workspace/          # Persistent memory system (OpenClaw-inspired)
 │   ├── mod.rs          # Workspace struct, memory operations
 │   ├── document.rs     # MemoryDocument, MemoryChunk, WorkspaceEntry
@@ -201,6 +207,7 @@ When designing new features or systems, always prefer generic/extensible archite
 - Use `RwLock` for concurrent read/write access
 
 ### Traits for Extensibility
+- `Database` - Add new database backends (must implement all ~60 methods)
 - `Channel` - Add new input sources
 - `Tool` - Add new capabilities
 - `LlmProvider` - Add new LLM backends
@@ -248,7 +255,12 @@ Pending -> InProgress -> Completed -> Submitted -> Accepted
 
 Environment variables (see `.env.example`):
 ```bash
+# Database backend (default: postgres)
+DATABASE_BACKEND=postgres               # or "libsql" / "turso"
 DATABASE_URL=postgres://user:pass@localhost/ironclaw
+LIBSQL_PATH=~/.ironclaw/ironclaw.db    # libSQL local path (default)
+# LIBSQL_URL=libsql://xxx.turso.io    # Turso cloud (optional)
+# LIBSQL_AUTH_TOKEN=xxx                # Required with LIBSQL_URL
 
 # NEAR AI (required)
 NEARAI_SESSION_TOKEN=sess_...
@@ -308,7 +320,51 @@ Session tokens have the format `sess_xxx` (37 characters). They are authenticate
 
 ## Database
 
-Single migration in `migrations/V1__initial.sql`. Tables:
+IronClaw supports two database backends, selected at compile time via Cargo feature flags and at runtime via the `DATABASE_BACKEND` environment variable.
+
+**IMPORTANT: All new features that touch persistence MUST support both backends.** Implement the operation as a method on the `Database` trait in `src/db/mod.rs`, then add the implementation in both `src/db/postgres.rs` (delegate to Store/Repository) and `src/db/libsql_backend.rs` (native SQL).
+
+### Backends
+
+| Backend | Feature Flag | Default | Use Case |
+|---------|-------------|---------|----------|
+| PostgreSQL | `postgres` (default) | Yes | Production, existing deployments |
+| libSQL/Turso | `libsql` | No | Zero-dependency local mode, edge, Turso cloud |
+
+```bash
+# Build with PostgreSQL only (default)
+cargo build
+
+# Build with libSQL only
+cargo build --no-default-features --features libsql
+
+# Build with both backends available
+cargo build --features "postgres,libsql"
+```
+
+### Database Trait
+
+The `Database` trait (`src/db/mod.rs`) defines ~60 async methods covering all persistence:
+- Conversations, messages, metadata
+- Jobs, actions, LLM calls, estimation snapshots
+- Sandbox jobs, job events
+- Routines, routine runs
+- Tool failures, settings
+- Workspace: documents, chunks, hybrid search
+
+Both backends implement this trait. PostgreSQL delegates to the existing `Store` + `Repository`. libSQL implements native SQLite-dialect SQL.
+
+### Schema
+
+**PostgreSQL:** `migrations/V1__initial.sql` (351 lines). Uses pgvector for embeddings, tsvector for FTS, PL/pgSQL functions. Managed by `refinery`.
+
+**libSQL:** `src/db/libsql_migrations.rs` (consolidated schema, ~480 lines). Translates PG types:
+- `UUID` -> `TEXT`, `TIMESTAMPTZ` -> `TEXT` (ISO-8601), `JSONB` -> `TEXT`
+- `VECTOR(1536)` -> `F32_BLOB(1536)` with `libsql_vector_idx`
+- `tsvector`/`ts_rank_cd` -> FTS5 virtual table with sync triggers
+- PL/pgSQL functions -> SQLite triggers
+
+**Tables (both backends):**
 
 **Core:**
 - `conversations` - Multi-channel conversation tracking
@@ -320,12 +376,41 @@ Single migration in `migrations/V1__initial.sql`. Tables:
 
 **Workspace/Memory:**
 - `memory_documents` - Flexible path-based files (e.g., "context/vision.md", "daily/2024-01-15.md")
-- `memory_chunks` - Chunked content with FTS (tsvector) and vector (pgvector) indexes
+- `memory_chunks` - Chunked content with FTS and vector indexes
 - `heartbeat_state` - Periodic execution tracking
 
-Requires pgvector extension: `CREATE EXTENSION IF NOT EXISTS vector;`
+**Other:**
+- `routines`, `routine_runs` - Scheduled/reactive execution
+- `settings` - Per-user key-value settings
+- `tool_failures` - Self-repair tracking
+- `secrets`, `wasm_tools`, `tool_capabilities` - Extension infrastructure
 
-Run migrations: `refinery migrate -c refinery.toml`
+### Configuration
+
+```bash
+# Backend selection (default: postgres)
+DATABASE_BACKEND=libsql
+
+# PostgreSQL
+DATABASE_URL=postgres://user:pass@localhost/ironclaw
+
+# libSQL (embedded)
+LIBSQL_PATH=~/.ironclaw/ironclaw.db    # Default path
+
+# libSQL (Turso cloud sync)
+LIBSQL_URL=libsql://your-db.turso.io
+LIBSQL_AUTH_TOKEN=your-token            # Required when LIBSQL_URL is set
+```
+
+### Current Limitations (libSQL backend)
+
+- **Workspace/memory system** not yet wired through Database trait (requires Store migration)
+- **Secrets store** not yet available (still requires PostgresSecretsStore)
+- **Hybrid search** uses FTS5 only (vector search via libsql_vector_idx not yet implemented)
+- **Settings reload from DB** skipped (Config::from_db requires Store)
+- No incremental migration versioning (schema is CREATE IF NOT EXISTS, no ALTER TABLE support yet)
+- **No encryption at rest** -- The local SQLite database file stores conversation content, job data, workspace memory, and other application data in plaintext. Only secrets (API tokens, credentials) are encrypted via AES-256-GCM before storage. Users handling sensitive data should use full-disk encryption (FileVault, LUKS, BitLocker) or consider the PostgreSQL backend with TDE/encrypted storage.
+- **JSON merge patch vs path-targeted update** -- The libSQL backend uses RFC 7396 JSON Merge Patch (`json_patch`) for metadata updates, while PostgreSQL uses path-targeted `jsonb_set`. Merge patch replaces top-level keys entirely, which may drop nested keys not present in the patch. Callers should avoid relying on partial nested object updates in metadata fields.
 
 ## Safety Layer
 
@@ -387,6 +472,7 @@ Key test patterns:
 - ✅ **Claude Code mode** - Delegate jobs to Claude CLI inside containers
 - ✅ **Routines system** - Cron, event, webhook, and manual triggers with guardrails
 - ✅ **Extension management** - Install, auth, activate MCP/WASM extensions via CLI and web UI
+- ✅ **libSQL/Turso backend** - Database trait abstraction (`src/db/`), feature-gated dual backend support (postgres/libsql), embedded SQLite for zero-dependency local mode
 
 ## Adding a New Tool
 
@@ -625,7 +711,7 @@ Four tools for LLM use:
 
 ### Hybrid Search (RRF)
 
-Combines full-text search (PostgreSQL `ts_rank_cd`) and vector similarity (pgvector cosine) using Reciprocal Rank Fusion:
+Combines full-text search and vector similarity using Reciprocal Rank Fusion:
 
 ```
 score(d) = Σ 1/(k + rank(d)) for each method where d appears
@@ -633,6 +719,10 @@ score(d) = Σ 1/(k + rank(d)) for each method where d appears
 
 Default k=60. Results from both methods are combined, with documents appearing in both getting boosted scores.
 
+**Backend differences:**
+- **PostgreSQL:** `ts_rank_cd` for FTS, pgvector cosine distance for vectors, full RRF
+- **libSQL:** FTS5 for keyword search only (vector search via `libsql_vector_idx` not yet wired)
+
 ### Heartbeat System
 
 Proactive periodic execution (default: 30 minutes):
diff --git a/Cargo.lock b/Cargo.lock
index be2dcb82..6d64e505 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -66,7 +66,7 @@ dependencies = [
  "cfg-if",
  "once_cell",
  "version_check",
- "zerocopy",
+ "zerocopy 0.8.37",
 ]
 
 [[package]]
@@ -364,24 +364,52 @@ version = "1.5.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
 
+[[package]]
+name = "axum"
+version = "0.6.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b829e4e32b91e643de6eafe82b1d90675f5874230191a4ffbc1b336dec4d6bf"
+dependencies = [
+ "async-trait",
+ "axum-core 0.3.4",
+ "bitflags 1.3.2",
+ "bytes",
+ "futures-util",
+ "http 0.2.12",
+ "http-body 0.4.6",
+ "hyper 0.14.32",
+ "itoa",
+ "matchit 0.7.3",
+ "memchr",
+ "mime",
+ "percent-encoding",
+ "pin-project-lite",
+ "rustversion",
+ "serde",
+ "sync_wrapper 0.1.2",
+ "tower 0.4.13",
+ "tower-layer",
+ "tower-service",
+]
+
 [[package]]
 name = "axum"
 version = "0.8.8"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8"
 dependencies = [
- "axum-core",
+ "axum-core 0.5.6",
  "base64 0.22.1",
  "bytes",
  "form_urlencoded",
  "futures-util",
- "http",
- "http-body",
+ "http 1.4.0",
+ "http-body 1.0.1",
  "http-body-util",
- "hyper",
+ "hyper 1.8.1",
  "hyper-util",
  "itoa",
- "matchit",
+ "matchit 0.8.4",
  "memchr",
  "mime",
  "percent-encoding",
@@ -391,15 +419,32 @@ dependencies = [
  "serde_path_to_error",
  "serde_urlencoded",
  "sha1",
- "sync_wrapper",
+ "sync_wrapper 1.0.2",
  "tokio",
  "tokio-tungstenite 0.28.0",
- "tower",
+ "tower 0.5.3",
  "tower-layer",
  "tower-service",
  "tracing",
 ]
 
+[[package]]
+name = "axum-core"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "759fa577a247914fd3f7f76d62972792636412fbfd634cd452f6a385a74d2d2c"
+dependencies = [
+ "async-trait",
+ "bytes",
+ "futures-util",
+ "http 0.2.12",
+ "http-body 0.4.6",
+ "mime",
+ "rustversion",
+ "tower-layer",
+ "tower-service",
+]
+
 [[package]]
 name = "axum-core"
 version = "0.5.6"
@@ -408,12 +453,12 @@ checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
 dependencies = [
  "bytes",
  "futures-core",
- "http",
- "http-body",
+ "http 1.4.0",
+ "http-body 1.0.1",
  "http-body-util",
  "mime",
  "pin-project-lite",
- "sync_wrapper",
+ "sync_wrapper 1.0.2",
  "tower-layer",
  "tower-service",
  "tracing",
@@ -431,6 +476,38 @@ version = "0.22.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
 
+[[package]]
+name = "bincode"
+version = "1.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "bindgen"
+version = "0.66.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2b84e06fc203107bfbad243f4aba2af864eb7db3b1cf46ea0a023b0b433d2a7"
+dependencies = [
+ "bitflags 2.10.0",
+ "cexpr",
+ "clang-sys",
+ "lazy_static",
+ "lazycell",
+ "log",
+ "peeking_take_while",
+ "prettyplease",
+ "proc-macro2",
+ "quote",
+ "regex",
+ "rustc-hash 1.1.0",
+ "shlex",
+ "syn 2.0.114",
+ "which",
+]
+
 [[package]]
 name = "bitflags"
 version = "1.3.2"
@@ -513,9 +590,9 @@ dependencies = [
  "futures-util",
  "hex",
  "home",
- "http",
+ "http 1.4.0",
  "http-body-util",
- "hyper",
+ "hyper 1.8.1",
  "hyper-named-pipe",
  "hyper-rustls",
  "hyper-util",
@@ -615,6 +692,9 @@ name = "bytes"
 version = "1.11.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3"
+dependencies = [
+ "serde",
+]
 
 [[package]]
 name = "cap-fs-ext"
@@ -715,6 +795,15 @@ dependencies = [
  "shlex",
 ]
 
+[[package]]
+name = "cexpr"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
+dependencies = [
+ "nom",
+]
+
 [[package]]
 name = "cfg-if"
 version = "1.0.4"
@@ -751,6 +840,17 @@ dependencies = [
  "inout",
 ]
 
+[[package]]
+name = "clang-sys"
+version = "1.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4"
+dependencies = [
+ "glob",
+ "libc",
+ "libloading",
+]
+
 [[package]]
 name = "clap"
 version = "4.5.56"
@@ -929,7 +1029,7 @@ dependencies = [
  "hashbrown 0.14.5",
  "log",
  "regalloc2",
- "rustc-hash",
+ "rustc-hash 2.1.1",
  "serde",
  "smallvec",
  "target-lexicon",
@@ -1540,6 +1640,12 @@ version = "0.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
 
+[[package]]
+name = "fallible-streaming-iterator"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
+
 [[package]]
 name = "fastrand"
 version = "2.3.0"
@@ -1831,6 +1937,25 @@ version = "0.3.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
 
+[[package]]
+name = "h2"
+version = "0.3.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d"
+dependencies = [
+ "bytes",
+ "fnv",
+ "futures-core",
+ "futures-sink",
+ "futures-util",
+ "http 0.2.12",
+ "indexmap 2.13.0",
+ "slab",
+ "tokio",
+ "tokio-util",
+ "tracing",
+]
+
 [[package]]
 name = "h2"
 version = "0.4.13"
@@ -1842,7 +1967,7 @@ dependencies = [
  "fnv",
  "futures-core",
  "futures-sink",
- "http",
+ "http 1.4.0",
  "indexmap 2.13.0",
  "slab",
  "tokio",
@@ -1866,6 +1991,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
 dependencies = [
  "ahash 0.8.12",
+ "allocator-api2",
  "serde",
 ]
 
@@ -1885,6 +2011,15 @@ version = "0.16.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
 
+[[package]]
+name = "hashlink"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8094feaf31ff591f651a2664fb9cfd92bba7a60ce3197265e9482ebe753c8f7"
+dependencies = [
+ "hashbrown 0.14.5",
+]
+
 [[package]]
 name = "heck"
 version = "0.5.0"
@@ -1930,6 +2065,17 @@ dependencies = [
  "windows-sys 0.59.0",
 ]
 
+[[package]]
+name = "http"
+version = "0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1"
+dependencies = [
+ "bytes",
+ "fnv",
+ "itoa",
+]
+
 [[package]]
 name = "http"
 version = "1.4.0"
@@ -1940,6 +2086,17 @@ dependencies = [
  "itoa",
 ]
 
+[[package]]
+name = "http-body"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2"
+dependencies = [
+ "bytes",
+ "http 0.2.12",
+ "pin-project-lite",
+]
+
 [[package]]
 name = "http-body"
 version = "1.0.1"
@@ -1947,7 +2104,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
 dependencies = [
  "bytes",
- "http",
+ "http 1.4.0",
 ]
 
 [[package]]
@@ -1958,11 +2115,17 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
 dependencies = [
  "bytes",
  "futures-core",
- "http",
- "http-body",
+ "http 1.4.0",
+ "http-body 1.0.1",
  "pin-project-lite",
 ]
 
+[[package]]
+name = "http-range-header"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "add0ab9360ddbd88cfeb3bd9574a1d85cfdfa14db10b3e21d3700dbc4328758f"
+
 [[package]]
 name = "httparse"
 version = "1.10.1"
@@ -1975,6 +2138,30 @@ version = "1.0.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
 
+[[package]]
+name = "hyper"
+version = "0.14.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7"
+dependencies = [
+ "bytes",
+ "futures-channel",
+ "futures-core",
+ "futures-util",
+ "h2 0.3.27",
+ "http 0.2.12",
+ "http-body 0.4.6",
+ "httparse",
+ "httpdate",
+ "itoa",
+ "pin-project-lite",
+ "socket2 0.5.10",
+ "tokio",
+ "tower-service",
+ "tracing",
+ "want",
+]
+
 [[package]]
 name = "hyper"
 version = "1.8.1"
@@ -1985,9 +2172,9 @@ dependencies = [
  "bytes",
  "futures-channel",
  "futures-core",
- "h2",
- "http",
- "http-body",
+ "h2 0.4.13",
+ "http 1.4.0",
+ "http-body 1.0.1",
  "httparse",
  "httpdate",
  "itoa",
@@ -2005,7 +2192,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "73b7d8abf35697b81a825e386fc151e0d503e8cb5fcb93cc8669c376dfd6f278"
 dependencies = [
  "hex",
- "hyper",
+ "hyper 1.8.1",
  "hyper-util",
  "pin-project-lite",
  "tokio",
@@ -2019,8 +2206,8 @@ version = "0.27.7"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
 dependencies = [
- "http",
- "hyper",
+ "http 1.4.0",
+ "hyper 1.8.1",
  "hyper-util",
  "rustls",
  "rustls-pki-types",
@@ -2030,6 +2217,18 @@ dependencies = [
  "webpki-roots",
 ]
 
+[[package]]
+name = "hyper-timeout"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbb958482e8c7be4bc3cf272a766a2b0bf1a6755e7a6ae777f017a31d11b13b1"
+dependencies = [
+ "hyper 0.14.32",
+ "pin-project-lite",
+ "tokio",
+ "tokio-io-timeout",
+]
+
 [[package]]
 name = "hyper-tls"
 version = "0.6.0"
@@ -2038,7 +2237,7 @@ checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
 dependencies = [
  "bytes",
  "http-body-util",
- "hyper",
+ "hyper 1.8.1",
  "hyper-util",
  "native-tls",
  "tokio",
@@ -2056,14 +2255,14 @@ dependencies = [
  "bytes",
  "futures-channel",
  "futures-util",
- "http",
- "http-body",
- "hyper",
+ "http 1.4.0",
+ "http-body 1.0.1",
+ "hyper 1.8.1",
  "ipnet",
  "libc",
  "percent-encoding",
  "pin-project-lite",
- "socket2",
+ "socket2 0.6.2",
  "system-configuration",
  "tokio",
  "tower-service",
@@ -2079,7 +2278,7 @@ checksum = "986c5ce3b994526b3cd75578e62554abd09f0899d6206de48b3e96ab34ccc8c7"
 dependencies = [
  "hex",
  "http-body-util",
- "hyper",
+ "hyper 1.8.1",
  "hyper-util",
  "pin-project-lite",
  "tokio",
@@ -2297,7 +2496,7 @@ dependencies = [
  "aho-corasick",
  "anyhow",
  "async-trait",
- "axum",
+ "axum 0.8.8",
  "base64 0.22.1",
  "blake3",
  "bollard",
@@ -2313,8 +2512,9 @@ dependencies = [
  "futures",
  "hkdf",
  "http-body-util",
- "hyper",
+ "hyper 1.8.1",
  "hyper-util",
+ "libsql",
  "mime_guess",
  "open",
  "pgvector",
@@ -2344,8 +2544,8 @@ dependencies = [
  "tokio-stream",
  "tokio-test",
  "tokio-tungstenite 0.26.2",
- "tower",
- "tower-http",
+ "tower 0.5.3",
+ "tower-http 0.6.8",
  "tracing",
  "tracing-subscriber",
  "urlencoding",
@@ -2465,6 +2665,12 @@ version = "1.5.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
 
+[[package]]
+name = "lazycell"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55"
+
 [[package]]
 name = "leb128"
 version = "0.2.5"
@@ -2483,6 +2689,16 @@ version = "0.2.180"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
 
+[[package]]
+name = "libloading"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
+dependencies = [
+ "cfg-if",
+ "windows-link",
+]
+
 [[package]]
 name = "libm"
 version = "0.2.16"
@@ -2500,6 +2716,121 @@ dependencies = [
  "redox_syscall 0.7.0",
 ]
 
+[[package]]
+name = "libsql"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fe18646e4ef8db446bc3e3f5fb96131483203bc5f4998ff149f79a067530c01c"
+dependencies = [
+ "anyhow",
+ "async-stream",
+ "async-trait",
+ "bincode",
+ "bitflags 2.10.0",
+ "bytes",
+ "fallible-iterator 0.3.0",
+ "futures",
+ "http 0.2.12",
+ "hyper 0.14.32",
+ "libsql-sqlite3-parser",
+ "libsql-sys",
+ "libsql_replication",
+ "parking_lot",
+ "serde",
+ "thiserror 1.0.69",
+ "tokio",
+ "tokio-stream",
+ "tonic",
+ "tonic-web",
+ "tower 0.4.13",
+ "tower-http 0.4.4",
+ "tracing",
+ "uuid",
+ "zerocopy 0.7.35",
+]
+
+[[package]]
+name = "libsql-ffi"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5f2a50a585a1184a43621a9133b7702ba5cb7a87ca5e704056b19d8005de6faf"
+dependencies = [
+ "bindgen",
+ "cc",
+]
+
+[[package]]
+name = "libsql-rusqlite"
+version = "0.33.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae65c66088dcd309abbd5617ae046abac2a2ee0a7fdada5127353bd68e0a27ea"
+dependencies = [
+ "bitflags 2.10.0",
+ "fallible-iterator 0.2.0",
+ "fallible-streaming-iterator",
+ "hashlink",
+ "libsql-ffi",
+ "smallvec",
+]
+
+[[package]]
+name = "libsql-sqlite3-parser"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "15a90128c708356af8f7d767c9ac2946692c9112b4f74f07b99a01a60680e413"
+dependencies = [
+ "bitflags 2.10.0",
+ "cc",
+ "fallible-iterator 0.3.0",
+ "indexmap 2.13.0",
+ "log",
+ "memchr",
+ "phf 0.11.3",
+ "phf_codegen",
+ "phf_shared 0.11.3",
+ "uncased",
+]
+
+[[package]]
+name = "libsql-sys"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c05b61c226781d6f5e26e3e7364617f19c0c1d5332035802e9229d6024cec05"
+dependencies = [
+ "bytes",
+ "libsql-ffi",
+ "libsql-rusqlite",
+ "once_cell",
+ "tracing",
+ "zerocopy 0.7.35",
+]
+
+[[package]]
+name = "libsql_replication"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9cf40c4c2c01462da758272976de0a23d19b4e9c714db08efecf262d896655b5"
+dependencies = [
+ "aes",
+ "async-stream",
+ "async-trait",
+ "bytes",
+ "cbc",
+ "libsql-rusqlite",
+ "libsql-sys",
+ "parking_lot",
+ "prost",
+ "serde",
+ "thiserror 1.0.69",
+ "tokio",
+ "tokio-stream",
+ "tokio-util",
+ "tonic",
+ "tracing",
+ "uuid",
+ "zerocopy 0.7.35",
+]
+
 [[package]]
 name = "linux-raw-sys"
 version = "0.4.15"
@@ -2563,6 +2894,12 @@ dependencies = [
  "regex-automata",
 ]
 
+[[package]]
+name = "matchit"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
+
 [[package]]
 name = "matchit"
 version = "0.8.4"
@@ -3011,6 +3348,12 @@ version = "0.2.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
 
+[[package]]
+name = "peeking_take_while"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099"
+
 [[package]]
 name = "percent-encoding"
 version = "2.3.2"
@@ -3027,16 +3370,55 @@ dependencies = [
  "postgres-types",
 ]
 
+[[package]]
+name = "phf"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
+dependencies = [
+ "phf_shared 0.11.3",
+]
+
 [[package]]
 name = "phf"
 version = "0.13.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
 dependencies = [
- "phf_shared",
+ "phf_shared 0.13.1",
  "serde",
 ]
 
+[[package]]
+name = "phf_codegen"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
+dependencies = [
+ "phf_generator",
+ "phf_shared 0.11.3",
+]
+
+[[package]]
+name = "phf_generator"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
+dependencies = [
+ "phf_shared 0.11.3",
+ "rand 0.8.5",
+]
+
+[[package]]
+name = "phf_shared"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
+dependencies = [
+ "siphasher",
+ "uncased",
+]
+
 [[package]]
 name = "phf_shared"
 version = "0.13.1"
@@ -3187,7 +3569,7 @@ version = "0.2.21"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
 dependencies = [
- "zerocopy",
+ "zerocopy 0.8.37",
 ]
 
 [[package]]
@@ -3200,6 +3582,16 @@ dependencies = [
  "yansi",
 ]
 
+[[package]]
+name = "prettyplease"
+version = "0.2.37"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
+dependencies = [
+ "proc-macro2",
+ "syn 2.0.114",
+]
+
 [[package]]
 name = "proc-macro-crate"
 version = "3.4.0"
@@ -3218,6 +3610,29 @@ dependencies = [
  "unicode-ident",
 ]
 
+[[package]]
+name = "prost"
+version = "0.12.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "deb1435c188b76130da55f17a466d252ff7b1418b2ad3e037d127b94e3411f29"
+dependencies = [
+ "bytes",
+ "prost-derive",
+]
+
+[[package]]
+name = "prost-derive"
+version = "0.12.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "81bddcdb20abf9501610992b6759a4c888aef7d1a7247ef75e2404275ac24af1"
+dependencies = [
+ "anyhow",
+ "itertools",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.114",
+]
+
 [[package]]
 name = "psm"
 version = "0.1.29"
@@ -3270,9 +3685,9 @@ dependencies = [
  "pin-project-lite",
  "quinn-proto",
  "quinn-udp",
- "rustc-hash",
+ "rustc-hash 2.1.1",
  "rustls",
- "socket2",
+ "socket2 0.6.2",
  "thiserror 2.0.18",
  "tokio",
  "tracing",
@@ -3290,7 +3705,7 @@ dependencies = [
  "lru-slab",
  "rand 0.9.2",
  "ring",
- "rustc-hash",
+ "rustc-hash 2.1.1",
  "rustls",
  "rustls-pki-types",
  "slab",
@@ -3309,7 +3724,7 @@ dependencies = [
  "cfg_aliases",
  "libc",
  "once_cell",
- "socket2",
+ "socket2 0.6.2",
  "tracing",
  "windows-sys 0.60.2",
 ]
@@ -3548,7 +3963,7 @@ dependencies = [
  "bumpalo",
  "hashbrown 0.15.5",
  "log",
- "rustc-hash",
+ "rustc-hash 2.1.1",
  "smallvec",
 ]
 
@@ -3601,11 +4016,11 @@ dependencies = [
  "encoding_rs",
  "futures-core",
  "futures-util",
- "h2",
- "http",
- "http-body",
+ "h2 0.4.13",
+ "http 1.4.0",
+ "http-body 1.0.1",
  "http-body-util",
- "hyper",
+ "hyper 1.8.1",
  "hyper-rustls",
  "hyper-tls",
  "hyper-util",
@@ -3622,13 +4037,13 @@ dependencies = [
  "serde",
  "serde_json",
  "serde_urlencoded",
- "sync_wrapper",
+ "sync_wrapper 1.0.2",
  "tokio",
  "tokio-native-tls",
  "tokio-rustls",
  "tokio-util",
- "tower",
- "tower-http",
+ "tower 0.5.3",
+ "tower-http 0.6.8",
  "tower-service",
  "url",
  "wasm-bindgen",
@@ -3653,7 +4068,7 @@ dependencies = [
  "futures",
  "futures-timer",
  "glob",
- "http",
+ "http 1.4.0",
  "mime",
  "mime_guess",
  "nanoid",
@@ -3746,6 +4161,12 @@ version = "0.1.27"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d"
 
+[[package]]
+name = "rustc-hash"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
+
 [[package]]
 name = "rustc-hash"
 version = "2.1.1"
@@ -4273,6 +4694,16 @@ dependencies = [
  "serde",
 ]
 
+[[package]]
+name = "socket2"
+version = "0.5.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678"
+dependencies = [
+ "libc",
+ "windows-sys 0.52.0",
+]
+
 [[package]]
 name = "socket2"
 version = "0.6.2"
@@ -4375,6 +4806,12 @@ dependencies = [
  "unicode-ident",
 ]
 
+[[package]]
+name = "sync_wrapper"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160"
+
 [[package]]
 name = "sync_wrapper"
 version = "1.0.2"
@@ -4637,12 +5074,22 @@ dependencies = [
  "parking_lot",
  "pin-project-lite",
  "signal-hook-registry",
- "socket2",
+ "socket2 0.6.2",
  "tokio-macros",
  "tracing",
  "windows-sys 0.61.2",
 ]
 
+[[package]]
+name = "tokio-io-timeout"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bd86198d9ee903fedd2f9a2e72014287c0d9167e4ae43b5853007205dda1b76"
+dependencies = [
+ "pin-project-lite",
+ "tokio",
+]
+
 [[package]]
 name = "tokio-macros"
 version = "2.6.0"
@@ -4679,12 +5126,12 @@ dependencies = [
  "log",
  "parking_lot",
  "percent-encoding",
- "phf",
+ "phf 0.13.1",
  "pin-project-lite",
  "postgres-protocol",
  "postgres-types",
  "rand 0.9.2",
- "socket2",
+ "socket2 0.6.2",
  "tokio",
  "tokio-util",
  "whoami",
@@ -4846,6 +5293,73 @@ version = "0.1.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
 
+[[package]]
+name = "tonic"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76c4eb7a4e9ef9d4763600161f12f5070b92a578e1b634db88a6887844c91a13"
+dependencies = [
+ "async-stream",
+ "async-trait",
+ "axum 0.6.20",
+ "base64 0.21.7",
+ "bytes",
+ "h2 0.3.27",
+ "http 0.2.12",
+ "http-body 0.4.6",
+ "hyper 0.14.32",
+ "hyper-timeout",
+ "percent-encoding",
+ "pin-project",
+ "prost",
+ "tokio",
+ "tokio-stream",
+ "tower 0.4.13",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "tonic-web"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc3b0e1cedbf19fdfb78ef3d672cb9928e0a91a9cb4629cc0c916e8cff8aaaa1"
+dependencies = [
+ "base64 0.21.7",
+ "bytes",
+ "http 0.2.12",
+ "http-body 0.4.6",
+ "hyper 0.14.32",
+ "pin-project",
+ "tokio-stream",
+ "tonic",
+ "tower-http 0.4.4",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "tower"
+version = "0.4.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c"
+dependencies = [
+ "futures-core",
+ "futures-util",
+ "indexmap 1.9.3",
+ "pin-project",
+ "pin-project-lite",
+ "rand 0.8.5",
+ "slab",
+ "tokio",
+ "tokio-util",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
 [[package]]
 name = "tower"
 version = "0.5.3"
@@ -4855,13 +5369,33 @@ dependencies = [
  "futures-core",
  "futures-util",
  "pin-project-lite",
- "sync_wrapper",
+ "sync_wrapper 1.0.2",
  "tokio",
  "tower-layer",
  "tower-service",
  "tracing",
 ]
 
+[[package]]
+name = "tower-http"
+version = "0.4.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61c5bb1d698276a2443e5ecfabc1008bf15a36c12e6a7176e7bf089ea9131140"
+dependencies = [
+ "bitflags 2.10.0",
+ "bytes",
+ "futures-core",
+ "futures-util",
+ "http 0.2.12",
+ "http-body 0.4.6",
+ "http-range-header",
+ "pin-project-lite",
+ "tower 0.4.13",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
 [[package]]
 name = "tower-http"
 version = "0.6.8"
@@ -4871,11 +5405,11 @@ dependencies = [
  "bitflags 2.10.0",
  "bytes",
  "futures-util",
- "http",
- "http-body",
+ "http 1.4.0",
+ "http-body 1.0.1",
  "iri-string",
  "pin-project-lite",
- "tower",
+ "tower 0.5.3",
  "tower-layer",
  "tower-service",
  "tracing",
@@ -4994,7 +5528,7 @@ checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13"
 dependencies = [
  "bytes",
  "data-encoding",
- "http",
+ "http 1.4.0",
  "httparse",
  "log",
  "rand 0.9.2",
@@ -5011,7 +5545,7 @@ checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442"
 dependencies = [
  "bytes",
  "data-encoding",
- "http",
+ "http 1.4.0",
  "httparse",
  "log",
  "rand 0.9.2",
@@ -5037,6 +5571,15 @@ dependencies = [
  "winapi",
 ]
 
+[[package]]
+name = "uncased"
+version = "0.9.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697"
+dependencies = [
+ "version_check",
+]
+
 [[package]]
 name = "unicase"
 version = "2.9.0"
@@ -5703,6 +6246,18 @@ dependencies = [
  "rustls-pki-types",
 ]
 
+[[package]]
+name = "which"
+version = "4.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7"
+dependencies = [
+ "either",
+ "home",
+ "once_cell",
+ "rustix 0.38.44",
+]
+
 [[package]]
 name = "whoami"
 version = "2.1.0"
@@ -6287,13 +6842,34 @@ dependencies = [
  "zvariant",
 ]
 
+[[package]]
+name = "zerocopy"
+version = "0.7.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0"
+dependencies = [
+ "byteorder",
+ "zerocopy-derive 0.7.35",
+]
+
 [[package]]
 name = "zerocopy"
 version = "0.8.37"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "7456cf00f0685ad319c5b1693f291a650eaf345e941d082fc4e03df8a03996ac"
 dependencies = [
- "zerocopy-derive",
+ "zerocopy-derive 0.8.37",
+]
+
+[[package]]
+name = "zerocopy-derive"
+version = "0.7.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.114",
 ]
 
 [[package]]
diff --git a/Cargo.toml b/Cargo.toml
index 8ae00a3d..6a11219a 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -28,11 +28,14 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "rus
 serde = { version = "1", features = ["derive"] }
 serde_json = "1"
 
-# Database
-deadpool-postgres = "0.14"
-tokio-postgres = { version = "0.7", features = ["with-uuid-1", "with-chrono-0_4", "with-serde_json-1"] }
-postgres-types = { version = "0.2", features = ["with-serde_json-1"] }
-refinery = { version = "0.8", features = ["tokio-postgres"] }
+# Database - PostgreSQL (default, feature-gated)
+deadpool-postgres = { version = "0.14", optional = true }
+tokio-postgres = { version = "0.7", features = ["with-uuid-1", "with-chrono-0_4", "with-serde_json-1"], optional = true }
+postgres-types = { version = "0.2", features = ["with-serde_json-1"], optional = true }
+refinery = { version = "0.8", features = ["tokio-postgres"], optional = true }
+
+# Database - libSQL/Turso (optional embedded database)
+libsql = { version = "0.6", optional = true, default-features = false, features = ["core", "replication"] }
 
 # Error handling
 thiserror = "2"
@@ -48,7 +51,7 @@ dotenvy = "0.15"
 # Core types
 uuid = { version = "1", features = ["v4", "serde"] }
 chrono = { version = "0.4", features = ["serde"] }
-rust_decimal = { version = "1", features = ["serde", "serde-with-str", "db-tokio-postgres", "maths"] }
+rust_decimal = { version = "1", features = ["serde", "serde-with-str", "maths"] }
 rust_decimal_macros = "1"
 
 # Async traits
@@ -89,7 +92,7 @@ open = "5"
 
 # Vector embeddings for semantic search
 # The postgres feature provides ToSql/FromSql for postgres-types (shared by tokio-postgres)
-pgvector = { version = "0.4", features = ["postgres"] }
+pgvector = { version = "0.4", features = ["postgres"], optional = true }
 
 # WASM sandbox for untrusted tool execution
 wasmtime = { version = "28", features = ["component-model"] }
@@ -135,9 +138,22 @@ pretty_assertions = "1"
 tempfile = "3"
 
 [features]
-default = []
+default = ["postgres"]
+postgres = [
+    "dep:deadpool-postgres",
+    "dep:tokio-postgres",
+    "dep:postgres-types",
+    "dep:refinery",
+    "dep:pgvector",
+    "rust_decimal/db-tokio-postgres",
+]
+libsql = ["dep:libsql"]
 integration = []
 
+[[example]]
+name = "test_heartbeat"
+required-features = ["postgres"]
+
 # The profile that 'cargo dist' will build with
 [profile.dist]
 inherits = "release"
diff --git a/src/agent/agent_loop.rs b/src/agent/agent_loop.rs
index 18a7d6fc..fa9cfb9a 100644
--- a/src/agent/agent_loop.rs
+++ b/src/agent/agent_loop.rs
@@ -19,9 +19,9 @@ use crate::channels::{ChannelManager, IncomingMessage, OutgoingResponse, StatusU
 use crate::config::{AgentConfig, HeartbeatConfig, RoutineConfig};
 use crate::context::ContextManager;
 use crate::context::JobContext;
+use crate::db::Database;
 use crate::error::Error;
 use crate::extensions::ExtensionManager;
-use crate::history::Store;
 use crate::llm::{ChatMessage, LlmProvider, Reasoning, ReasoningContext, RespondResult};
 use crate::safety::SafetyLayer;
 use crate::tools::ToolRegistry;
@@ -65,7 +65,7 @@ enum AgenticLoopResult {
 ///
 /// Bundles the shared components to reduce argument count.
 pub struct AgentDeps {
-    pub store: Option>,
+    pub store: Option>,
     pub llm: Arc,
     pub safety: Arc,
     pub tools: Arc,
@@ -130,7 +130,7 @@ impl Agent {
     }
 
     // Convenience accessors
-    fn store(&self) -> Option<&Arc> {
+    fn store(&self) -> Option<&Arc> {
         self.deps.store.as_ref()
     }
 
diff --git a/src/agent/routine_engine.rs b/src/agent/routine_engine.rs
index e88a0a9d..52156ac5 100644
--- a/src/agent/routine_engine.rs
+++ b/src/agent/routine_engine.rs
@@ -24,14 +24,14 @@ use crate::agent::routine::{
 };
 use crate::channels::{IncomingMessage, OutgoingResponse};
 use crate::config::RoutineConfig;
-use crate::history::Store;
+use crate::db::Database;
 use crate::llm::{ChatMessage, CompletionRequest, FinishReason, LlmProvider};
 use crate::workspace::Workspace;
 
 /// The routine execution engine.
 pub struct RoutineEngine {
     config: RoutineConfig,
-    store: Arc,
+    store: Arc,
     llm: Arc,
     workspace: Arc,
     /// Sender for notifications (routed to channel manager).
@@ -45,7 +45,7 @@ pub struct RoutineEngine {
 impl RoutineEngine {
     pub fn new(
         config: RoutineConfig,
-        store: Arc,
+        store: Arc,
         llm: Arc,
         workspace: Arc,
         notify_tx: mpsc::Sender,
@@ -293,7 +293,7 @@ impl RoutineEngine {
 
 /// Shared context passed to the execution function.
 struct EngineContext {
-    store: Arc,
+    store: Arc,
     llm: Arc,
     workspace: Arc,
     notify_tx: mpsc::Sender,
diff --git a/src/agent/scheduler.rs b/src/agent/scheduler.rs
index f37bba28..a665c2af 100644
--- a/src/agent/scheduler.rs
+++ b/src/agent/scheduler.rs
@@ -12,8 +12,8 @@ use crate::agent::task::{Task, TaskContext, TaskOutput};
 use crate::agent::worker::{Worker, WorkerDeps};
 use crate::config::AgentConfig;
 use crate::context::{ContextManager, JobContext, JobState};
+use crate::db::Database;
 use crate::error::{Error, JobError};
-use crate::history::Store;
 use crate::llm::LlmProvider;
 use crate::safety::SafetyLayer;
 use crate::tools::ToolRegistry;
@@ -48,7 +48,7 @@ pub struct Scheduler {
     llm: Arc,
     safety: Arc,
     tools: Arc,
-    store: Option>,
+    store: Option>,
     /// Running jobs (main LLM-driven jobs).
     jobs: Arc>>,
     /// Running sub-tasks (tool executions, background tasks).
@@ -63,7 +63,7 @@ impl Scheduler {
         llm: Arc,
         safety: Arc,
         tools: Arc,
-        store: Option>,
+        store: Option>,
     ) -> Self {
         Self {
             config,
diff --git a/src/agent/self_repair.rs b/src/agent/self_repair.rs
index ace75fe8..ee7b2a4c 100644
--- a/src/agent/self_repair.rs
+++ b/src/agent/self_repair.rs
@@ -8,8 +8,8 @@ use chrono::{DateTime, Utc};
 use uuid::Uuid;
 
 use crate::context::{ContextManager, JobState};
+use crate::db::Database;
 use crate::error::RepairError;
-use crate::history::Store;
 use crate::tools::{BuildRequirement, Language, SoftwareBuilder, SoftwareType, ToolRegistry};
 
 /// A job that has been detected as stuck.
@@ -69,7 +69,7 @@ pub struct DefaultSelfRepair {
     #[allow(dead_code)] // Will be used for time-based stuck detection
     stuck_threshold: Duration,
     max_repair_attempts: u32,
-    store: Option>,
+    store: Option>,
     builder: Option>,
     #[allow(dead_code)] // Will be used for tool hot-reload after repair
     tools: Option>,
@@ -94,7 +94,7 @@ impl DefaultSelfRepair {
 
     /// Add a Store for tool failure tracking.
     #[allow(dead_code)] // Public API for configuring repair with persistence
-    pub fn with_store(mut self, store: Arc) -> Self {
+    pub fn with_store(mut self, store: Arc) -> Self {
         self.store = Some(store);
         self
     }
diff --git a/src/agent/worker.rs b/src/agent/worker.rs
index bf8ec895..39c5cd8d 100644
--- a/src/agent/worker.rs
+++ b/src/agent/worker.rs
@@ -10,8 +10,8 @@ use uuid::Uuid;
 use crate::agent::scheduler::WorkerMessage;
 use crate::agent::task::TaskOutput;
 use crate::context::{ContextManager, JobState};
+use crate::db::Database;
 use crate::error::Error;
-use crate::history::Store;
 use crate::llm::{
     ActionPlan, ChatMessage, LlmProvider, Reasoning, ReasoningContext, RespondResult, ToolSelection,
 };
@@ -28,7 +28,7 @@ pub struct WorkerDeps {
     pub llm: Arc,
     pub safety: Arc,
     pub tools: Arc,
-    pub store: Option>,
+    pub store: Option>,
     pub timeout: Duration,
     pub use_planning: bool,
 }
@@ -67,7 +67,7 @@ impl Worker {
         &self.deps.tools
     }
 
-    fn store(&self) -> Option<&Arc> {
+    fn store(&self) -> Option<&Arc> {
         self.deps.store.as_ref()
     }
 
@@ -381,7 +381,7 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
         tools: Arc,
         context_manager: Arc,
         safety: Arc,
-        store: Option>,
+        store: Option>,
         job_id: Uuid,
         tool_name: &str,
         params: &serde_json::Value,
diff --git a/src/bootstrap.rs b/src/bootstrap.rs
index 72ff65c0..e24b996e 100644
--- a/src/bootstrap.rs
+++ b/src/bootstrap.rs
@@ -124,7 +124,7 @@ impl BootstrapConfig {
 /// If both conditions hold, migrates settings, MCP servers, and session data
 /// to the database, writes `bootstrap.json`, and renames old files to `.migrated`.
 pub async fn migrate_disk_to_db(
-    store: &crate::history::Store,
+    store: &dyn crate::db::Database,
     user_id: &str,
 ) -> Result<(), MigrationError> {
     let legacy_settings_path = BootstrapConfig::legacy_settings_path();
diff --git a/src/channels/web/mod.rs b/src/channels/web/mod.rs
index ac6ef2f6..356eda2c 100644
--- a/src/channels/web/mod.rs
+++ b/src/channels/web/mod.rs
@@ -32,9 +32,9 @@ use tokio_stream::wrappers::ReceiverStream;
 use crate::agent::SessionManager;
 use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate};
 use crate::config::GatewayConfig;
+use crate::db::Database;
 use crate::error::ChannelError;
 use crate::extensions::ExtensionManager;
-use crate::history::Store;
 use crate::orchestrator::job_manager::ContainerJobManager;
 use crate::tools::ToolRegistry;
 use crate::workspace::Workspace;
@@ -147,7 +147,7 @@ impl GatewayChannel {
     }
 
     /// Inject the database store for sandbox job persistence.
-    pub fn with_store(mut self, store: Arc) -> Self {
+    pub fn with_store(mut self, store: Arc) -> Self {
         self.rebuild_state(|s| s.store = Some(store));
         self
     }
diff --git a/src/channels/web/server.rs b/src/channels/web/server.rs
index 68bf6edf..744c369e 100644
--- a/src/channels/web/server.rs
+++ b/src/channels/web/server.rs
@@ -30,8 +30,8 @@ use crate::channels::web::auth::{AuthState, auth_middleware};
 use crate::channels::web::log_layer::LogBroadcaster;
 use crate::channels::web::sse::SseManager;
 use crate::channels::web::types::*;
+use crate::db::Database;
 use crate::extensions::ExtensionManager;
-use crate::history::Store;
 use crate::orchestrator::job_manager::ContainerJobManager;
 use crate::tools::ToolRegistry;
 use crate::workspace::Workspace;
@@ -126,7 +126,7 @@ pub struct GatewayState {
     /// Tool registry for listing registered tools.
     pub tool_registry: Option>,
     /// Database store for sandbox job persistence.
-    pub store: Option>,
+    pub store: Option>,
     /// Container job manager for sandbox operations.
     pub job_manager: Option>,
     /// Prompt queue for Claude Code follow-up prompts.
diff --git a/src/cli/config.rs b/src/cli/config.rs
index 07788dca..8835b2c0 100644
--- a/src/cli/config.rs
+++ b/src/cli/config.rs
@@ -1,7 +1,9 @@
 //! Configuration management CLI commands.
 //!
 //! Commands for viewing and modifying settings.
-//! Settings are stored in PostgreSQL (env > DB > default).
+//! Settings are stored in the database (env > DB > default).
+
+use std::sync::Arc;
 
 use clap::Subcommand;
 
@@ -49,8 +51,8 @@ pub async fn run_config_command(cmd: ConfigCommand) -> anyhow::Result<()> {
     let _ = dotenvy::dotenv();
 
     // Try to connect to the DB for settings access
-    let store = match connect_store().await {
-        Ok(s) => Some(s),
+    let db: Option> = match connect_db().await {
+        Ok(d) => Some(d),
         Err(e) => {
             eprintln!(
                 "Warning: Could not connect to database ({}), using disk fallback",
@@ -60,29 +62,30 @@ pub async fn run_config_command(cmd: ConfigCommand) -> anyhow::Result<()> {
         }
     };
 
+    let db_ref = db.as_deref();
     match cmd {
-        ConfigCommand::List { filter } => list_settings(store.as_ref(), filter).await,
-        ConfigCommand::Get { path } => get_setting(store.as_ref(), &path).await,
-        ConfigCommand::Set { path, value } => set_setting(store.as_ref(), &path, &value).await,
-        ConfigCommand::Reset { path } => reset_setting(store.as_ref(), &path).await,
-        ConfigCommand::Path => show_path(store.is_some()),
+        ConfigCommand::List { filter } => list_settings(db_ref, filter).await,
+        ConfigCommand::Get { path } => get_setting(db_ref, &path).await,
+        ConfigCommand::Set { path, value } => set_setting(db_ref, &path, &value).await,
+        ConfigCommand::Reset { path } => reset_setting(db_ref, &path).await,
+        ConfigCommand::Path => show_path(db_ref.is_some()),
     }
 }
 
-/// Bootstrap a DB connection for config commands.
-async fn connect_store() -> anyhow::Result {
+/// Bootstrap a DB connection for config commands (backend-agnostic).
+async fn connect_db() -> anyhow::Result> {
     let config = crate::config::Config::from_env()
         .await
         .map_err(|e| anyhow::anyhow!("{}", e))?;
-    let store = crate::history::Store::new(&config.database).await?;
-    store.run_migrations().await?;
-    Ok(store)
+    crate::db::connect_from_config(&config.database)
+        .await
+        .map_err(|e| anyhow::anyhow!("{}", e))
 }
 
 const DEFAULT_USER_ID: &str = "default";
 
 /// Load settings: DB if available, else disk.
-async fn load_settings(store: Option<&crate::history::Store>) -> Settings {
+async fn load_settings(store: Option<&dyn crate::db::Database>) -> Settings {
     if let Some(store) = store {
         match store.get_all_settings(DEFAULT_USER_ID).await {
             Ok(map) if !map.is_empty() => return Settings::from_db_map(&map),
@@ -94,7 +97,7 @@ async fn load_settings(store: Option<&crate::history::Store>) -> Settings {
 
 /// List all settings.
 async fn list_settings(
-    store: Option<&crate::history::Store>,
+    store: Option<&dyn crate::db::Database>,
     filter: Option,
 ) -> anyhow::Result<()> {
     let settings = load_settings(store).await;
@@ -126,7 +129,7 @@ async fn list_settings(
 }
 
 /// Get a specific setting.
-async fn get_setting(store: Option<&crate::history::Store>, path: &str) -> anyhow::Result<()> {
+async fn get_setting(store: Option<&dyn crate::db::Database>, path: &str) -> anyhow::Result<()> {
     let settings = load_settings(store).await;
 
     match settings.get(path) {
@@ -142,7 +145,7 @@ async fn get_setting(store: Option<&crate::history::Store>, path: &str) -> anyho
 
 /// Set a setting value.
 async fn set_setting(
-    store: Option<&crate::history::Store>,
+    store: Option<&dyn crate::db::Database>,
     path: &str,
     value: &str,
 ) -> anyhow::Result<()> {
@@ -171,7 +174,7 @@ async fn set_setting(
 }
 
 /// Reset a setting to default.
-async fn reset_setting(store: Option<&crate::history::Store>, path: &str) -> anyhow::Result<()> {
+async fn reset_setting(store: Option<&dyn crate::db::Database>, path: &str) -> anyhow::Result<()> {
     let default = Settings::default();
     let default_value = default
         .get(path)
@@ -196,7 +199,7 @@ async fn reset_setting(store: Option<&crate::history::Store>, path: &str) -> any
 /// Show the settings storage info.
 fn show_path(has_db: bool) -> anyhow::Result<()> {
     if has_db {
-        println!("Settings stored in: PostgreSQL (settings table)");
+        println!("Settings stored in: database (settings table)");
         println!(
             "Bootstrap config:   {}",
             crate::bootstrap::BootstrapConfig::default_path().display()
diff --git a/src/cli/mcp.rs b/src/cli/mcp.rs
index 4600e302..e61b65de 100644
--- a/src/cli/mcp.rs
+++ b/src/cli/mcp.rs
@@ -8,8 +8,10 @@ use std::sync::Arc;
 use clap::Subcommand;
 
 use crate::config::Config;
-use crate::history::Store;
-use crate::secrets::{PostgresSecretsStore, SecretsCrypto, SecretsStore};
+use crate::db::Database;
+#[cfg(feature = "postgres")]
+use crate::secrets::PostgresSecretsStore;
+use crate::secrets::{SecretsCrypto, SecretsStore};
 use crate::tools::mcp::{
     McpClient, McpServerConfig, McpSessionManager, OAuthConfig,
     auth::{authorize_mcp_server, is_authenticated},
@@ -172,10 +174,10 @@ async fn add_server(
     config.validate()?;
 
     // Save (DB if available, else disk)
-    let store = connect_store().await;
-    let mut servers = load_servers(store.as_ref()).await?;
+    let db = connect_db().await;
+    let mut servers = load_servers(db.as_deref()).await?;
     servers.upsert(config);
-    save_servers(store.as_ref(), &servers).await?;
+    save_servers(db.as_deref(), &servers).await?;
 
     println!();
     println!("  ✓ Added MCP server '{}'", name);
@@ -193,12 +195,12 @@ async fn add_server(
 
 /// Remove an MCP server.
 async fn remove_server(name: String) -> anyhow::Result<()> {
-    let store = connect_store().await;
-    let mut servers = load_servers(store.as_ref()).await?;
+    let db = connect_db().await;
+    let mut servers = load_servers(db.as_deref()).await?;
     if !servers.remove(&name) {
         anyhow::bail!("Server '{}' not found", name);
     }
-    save_servers(store.as_ref(), &servers).await?;
+    save_servers(db.as_deref(), &servers).await?;
 
     println!();
     println!("  ✓ Removed MCP server '{}'", name);
@@ -209,8 +211,8 @@ async fn remove_server(name: String) -> anyhow::Result<()> {
 
 /// List configured MCP servers.
 async fn list_servers(verbose: bool) -> anyhow::Result<()> {
-    let store = connect_store().await;
-    let servers = load_servers(store.as_ref()).await?;
+    let db = connect_db().await;
+    let servers = load_servers(db.as_deref()).await?;
 
     if servers.servers.is_empty() {
         println!();
@@ -268,8 +270,8 @@ async fn list_servers(verbose: bool) -> anyhow::Result<()> {
 /// Authenticate with an MCP server.
 async fn auth_server(name: String, user_id: String) -> anyhow::Result<()> {
     // Get server config
-    let store = connect_store().await;
-    let servers = load_servers(store.as_ref()).await?;
+    let db = connect_db().await;
+    let servers = load_servers(db.as_deref()).await?;
     let server = servers
         .get(&name)
         .cloned()
@@ -341,8 +343,8 @@ async fn auth_server(name: String, user_id: String) -> anyhow::Result<()> {
 /// Test connection to an MCP server.
 async fn test_server(name: String, user_id: String) -> anyhow::Result<()> {
     // Get server config
-    let store = connect_store().await;
-    let servers = load_servers(store.as_ref()).await?;
+    let db = connect_db().await;
+    let servers = load_servers(db.as_deref()).await?;
     let server = servers
         .get(&name)
         .cloned()
@@ -437,8 +439,8 @@ async fn test_server(name: String, user_id: String) -> anyhow::Result<()> {
 
 /// Toggle server enabled/disabled state.
 async fn toggle_server(name: String, enable: bool, disable: bool) -> anyhow::Result<()> {
-    let store = connect_store().await;
-    let mut servers = load_servers(store.as_ref()).await?;
+    let db = connect_db().await;
+    let mut servers = load_servers(db.as_deref()).await?;
 
     let server = servers
         .get_mut(&name)
@@ -453,7 +455,7 @@ async fn toggle_server(name: String, enable: bool, disable: bool) -> anyhow::Res
     };
 
     server.enabled = new_state;
-    save_servers(store.as_ref(), &servers).await?;
+    save_servers(db.as_deref(), &servers).await?;
 
     let status = if new_state { "enabled" } else { "disabled" };
     println!();
@@ -465,18 +467,16 @@ async fn toggle_server(name: String, enable: bool, disable: bool) -> anyhow::Res
 
 const DEFAULT_USER_ID: &str = "default";
 
-/// Try to connect to the database store for DB-backed config.
-async fn connect_store() -> Option {
+/// Try to connect to the database (backend-agnostic).
+async fn connect_db() -> Option> {
     let config = Config::from_env().await.ok()?;
-    let store = Store::new(&config.database).await.ok()?;
-    store.run_migrations().await.ok()?;
-    Some(store)
+    crate::db::connect_from_config(&config.database).await.ok()
 }
 
 /// Load MCP servers (DB if available, else disk).
-async fn load_servers(store: Option<&Store>) -> Result {
-    if let Some(store) = store {
-        config::load_mcp_servers_from_db(store, DEFAULT_USER_ID).await
+async fn load_servers(db: Option<&dyn Database>) -> Result {
+    if let Some(db) = db {
+        config::load_mcp_servers_from_db(db, DEFAULT_USER_ID).await
     } else {
         config::load_mcp_servers().await
     }
@@ -484,11 +484,11 @@ async fn load_servers(store: Option<&Store>) -> Result,
+    db: Option<&dyn Database>,
     servers: &McpServersFile,
 ) -> Result<(), config::ConfigError> {
-    if let Some(store) = store {
-        config::save_mcp_servers_to_db(store, DEFAULT_USER_ID, servers).await
+    if let Some(db) = db {
+        config::save_mcp_servers_to_db(db, DEFAULT_USER_ID, servers).await
     } else {
         config::save_mcp_servers(servers).await
     }
@@ -504,14 +504,61 @@ async fn get_secrets_store() -> anyhow::Result,
+    embeddings: Option>,
+) -> anyhow::Result<()> {
+    let mut workspace = Workspace::new_with_db("default", db);
+    if let Some(emb) = embeddings {
+        workspace = workspace.with_embeddings(emb);
+    }
+
+    match cmd {
+        MemoryCommand::Search { query, limit } => search(&workspace, &query, limit).await,
+        MemoryCommand::Read { path } => read(&workspace, &path).await,
+        MemoryCommand::Write {
+            path,
+            content,
+            append,
+        } => write(&workspace, &path, content, append).await,
+        MemoryCommand::Tree { path, depth } => tree(&workspace, &path, depth).await,
+        MemoryCommand::Status => status(&workspace).await,
+    }
+}
+
 #[derive(Subcommand, Debug, Clone)]
 pub enum MemoryCommand {
     /// Search workspace memory (hybrid full-text + semantic)
@@ -55,7 +79,8 @@ pub enum MemoryCommand {
     Status,
 }
 
-/// Run a memory command.
+/// Run a memory command (PostgreSQL backend).
+#[cfg(feature = "postgres")]
 pub async fn run_memory_command(
     cmd: MemoryCommand,
     pool: deadpool_postgres::Pool,
diff --git a/src/cli/mod.rs b/src/cli/mod.rs
index 5823ab68..77ed1f3d 100644
--- a/src/cli/mod.rs
+++ b/src/cli/mod.rs
@@ -18,7 +18,10 @@ mod tool;
 
 pub use config::{ConfigCommand, run_config_command};
 pub use mcp::{McpCommand, run_mcp_command};
-pub use memory::{MemoryCommand, run_memory_command};
+pub use memory::MemoryCommand;
+#[cfg(feature = "postgres")]
+pub use memory::run_memory_command;
+pub use memory::run_memory_command_with_db;
 pub use pairing::{PairingCommand, run_pairing_command, run_pairing_command_with_store};
 pub use status::run_status_command;
 pub use tool::{ToolCommand, run_tool_command};
diff --git a/src/cli/status.rs b/src/cli/status.rs
index 0db48c90..af4585d8 100644
--- a/src/cli/status.rs
+++ b/src/cli/status.rs
@@ -135,6 +135,7 @@ pub async fn run_status_command() -> anyhow::Result<()> {
     Ok(())
 }
 
+#[cfg(feature = "postgres")]
 async fn check_database() -> anyhow::Result<()> {
     let _ = dotenvy::dotenv();
     let settings = Settings::load();
@@ -167,6 +168,12 @@ async fn check_database() -> anyhow::Result<()> {
     Ok(())
 }
 
+#[cfg(not(feature = "postgres"))]
+async fn check_database() -> anyhow::Result<()> {
+    // For non-postgres backends, just report configured
+    Ok(())
+}
+
 fn count_wasm_files(dir: &std::path::Path) -> usize {
     std::fs::read_dir(dir)
         .map(|entries| {
diff --git a/src/cli/tool.rs b/src/cli/tool.rs
index f49c3a8d..299225b8 100644
--- a/src/cli/tool.rs
+++ b/src/cli/tool.rs
@@ -11,8 +11,11 @@ use clap::Subcommand;
 use tokio::fs;
 
 use crate::config::Config;
-use crate::history::Store;
-use crate::secrets::{CreateSecretParams, PostgresSecretsStore, SecretsCrypto, SecretsStore};
+#[allow(unused_imports)]
+use crate::db::Database;
+#[cfg(feature = "postgres")]
+use crate::secrets::PostgresSecretsStore;
+use crate::secrets::{CreateSecretParams, SecretsCrypto, SecretsStore};
 use crate::tools::wasm::{CapabilitiesFile, compute_binary_hash};
 
 /// Default tools directory.
@@ -722,11 +725,58 @@ async fn auth_tool(name: String, dir: Option, user_id: String) -> anyho
         )
     })?;
 
-    let store = Store::new(&config.database).await?;
-    store.run_migrations().await?;
-
     let crypto = SecretsCrypto::new(master_key.clone())?;
-    let secrets_store = Arc::new(PostgresSecretsStore::new(store.pool(), Arc::new(crypto)));
+
+    let secrets_store: Arc = {
+        #[cfg(feature = "postgres")]
+        {
+            let store = crate::history::Store::new(&config.database).await?;
+            store.run_migrations().await?;
+            Arc::new(PostgresSecretsStore::new(store.pool(), Arc::new(crypto)))
+        }
+        #[cfg(all(feature = "libsql", not(feature = "postgres")))]
+        {
+            use crate::db::Database as _;
+            use crate::db::libsql_backend::LibSqlBackend;
+            use secrecy::ExposeSecret as _;
+
+            let default_path = crate::config::default_libsql_path();
+            let db_path = config
+                .database
+                .libsql_path
+                .as_deref()
+                .unwrap_or(&default_path);
+
+            let backend = if let Some(ref url) = config.database.libsql_url {
+                let token = config.database.libsql_auth_token.as_ref().ok_or_else(|| {
+                    anyhow::anyhow!("LIBSQL_AUTH_TOKEN is required when LIBSQL_URL is set")
+                })?;
+                LibSqlBackend::new_remote_replica(db_path, url, token.expose_secret())
+                    .await
+                    .map_err(|e| anyhow::anyhow!("{}", e))?
+            } else {
+                LibSqlBackend::new_local(db_path)
+                    .await
+                    .map_err(|e| anyhow::anyhow!("{}", e))?
+            };
+            backend
+                .run_migrations()
+                .await
+                .map_err(|e| anyhow::anyhow!("{}", e))?;
+
+            Arc::new(crate::secrets::LibSqlSecretsStore::new(
+                backend.shared_db(),
+                Arc::new(crypto),
+            ))
+        }
+        #[cfg(not(any(feature = "postgres", feature = "libsql")))]
+        {
+            let _ = crypto;
+            anyhow::bail!(
+                "No database backend available for secrets. Enable 'postgres' or 'libsql' feature."
+            );
+        }
+    };
 
     // Check if already configured
     let already_configured = secrets_store
@@ -773,29 +823,29 @@ async fn auth_tool(name: String, dir: Option, user_id: String) -> anyho
                     println!("  Validation failed: {}", e);
                     println!();
                     println!("  Falling back to manual entry...");
-                    return auth_tool_manual(&secrets_store, &user_id, &auth).await;
+                    return auth_tool_manual(secrets_store.as_ref(), &user_id, &auth).await;
                 }
             }
         }
 
         // Save the token
-        save_token(&secrets_store, &user_id, &auth, &token).await?;
+        save_token(secrets_store.as_ref(), &user_id, &auth, &token).await?;
         print_success(display_name);
         return Ok(());
     }
 
     // Check for OAuth configuration
     if let Some(ref oauth) = auth.oauth {
-        return auth_tool_oauth(&secrets_store, &user_id, &auth, oauth).await;
+        return auth_tool_oauth(secrets_store.as_ref(), &user_id, &auth, oauth).await;
     }
 
     // Fall back to manual entry
-    auth_tool_manual(&secrets_store, &user_id, &auth).await
+    auth_tool_manual(secrets_store.as_ref(), &user_id, &auth).await
 }
 
 /// OAuth browser-based login flow.
 async fn auth_tool_oauth(
-    store: &PostgresSecretsStore,
+    store: &(dyn SecretsStore + Send + Sync),
     user_id: &str,
     auth: &crate::tools::wasm::AuthCapabilitySchema,
     oauth: &crate::tools::wasm::OAuthConfigSchema,
@@ -1041,7 +1091,7 @@ async fn auth_tool_oauth(
 
 /// Manual token entry flow.
 async fn auth_tool_manual(
-    store: &PostgresSecretsStore,
+    store: &(dyn SecretsStore + Send + Sync),
     user_id: &str,
     auth: &crate::tools::wasm::AuthCapabilitySchema,
 ) -> anyhow::Result<()> {
@@ -1214,7 +1264,7 @@ async fn validate_token(
 
 /// Save token to secrets store.
 async fn save_token(
-    store: &PostgresSecretsStore,
+    store: &(dyn SecretsStore + Send + Sync),
     user_id: &str,
     auth: &crate::tools::wasm::AuthCapabilitySchema,
     token: &str,
diff --git a/src/config.rs b/src/config.rs
index 285a2914..a6ddb158 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -38,7 +38,7 @@ impl Config {
     /// Priority: env var > DB settings > default.
     /// This is the primary way to load config after DB is connected.
     pub async fn from_db(
-        store: &crate::history::Store,
+        store: &dyn crate::db::Database,
         user_id: &str,
         bootstrap: &crate::bootstrap::BootstrapConfig,
     ) -> Result {
@@ -134,17 +134,72 @@ impl TunnelConfig {
     }
 }
 
+/// Which database backend to use.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
+pub enum DatabaseBackend {
+    /// PostgreSQL via deadpool-postgres (default).
+    #[default]
+    Postgres,
+    /// libSQL/Turso embedded database.
+    LibSql,
+}
+
+impl std::str::FromStr for DatabaseBackend {
+    type Err = String;
+
+    fn from_str(s: &str) -> Result {
+        match s.to_lowercase().as_str() {
+            "postgres" | "postgresql" | "pg" => Ok(Self::Postgres),
+            "libsql" | "turso" | "sqlite" => Ok(Self::LibSql),
+            _ => Err(format!(
+                "invalid database backend '{}', expected 'postgres' or 'libsql'",
+                s
+            )),
+        }
+    }
+}
+
 /// Database configuration.
 #[derive(Debug, Clone)]
 pub struct DatabaseConfig {
+    /// Which backend to use (default: Postgres).
+    pub backend: DatabaseBackend,
+
+    // -- PostgreSQL fields --
     pub url: SecretString,
     pub pool_size: usize,
+
+    // -- libSQL fields --
+    /// Path to local libSQL database file (default: ~/.ironclaw/ironclaw.db).
+    pub libsql_path: Option,
+    /// Turso cloud URL for remote sync (optional).
+    pub libsql_url: Option,
+    /// Turso auth token (required when libsql_url is set).
+    pub libsql_auth_token: Option,
 }
 
 impl DatabaseConfig {
     fn resolve(bootstrap: &crate::bootstrap::BootstrapConfig) -> Result {
+        let backend: DatabaseBackend = if let Some(b) = optional_env("DATABASE_BACKEND")? {
+            b.parse().map_err(|e| ConfigError::InvalidValue {
+                key: "DATABASE_BACKEND".to_string(),
+                message: e,
+            })?
+        } else {
+            DatabaseBackend::default()
+        };
+
+        // PostgreSQL URL is required only when using the postgres backend.
+        // For libsql backend, default to an empty placeholder.
         let url = optional_env("DATABASE_URL")?
             .or_else(|| bootstrap.database_url.clone())
+            .or_else(|| {
+                if backend == DatabaseBackend::LibSql {
+                    Some("unused://libsql".to_string())
+                } else {
+                    None
+                }
+            })
             .ok_or_else(|| ConfigError::MissingRequired {
                 key: "database_url".to_string(),
                 hint: "Run 'ironclaw onboard' or set DATABASE_URL environment variable".to_string(),
@@ -160,9 +215,31 @@ impl DatabaseConfig {
             .or(bootstrap.database_pool_size)
             .unwrap_or(10);
 
+        let libsql_path = optional_env("LIBSQL_PATH")?.map(PathBuf::from).or_else(|| {
+            if backend == DatabaseBackend::LibSql {
+                Some(default_libsql_path())
+            } else {
+                None
+            }
+        });
+
+        let libsql_url = optional_env("LIBSQL_URL")?;
+        let libsql_auth_token = optional_env("LIBSQL_AUTH_TOKEN")?.map(SecretString::from);
+
+        if libsql_url.is_some() && libsql_auth_token.is_none() {
+            return Err(ConfigError::MissingRequired {
+                key: "LIBSQL_AUTH_TOKEN".to_string(),
+                hint: "LIBSQL_AUTH_TOKEN is required when LIBSQL_URL is set".to_string(),
+            });
+        }
+
         Ok(Self {
+            backend,
             url: SecretString::from(url),
             pool_size,
+            libsql_path,
+            libsql_url,
+            libsql_auth_token,
         })
     }
 
@@ -172,6 +249,14 @@ impl DatabaseConfig {
     }
 }
 
+/// Default libSQL database path (~/.ironclaw/ironclaw.db).
+pub fn default_libsql_path() -> PathBuf {
+    dirs::home_dir()
+        .unwrap_or_else(|| PathBuf::from("."))
+        .join(".ironclaw")
+        .join("ironclaw.db")
+}
+
 /// Which LLM backend to use.
 ///
 /// Defaults to `NearAi` to keep IronClaw close to the NEAR ecosystem.
diff --git a/src/db/libsql_backend.rs b/src/db/libsql_backend.rs
new file mode 100644
index 00000000..8dde3ad3
--- /dev/null
+++ b/src/db/libsql_backend.rs
@@ -0,0 +1,2609 @@
+//! libSQL/Turso backend for the Database trait.
+//!
+//! Provides an embedded SQLite-compatible database using Turso's libSQL fork.
+//! Supports three modes:
+//! - Local embedded (file-based, no server needed)
+//! - Turso cloud with embedded replica (sync to cloud)
+//! - In-memory (for testing)
+
+use std::collections::HashMap;
+use std::path::Path;
+use std::sync::Arc;
+
+use async_trait::async_trait;
+use chrono::{DateTime, NaiveDateTime, Utc};
+use libsql::{Connection, Database as LibSqlDatabase, params};
+use rust_decimal::Decimal;
+use uuid::Uuid;
+
+use crate::agent::BrokenTool;
+use crate::agent::routine::{
+    NotifyConfig, Routine, RoutineAction, RoutineGuardrails, RoutineRun, RunStatus, Trigger,
+};
+use crate::context::{ActionRecord, JobContext, JobState};
+use crate::db::Database;
+use crate::error::{DatabaseError, WorkspaceError};
+use crate::history::{
+    ConversationMessage, ConversationSummary, JobEventRecord, LlmCallRecord, SandboxJobRecord,
+    SandboxJobSummary, SettingRow,
+};
+use crate::workspace::{
+    MemoryChunk, MemoryDocument, RankedResult, SearchConfig, SearchResult, WorkspaceEntry,
+    reciprocal_rank_fusion,
+};
+
+use crate::db::libsql_migrations;
+
+/// Explicit column list for routines table (matches positional access in `row_to_routine_libsql`).
+const ROUTINE_COLUMNS: &str = "\
+    id, name, description, user_id, enabled, \
+    trigger_type, trigger_config, action_type, action_config, \
+    cooldown_secs, max_concurrent, dedup_window_secs, \
+    notify_channel, notify_user, notify_on_success, notify_on_failure, notify_on_attention, \
+    state, last_run_at, next_fire_at, run_count, consecutive_failures, \
+    created_at, updated_at";
+
+/// Explicit column list for routine_runs table (matches positional access in `row_to_routine_run_libsql`).
+const ROUTINE_RUN_COLUMNS: &str = "\
+    id, routine_id, trigger_type, trigger_detail, started_at, \
+    status, completed_at, result_summary, tokens_used, job_id, created_at";
+
+/// libSQL/Turso database backend.
+///
+/// Stores the `Database` handle in an `Arc` so that the same underlying
+/// database can be shared with stores (SecretsStore, WasmToolStore) that
+/// create their own connections per-operation.
+pub struct LibSqlBackend {
+    db: Arc,
+}
+
+impl LibSqlBackend {
+    /// Create a new local embedded database.
+    pub async fn new_local(path: &Path) -> Result {
+        // Ensure parent directory exists
+        if let Some(parent) = path.parent() {
+            std::fs::create_dir_all(parent).map_err(|e| {
+                DatabaseError::Pool(format!("Failed to create database directory: {}", e))
+            })?;
+        }
+
+        let db = libsql::Builder::new_local(path)
+            .build()
+            .await
+            .map_err(|e| DatabaseError::Pool(format!("Failed to open libSQL database: {}", e)))?;
+
+        Ok(Self { db: Arc::new(db) })
+    }
+
+    /// Create a new in-memory database (for testing).
+    pub async fn new_memory() -> Result {
+        let db = libsql::Builder::new_local(":memory:")
+            .build()
+            .await
+            .map_err(|e| {
+                DatabaseError::Pool(format!("Failed to create in-memory database: {}", e))
+            })?;
+
+        Ok(Self { db: Arc::new(db) })
+    }
+
+    /// Create with Turso cloud sync (embedded replica).
+    pub async fn new_remote_replica(
+        path: &Path,
+        url: &str,
+        auth_token: &str,
+    ) -> Result {
+        if let Some(parent) = path.parent() {
+            std::fs::create_dir_all(parent).map_err(|e| {
+                DatabaseError::Pool(format!("Failed to create database directory: {}", e))
+            })?;
+        }
+
+        let db = libsql::Builder::new_remote_replica(path, url.to_string(), auth_token.to_string())
+            .build()
+            .await
+            .map_err(|e| DatabaseError::Pool(format!("Failed to open remote replica: {}", e)))?;
+
+        Ok(Self { db: Arc::new(db) })
+    }
+
+    /// Get a shared reference to the underlying database handle.
+    ///
+    /// Use this to pass the database to stores (SecretsStore, WasmToolStore)
+    /// that need to create their own connections per-operation.
+    pub fn shared_db(&self) -> Arc {
+        Arc::clone(&self.db)
+    }
+
+    /// Create a new connection to the database.
+    pub fn connect(&self) -> Result {
+        self.db
+            .connect()
+            .map_err(|e| DatabaseError::Pool(format!("Failed to create connection: {}", e)))
+    }
+}
+
+// ==================== Helper functions ====================
+
+/// Parse an ISO-8601 timestamp string from SQLite into DateTime.
+///
+/// Tries multiple formats in order:
+/// 1. RFC 3339 with timezone (e.g. `2024-01-15T10:30:00.123Z`)
+/// 2. Naive datetime with fractional seconds (e.g. `2024-01-15 10:30:00.123`)
+/// 3. Naive datetime without fractional seconds (e.g. `2024-01-15 10:30:00`)
+///
+/// Returns an error if none of the formats match.
+fn parse_timestamp(s: &str) -> Result, String> {
+    // RFC 3339 (our canonical write format)
+    if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
+        return Ok(dt.with_timezone(&Utc));
+    }
+    // Naive with fractional seconds (legacy or SQLite datetime() output)
+    if let Ok(ndt) = NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S%.f") {
+        return Ok(ndt.and_utc());
+    }
+    // Naive without fractional seconds (legacy format)
+    if let Ok(ndt) = NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") {
+        return Ok(ndt.and_utc());
+    }
+    Err(format!("unparseable timestamp: {:?}", s))
+}
+
+/// Format a DateTime for SQLite storage (RFC 3339 with millisecond precision).
+fn fmt_ts(dt: &DateTime) -> String {
+    dt.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
+}
+
+/// Format an optional DateTime.
+fn fmt_opt_ts(dt: &Option>) -> libsql::Value {
+    match dt {
+        Some(dt) => libsql::Value::Text(fmt_ts(dt)),
+        None => libsql::Value::Null,
+    }
+}
+
+fn parse_job_state(s: &str) -> JobState {
+    match s {
+        "pending" => JobState::Pending,
+        "in_progress" => JobState::InProgress,
+        "completed" => JobState::Completed,
+        "submitted" => JobState::Submitted,
+        "accepted" => JobState::Accepted,
+        "failed" => JobState::Failed,
+        "stuck" => JobState::Stuck,
+        "cancelled" => JobState::Cancelled,
+        _ => JobState::Pending,
+    }
+}
+
+/// Extract a text column from a libsql Row, returning empty string for NULL.
+fn get_text(row: &libsql::Row, idx: i32) -> String {
+    row.get::(idx).unwrap_or_default()
+}
+
+/// Extract an optional text column.
+/// Returns None for SQL NULL, preserves empty strings as Some("").
+fn get_opt_text(row: &libsql::Row, idx: i32) -> Option {
+    row.get::(idx).ok()
+}
+
+/// Convert an `Option<&str>` to a `libsql::Value` (Text or Null).
+/// Use this instead of `.unwrap_or("")` to preserve NULL semantics.
+fn opt_text(s: Option<&str>) -> libsql::Value {
+    match s {
+        Some(s) => libsql::Value::Text(s.to_string()),
+        None => libsql::Value::Null,
+    }
+}
+
+/// Convert an `Option` to a `libsql::Value` (Text or Null).
+fn opt_text_owned(s: Option) -> libsql::Value {
+    match s {
+        Some(s) => libsql::Value::Text(s),
+        None => libsql::Value::Null,
+    }
+}
+
+/// Extract an i64 column, defaulting to 0.
+fn get_i64(row: &libsql::Row, idx: i32) -> i64 {
+    row.get::(idx).unwrap_or(0)
+}
+
+/// Extract an optional bool from an integer column.
+fn get_opt_bool(row: &libsql::Row, idx: i32) -> Option {
+    row.get::(idx).ok().map(|v| v != 0)
+}
+
+/// Parse a Decimal from a text column.
+fn get_decimal(row: &libsql::Row, idx: i32) -> Decimal {
+    row.get::(idx)
+        .ok()
+        .and_then(|s| s.parse::().ok())
+        .unwrap_or_default()
+}
+
+/// Parse an optional Decimal from a text column.
+fn get_opt_decimal(row: &libsql::Row, idx: i32) -> Option {
+    row.get::(idx)
+        .ok()
+        .and_then(|s| s.parse::().ok())
+}
+
+/// Parse a JSON value from a text column.
+fn get_json(row: &libsql::Row, idx: i32) -> serde_json::Value {
+    row.get::(idx)
+        .ok()
+        .and_then(|s| serde_json::from_str(&s).ok())
+        .unwrap_or(serde_json::Value::Null)
+}
+
+/// Parse a timestamp from a text column.
+///
+/// If the column is NULL or the value cannot be parsed, logs a warning and
+/// returns the Unix epoch (1970-01-01T00:00:00Z) so the error is detectable
+/// rather than silently replaced by the current time.
+fn get_ts(row: &libsql::Row, idx: i32) -> DateTime {
+    match row.get::(idx) {
+        Ok(s) => match parse_timestamp(&s) {
+            Ok(dt) => dt,
+            Err(e) => {
+                tracing::warn!("Timestamp parse failure at column {}: {}", idx, e);
+                DateTime::UNIX_EPOCH
+            }
+        },
+        Err(_) => DateTime::UNIX_EPOCH,
+    }
+}
+
+/// Parse an optional timestamp from a text column.
+///
+/// Returns None if the column is NULL. Logs a warning and returns None if the
+/// value is present but cannot be parsed.
+fn get_opt_ts(row: &libsql::Row, idx: i32) -> Option> {
+    match row.get::(idx) {
+        Ok(s) if s.is_empty() => None,
+        Ok(s) => match parse_timestamp(&s) {
+            Ok(dt) => Some(dt),
+            Err(e) => {
+                tracing::warn!("Timestamp parse failure at column {}: {}", idx, e);
+                None
+            }
+        },
+        Err(_) => None,
+    }
+}
+
+#[async_trait]
+impl Database for LibSqlBackend {
+    async fn run_migrations(&self) -> Result<(), DatabaseError> {
+        let conn = self.connect()?;
+        conn.execute_batch(libsql_migrations::SCHEMA)
+            .await
+            .map_err(|e| DatabaseError::Migration(format!("libSQL migration failed: {}", e)))?;
+        Ok(())
+    }
+
+    // ==================== Conversations ====================
+
+    async fn create_conversation(
+        &self,
+        channel: &str,
+        user_id: &str,
+        thread_id: Option<&str>,
+    ) -> Result {
+        let conn = self.connect()?;
+        let id = Uuid::new_v4();
+        conn.execute(
+            "INSERT INTO conversations (id, channel, user_id, thread_id) VALUES (?1, ?2, ?3, ?4)",
+            params![id.to_string(), channel, user_id, opt_text(thread_id)],
+        )
+        .await
+        .map_err(|e| DatabaseError::Query(e.to_string()))?;
+        Ok(id)
+    }
+
+    async fn touch_conversation(&self, id: Uuid) -> Result<(), DatabaseError> {
+        let conn = self.connect()?;
+        let now = fmt_ts(&Utc::now());
+        conn.execute(
+            "UPDATE conversations SET last_activity = ?2 WHERE id = ?1",
+            params![id.to_string(), now],
+        )
+        .await
+        .map_err(|e| DatabaseError::Query(e.to_string()))?;
+        Ok(())
+    }
+
+    async fn add_conversation_message(
+        &self,
+        conversation_id: Uuid,
+        role: &str,
+        content: &str,
+    ) -> Result {
+        let conn = self.connect()?;
+        let id = Uuid::new_v4();
+        conn.execute(
+                "INSERT INTO conversation_messages (id, conversation_id, role, content) VALUES (?1, ?2, ?3, ?4)",
+                params![id.to_string(), conversation_id.to_string(), role, content],
+            )
+            .await
+            .map_err(|e| DatabaseError::Query(e.to_string()))?;
+        self.touch_conversation(conversation_id).await?;
+        Ok(id)
+    }
+
+    async fn ensure_conversation(
+        &self,
+        id: Uuid,
+        channel: &str,
+        user_id: &str,
+        thread_id: Option<&str>,
+    ) -> Result<(), DatabaseError> {
+        let conn = self.connect()?;
+        let now = fmt_ts(&Utc::now());
+        conn.execute(
+            r#"
+                INSERT INTO conversations (id, channel, user_id, thread_id)
+                VALUES (?1, ?2, ?3, ?4)
+                ON CONFLICT (id) DO UPDATE SET last_activity = ?5
+                "#,
+            params![id.to_string(), channel, user_id, opt_text(thread_id), now],
+        )
+        .await
+        .map_err(|e| DatabaseError::Query(e.to_string()))?;
+        Ok(())
+    }
+
+    async fn list_conversations_with_preview(
+        &self,
+        user_id: &str,
+        channel: &str,
+        limit: i64,
+    ) -> Result, DatabaseError> {
+        let conn = self.connect()?;
+        let mut rows = conn
+            .query(
+                r#"
+                SELECT
+                    c.id,
+                    c.started_at,
+                    c.last_activity,
+                    c.metadata,
+                    (SELECT COUNT(*) FROM conversation_messages m WHERE m.conversation_id = c.id) AS message_count,
+                    (SELECT substr(m2.content, 1, 100)
+                     FROM conversation_messages m2
+                     WHERE m2.conversation_id = c.id AND m2.role = 'user'
+                     ORDER BY m2.created_at ASC
+                     LIMIT 1
+                    ) AS title
+                FROM conversations c
+                WHERE c.user_id = ?1 AND c.channel = ?2
+                ORDER BY c.last_activity DESC
+                LIMIT ?3
+                "#,
+                params![user_id, channel, limit],
+            )
+            .await
+            .map_err(|e| DatabaseError::Query(e.to_string()))?;
+
+        let mut results = Vec::new();
+        while let Some(row) = rows
+            .next()
+            .await
+            .map_err(|e| DatabaseError::Query(e.to_string()))?
+        {
+            let metadata = get_json(&row, 3);
+            let thread_type = metadata
+                .get("thread_type")
+                .and_then(|v| v.as_str())
+                .map(String::from);
+            results.push(ConversationSummary {
+                id: row
+                    .get::(0)
+                    .unwrap_or_default()
+                    .parse()
+                    .unwrap_or_default(),
+                started_at: get_ts(&row, 1),
+                last_activity: get_ts(&row, 2),
+                message_count: get_i64(&row, 4),
+                title: get_opt_text(&row, 5),
+                thread_type,
+            });
+        }
+        Ok(results)
+    }
+
+    async fn get_or_create_assistant_conversation(
+        &self,
+        user_id: &str,
+        channel: &str,
+    ) -> Result {
+        let conn = self.connect()?;
+        // Try to find existing
+        let mut rows = conn
+            .query(
+                r#"
+                SELECT id FROM conversations
+                WHERE user_id = ?1 AND channel = ?2
+                  AND json_extract(metadata, '$.thread_type') = 'assistant'
+                LIMIT 1
+                "#,
+                params![user_id, channel],
+            )
+            .await
+            .map_err(|e| DatabaseError::Query(e.to_string()))?;
+
+        if let Some(row) = rows
+            .next()
+            .await
+            .map_err(|e| DatabaseError::Query(e.to_string()))?
+        {
+            let id_str: String = row.get(0).unwrap_or_default();
+            return id_str
+                .parse()
+                .map_err(|_| DatabaseError::Serialization("Invalid UUID".to_string()));
+        }
+
+        // Create new
+        let id = Uuid::new_v4();
+        let metadata = serde_json::json!({"thread_type": "assistant", "title": "Assistant"});
+        conn.execute(
+            "INSERT INTO conversations (id, channel, user_id, metadata) VALUES (?1, ?2, ?3, ?4)",
+            params![id.to_string(), channel, user_id, metadata.to_string()],
+        )
+        .await
+        .map_err(|e| DatabaseError::Query(e.to_string()))?;
+        Ok(id)
+    }
+
+    async fn create_conversation_with_metadata(
+        &self,
+        channel: &str,
+        user_id: &str,
+        metadata: &serde_json::Value,
+    ) -> Result {
+        let conn = self.connect()?;
+        let id = Uuid::new_v4();
+        conn.execute(
+            "INSERT INTO conversations (id, channel, user_id, metadata) VALUES (?1, ?2, ?3, ?4)",
+            params![id.to_string(), channel, user_id, metadata.to_string()],
+        )
+        .await
+        .map_err(|e| DatabaseError::Query(e.to_string()))?;
+        Ok(id)
+    }
+
+    async fn list_conversation_messages_paginated(
+        &self,
+        conversation_id: Uuid,
+        before: Option>,
+        limit: i64,
+    ) -> Result<(Vec, bool), DatabaseError> {
+        let conn = self.connect()?;
+        let fetch_limit = limit + 1;
+        let cid = conversation_id.to_string();
+
+        let mut rows = if let Some(before_ts) = before {
+            conn.query(
+                r#"
+                    SELECT id, role, content, created_at
+                    FROM conversation_messages
+                    WHERE conversation_id = ?1 AND created_at < ?2
+                    ORDER BY created_at DESC
+                    LIMIT ?3
+                    "#,
+                params![cid, fmt_ts(&before_ts), fetch_limit],
+            )
+            .await
+        } else {
+            conn.query(
+                r#"
+                    SELECT id, role, content, created_at
+                    FROM conversation_messages
+                    WHERE conversation_id = ?1
+                    ORDER BY created_at DESC
+                    LIMIT ?2
+                    "#,
+                params![cid, fetch_limit],
+            )
+            .await
+        }
+        .map_err(|e| DatabaseError::Query(e.to_string()))?;
+
+        let mut all = Vec::new();
+        while let Some(row) = rows
+            .next()
+            .await
+            .map_err(|e| DatabaseError::Query(e.to_string()))?
+        {
+            all.push(ConversationMessage {
+                id: get_text(&row, 0).parse().unwrap_or_default(),
+                role: get_text(&row, 1),
+                content: get_text(&row, 2),
+                created_at: get_ts(&row, 3),
+            });
+        }
+
+        let has_more = all.len() as i64 > limit;
+        all.truncate(limit as usize);
+        all.reverse(); // oldest first
+        Ok((all, has_more))
+    }
+
+    async fn update_conversation_metadata_field(
+        &self,
+        id: Uuid,
+        key: &str,
+        value: &serde_json::Value,
+    ) -> Result<(), DatabaseError> {
+        let conn = self.connect()?;
+        // SQLite: use json_patch to merge the key
+        let patch = serde_json::json!({ key: value });
+        conn.execute(
+            "UPDATE conversations SET metadata = json_patch(metadata, ?2) WHERE id = ?1",
+            params![id.to_string(), patch.to_string()],
+        )
+        .await
+        .map_err(|e| DatabaseError::Query(e.to_string()))?;
+        Ok(())
+    }
+
+    async fn get_conversation_metadata(
+        &self,
+        id: Uuid,
+    ) -> Result, DatabaseError> {
+        let conn = self.connect()?;
+        let mut rows = conn
+            .query(
+                "SELECT metadata FROM conversations WHERE id = ?1",
+                params![id.to_string()],
+            )
+            .await
+            .map_err(|e| DatabaseError::Query(e.to_string()))?;
+
+        match rows
+            .next()
+            .await
+            .map_err(|e| DatabaseError::Query(e.to_string()))?
+        {
+            Some(row) => Ok(Some(get_json(&row, 0))),
+            None => Ok(None),
+        }
+    }
+
+    async fn list_conversation_messages(
+        &self,
+        conversation_id: Uuid,
+    ) -> Result, DatabaseError> {
+        let conn = self.connect()?;
+        let mut rows = conn
+            .query(
+                r#"
+                SELECT id, role, content, created_at
+                FROM conversation_messages
+                WHERE conversation_id = ?1
+                ORDER BY created_at ASC
+                "#,
+                params![conversation_id.to_string()],
+            )
+            .await
+            .map_err(|e| DatabaseError::Query(e.to_string()))?;
+
+        let mut messages = Vec::new();
+        while let Some(row) = rows
+            .next()
+            .await
+            .map_err(|e| DatabaseError::Query(e.to_string()))?
+        {
+            messages.push(ConversationMessage {
+                id: get_text(&row, 0).parse().unwrap_or_default(),
+                role: get_text(&row, 1),
+                content: get_text(&row, 2),
+                created_at: get_ts(&row, 3),
+            });
+        }
+        Ok(messages)
+    }
+
+    async fn conversation_belongs_to_user(
+        &self,
+        conversation_id: Uuid,
+        user_id: &str,
+    ) -> Result {
+        let conn = self.connect()?;
+        let mut rows = conn
+            .query(
+                "SELECT 1 FROM conversations WHERE id = ?1 AND user_id = ?2",
+                libsql::params![conversation_id.to_string(), user_id],
+            )
+            .await
+            .map_err(|e| DatabaseError::Query(e.to_string()))?;
+        let found = rows
+            .next()
+            .await
+            .map_err(|e| DatabaseError::Query(e.to_string()))?;
+        Ok(found.is_some())
+    }
+
+    // ==================== Jobs ====================
+
+    async fn save_job(&self, ctx: &JobContext) -> Result<(), DatabaseError> {
+        let conn = self.connect()?;
+        let status = ctx.state.to_string();
+        let estimated_time_secs = ctx.estimated_duration.map(|d| d.as_secs() as i64);
+
+        conn
+            .execute(
+                r#"
+                INSERT INTO agent_jobs (
+                    id, conversation_id, title, description, category, status, source,
+                    budget_amount, budget_token, bid_amount, estimated_cost, estimated_time_secs,
+                    actual_cost, repair_attempts, created_at, started_at, completed_at
+                ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)
+                ON CONFLICT (id) DO UPDATE SET
+                    title = excluded.title,
+                    description = excluded.description,
+                    category = excluded.category,
+                    status = excluded.status,
+                    estimated_cost = excluded.estimated_cost,
+                    estimated_time_secs = excluded.estimated_time_secs,
+                    actual_cost = excluded.actual_cost,
+                    repair_attempts = excluded.repair_attempts,
+                    started_at = excluded.started_at,
+                    completed_at = excluded.completed_at
+                "#,
+                params![
+                    ctx.job_id.to_string(),
+                    opt_text_owned(ctx.conversation_id.map(|id| id.to_string())),
+                    ctx.title.as_str(),
+                    ctx.description.as_str(),
+                    opt_text(ctx.category.as_deref()),
+                    status,
+                    "direct",
+                    opt_text_owned(ctx.budget.map(|d| d.to_string())),
+                    opt_text(ctx.budget_token.as_deref()),
+                    opt_text_owned(ctx.bid_amount.map(|d| d.to_string())),
+                    opt_text_owned(ctx.estimated_cost.map(|d| d.to_string())),
+                    estimated_time_secs,
+                    ctx.actual_cost.to_string(),
+                    ctx.repair_attempts as i64,
+                    fmt_ts(&ctx.created_at),
+                    fmt_opt_ts(&ctx.started_at),
+                    fmt_opt_ts(&ctx.completed_at),
+                ],
+            )
+            .await
+            .map_err(|e| DatabaseError::Query(e.to_string()))?;
+        Ok(())
+    }
+
+    async fn get_job(&self, id: Uuid) -> Result, DatabaseError> {
+        let conn = self.connect()?;
+        let mut rows = conn
+            .query(
+                r#"
+                SELECT id, conversation_id, title, description, category, status, user_id,
+                       budget_amount, budget_token, bid_amount, estimated_cost, estimated_time_secs,
+                       actual_cost, repair_attempts, created_at, started_at, completed_at
+                FROM agent_jobs WHERE id = ?1
+                "#,
+                params![id.to_string()],
+            )
+            .await
+            .map_err(|e| DatabaseError::Query(e.to_string()))?;
+
+        match rows
+            .next()
+            .await
+            .map_err(|e| DatabaseError::Query(e.to_string()))?
+        {
+            Some(row) => {
+                let status_str = get_text(&row, 5);
+                let state = parse_job_state(&status_str);
+                let estimated_time_secs: Option = row.get::(11).ok();
+
+                Ok(Some(JobContext {
+                    job_id: get_text(&row, 0).parse().unwrap_or_default(),
+                    state,
+                    user_id: get_text(&row, 6),
+                    conversation_id: get_opt_text(&row, 1).and_then(|s| s.parse().ok()),
+                    title: get_text(&row, 2),
+                    description: get_text(&row, 3),
+                    category: get_opt_text(&row, 4),
+                    budget: get_opt_decimal(&row, 7),
+                    budget_token: get_opt_text(&row, 8),
+                    bid_amount: get_opt_decimal(&row, 9),
+                    estimated_cost: get_opt_decimal(&row, 10),
+                    estimated_duration: estimated_time_secs
+                        .map(|s| std::time::Duration::from_secs(s as u64)),
+                    actual_cost: get_decimal(&row, 12),
+                    total_tokens_used: 0,
+                    max_tokens: 0,
+                    repair_attempts: get_i64(&row, 13) as u32,
+                    created_at: get_ts(&row, 14),
+                    started_at: get_opt_ts(&row, 15),
+                    completed_at: get_opt_ts(&row, 16),
+                    transitions: Vec::new(),
+                    metadata: serde_json::Value::Null,
+                }))
+            }
+            None => Ok(None),
+        }
+    }
+
+    async fn update_job_status(
+        &self,
+        id: Uuid,
+        status: JobState,
+        failure_reason: Option<&str>,
+    ) -> Result<(), DatabaseError> {
+        let conn = self.connect()?;
+        conn.execute(
+            "UPDATE agent_jobs SET status = ?2, failure_reason = ?3 WHERE id = ?1",
+            params![id.to_string(), status.to_string(), opt_text(failure_reason)],
+        )
+        .await
+        .map_err(|e| DatabaseError::Query(e.to_string()))?;
+        Ok(())
+    }
+
+    async fn mark_job_stuck(&self, id: Uuid) -> Result<(), DatabaseError> {
+        let conn = self.connect()?;
+        let now = fmt_ts(&Utc::now());
+        conn.execute(
+            "UPDATE agent_jobs SET status = 'stuck', stuck_since = ?2 WHERE id = ?1",
+            params![id.to_string(), now],
+        )
+        .await
+        .map_err(|e| DatabaseError::Query(e.to_string()))?;
+        Ok(())
+    }
+
+    async fn get_stuck_jobs(&self) -> Result, DatabaseError> {
+        let conn = self.connect()?;
+        let mut rows = conn
+            .query("SELECT id FROM agent_jobs WHERE status = 'stuck'", ())
+            .await
+            .map_err(|e| DatabaseError::Query(e.to_string()))?;
+
+        let mut ids = Vec::new();
+        while let Some(row) = rows
+            .next()
+            .await
+            .map_err(|e| DatabaseError::Query(e.to_string()))?
+        {
+            if let Ok(id_str) = row.get::(0)
+                && let Ok(id) = id_str.parse()
+            {
+                ids.push(id);
+            }
+        }
+        Ok(ids)
+    }
+
+    // ==================== Actions ====================
+
+    async fn save_action(&self, job_id: Uuid, action: &ActionRecord) -> Result<(), DatabaseError> {
+        let conn = self.connect()?;
+        let duration_ms = action.duration.as_millis() as i64;
+        let warnings_json = serde_json::to_string(&action.sanitization_warnings)
+            .map_err(|e| DatabaseError::Serialization(e.to_string()))?;
+
+        conn.execute(
+            r#"
+                INSERT INTO job_actions (
+                    id, job_id, sequence_num, tool_name, input, output_raw, output_sanitized,
+                    sanitization_warnings, cost, duration_ms, success, error_message, created_at
+                ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)
+                "#,
+            params![
+                action.id.to_string(),
+                job_id.to_string(),
+                action.sequence as i64,
+                action.tool_name.as_str(),
+                action.input.to_string(),
+                opt_text(action.output_raw.as_deref()),
+                opt_text_owned(action.output_sanitized.as_ref().map(|v| v.to_string())),
+                warnings_json,
+                opt_text_owned(action.cost.map(|d| d.to_string())),
+                duration_ms,
+                action.success as i64,
+                opt_text(action.error.as_deref()),
+                fmt_ts(&action.executed_at),
+            ],
+        )
+        .await
+        .map_err(|e| DatabaseError::Query(e.to_string()))?;
+        Ok(())
+    }
+
+    async fn get_job_actions(&self, job_id: Uuid) -> Result, DatabaseError> {
+        let conn = self.connect()?;
+        let mut rows = conn
+            .query(
+                r#"
+                SELECT id, sequence_num, tool_name, input, output_raw, output_sanitized,
+                       sanitization_warnings, cost, duration_ms, success, error_message, created_at
+                FROM job_actions WHERE job_id = ?1 ORDER BY sequence_num
+                "#,
+                params![job_id.to_string()],
+            )
+            .await
+            .map_err(|e| DatabaseError::Query(e.to_string()))?;
+
+        let mut actions = Vec::new();
+        while let Some(row) = rows
+            .next()
+            .await
+            .map_err(|e| DatabaseError::Query(e.to_string()))?
+        {
+            let warnings: Vec =
+                serde_json::from_str(&get_text(&row, 6)).unwrap_or_default();
+            actions.push(ActionRecord {
+                id: get_text(&row, 0).parse().unwrap_or_default(),
+                sequence: get_i64(&row, 1) as u32,
+                tool_name: get_text(&row, 2),
+                input: get_json(&row, 3),
+                output_raw: get_opt_text(&row, 4),
+                output_sanitized: get_opt_text(&row, 5).and_then(|s| serde_json::from_str(&s).ok()),
+                sanitization_warnings: warnings,
+                cost: get_opt_decimal(&row, 7),
+                duration: std::time::Duration::from_millis(get_i64(&row, 8) as u64),
+                success: get_i64(&row, 9) != 0,
+                error: get_opt_text(&row, 10),
+                executed_at: get_ts(&row, 11),
+            });
+        }
+        Ok(actions)
+    }
+
+    // ==================== LLM Calls ====================
+
+    async fn record_llm_call(&self, record: &LlmCallRecord<'_>) -> Result {
+        let conn = self.connect()?;
+        let id = Uuid::new_v4();
+        conn.execute(
+                r#"
+                INSERT INTO llm_calls (id, job_id, conversation_id, provider, model, input_tokens, output_tokens, cost, purpose)
+                VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
+                "#,
+                params![
+                    id.to_string(),
+                    opt_text_owned(record.job_id.map(|id| id.to_string())),
+                    opt_text_owned(record.conversation_id.map(|id| id.to_string())),
+                    record.provider,
+                    record.model,
+                    record.input_tokens as i64,
+                    record.output_tokens as i64,
+                    record.cost.to_string(),
+                    opt_text(record.purpose),
+                ],
+            )
+            .await
+            .map_err(|e| DatabaseError::Query(e.to_string()))?;
+        Ok(id)
+    }
+
+    // ==================== Estimation Snapshots ====================
+
+    async fn save_estimation_snapshot(
+        &self,
+        job_id: Uuid,
+        category: &str,
+        tool_names: &[String],
+        estimated_cost: Decimal,
+        estimated_time_secs: i32,
+        estimated_value: Decimal,
+    ) -> Result {
+        let conn = self.connect()?;
+        let id = Uuid::new_v4();
+        let tools_json = serde_json::to_string(tool_names)
+            .map_err(|e| DatabaseError::Serialization(e.to_string()))?;
+
+        conn.execute(
+                r#"
+                INSERT INTO estimation_snapshots (id, job_id, category, tool_names, estimated_cost, estimated_time_secs, estimated_value)
+                VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
+                "#,
+                params![
+                    id.to_string(),
+                    job_id.to_string(),
+                    category,
+                    tools_json,
+                    estimated_cost.to_string(),
+                    estimated_time_secs as i64,
+                    estimated_value.to_string(),
+                ],
+            )
+            .await
+            .map_err(|e| DatabaseError::Query(e.to_string()))?;
+        Ok(id)
+    }
+
+    async fn update_estimation_actuals(
+        &self,
+        id: Uuid,
+        actual_cost: Decimal,
+        actual_time_secs: i32,
+        actual_value: Option,
+    ) -> Result<(), DatabaseError> {
+        let conn = self.connect()?;
+        conn.execute(
+                "UPDATE estimation_snapshots SET actual_cost = ?2, actual_time_secs = ?3, actual_value = ?4 WHERE id = ?1",
+                params![
+                    id.to_string(),
+                    actual_cost.to_string(),
+                    actual_time_secs as i64,
+                    actual_value.map(|d| d.to_string()).unwrap_or_default(),
+                ],
+            )
+            .await
+            .map_err(|e| DatabaseError::Query(e.to_string()))?;
+        Ok(())
+    }
+
+    // ==================== Sandbox Jobs ====================
+
+    async fn save_sandbox_job(&self, job: &SandboxJobRecord) -> Result<(), DatabaseError> {
+        let conn = self.connect()?;
+        conn.execute(
+            r#"
+                INSERT INTO agent_jobs (
+                    id, title, description, status, source, user_id, project_dir,
+                    success, failure_reason, created_at, started_at, completed_at
+                ) VALUES (?1, ?2, '', ?3, 'sandbox', ?4, ?5, ?6, ?7, ?8, ?9, ?10)
+                ON CONFLICT (id) DO UPDATE SET
+                    status = excluded.status,
+                    success = excluded.success,
+                    failure_reason = excluded.failure_reason,
+                    started_at = excluded.started_at,
+                    completed_at = excluded.completed_at
+                "#,
+            params![
+                job.id.to_string(),
+                job.task.as_str(),
+                job.status.as_str(),
+                job.user_id.as_str(),
+                job.project_dir.as_str(),
+                job.success.map(|b| b as i64),
+                opt_text(job.failure_reason.as_deref()),
+                fmt_ts(&job.created_at),
+                fmt_opt_ts(&job.started_at),
+                fmt_opt_ts(&job.completed_at),
+            ],
+        )
+        .await
+        .map_err(|e| DatabaseError::Query(e.to_string()))?;
+        Ok(())
+    }
+
+    async fn get_sandbox_job(&self, id: Uuid) -> Result, DatabaseError> {
+        let conn = self.connect()?;
+        let mut rows = conn
+            .query(
+                r#"
+                SELECT id, title, status, user_id, project_dir,
+                       success, failure_reason, created_at, started_at, completed_at
+                FROM agent_jobs WHERE id = ?1 AND source = 'sandbox'
+                "#,
+                params![id.to_string()],
+            )
+            .await
+            .map_err(|e| DatabaseError::Query(e.to_string()))?;
+
+        match rows
+            .next()
+            .await
+            .map_err(|e| DatabaseError::Query(e.to_string()))?
+        {
+            Some(row) => Ok(Some(SandboxJobRecord {
+                id: get_text(&row, 0).parse().unwrap_or_default(),
+                task: get_text(&row, 1),
+                status: get_text(&row, 2),
+                user_id: get_text(&row, 3),
+                project_dir: get_text(&row, 4),
+                success: get_opt_bool(&row, 5),
+                failure_reason: get_opt_text(&row, 6),
+                created_at: get_ts(&row, 7),
+                started_at: get_opt_ts(&row, 8),
+                completed_at: get_opt_ts(&row, 9),
+            })),
+            None => Ok(None),
+        }
+    }
+
+    async fn list_sandbox_jobs(&self) -> Result, DatabaseError> {
+        let conn = self.connect()?;
+        let mut rows = conn
+            .query(
+                r#"
+                SELECT id, title, status, user_id, project_dir,
+                       success, failure_reason, created_at, started_at, completed_at
+                FROM agent_jobs WHERE source = 'sandbox'
+                ORDER BY created_at DESC
+                "#,
+                (),
+            )
+            .await
+            .map_err(|e| DatabaseError::Query(e.to_string()))?;
+
+        let mut jobs = Vec::new();
+        while let Some(row) = rows
+            .next()
+            .await
+            .map_err(|e| DatabaseError::Query(e.to_string()))?
+        {
+            jobs.push(SandboxJobRecord {
+                id: get_text(&row, 0).parse().unwrap_or_default(),
+                task: get_text(&row, 1),
+                status: get_text(&row, 2),
+                user_id: get_text(&row, 3),
+                project_dir: get_text(&row, 4),
+                success: get_opt_bool(&row, 5),
+                failure_reason: get_opt_text(&row, 6),
+                created_at: get_ts(&row, 7),
+                started_at: get_opt_ts(&row, 8),
+                completed_at: get_opt_ts(&row, 9),
+            });
+        }
+        Ok(jobs)
+    }
+
+    async fn update_sandbox_job_status(
+        &self,
+        id: Uuid,
+        status: &str,
+        success: Option,
+        message: Option<&str>,
+        started_at: Option>,
+        completed_at: Option>,
+    ) -> Result<(), DatabaseError> {
+        let conn = self.connect()?;
+        conn.execute(
+            r#"
+                UPDATE agent_jobs SET
+                    status = ?2,
+                    success = COALESCE(?3, success),
+                    failure_reason = COALESCE(?4, failure_reason),
+                    started_at = COALESCE(?5, started_at),
+                    completed_at = COALESCE(?6, completed_at)
+                WHERE id = ?1 AND source = 'sandbox'
+                "#,
+            params![
+                id.to_string(),
+                status,
+                success.map(|b| b as i64),
+                message,
+                fmt_opt_ts(&started_at),
+                fmt_opt_ts(&completed_at),
+            ],
+        )
+        .await
+        .map_err(|e| DatabaseError::Query(e.to_string()))?;
+        Ok(())
+    }
+
+    async fn cleanup_stale_sandbox_jobs(&self) -> Result {
+        let conn = self.connect()?;
+        let now = fmt_ts(&Utc::now());
+        let count = conn
+            .execute(
+                r#"
+                UPDATE agent_jobs SET
+                    status = 'interrupted',
+                    failure_reason = 'Process restarted',
+                    completed_at = ?1
+                WHERE source = 'sandbox' AND status IN ('running', 'creating')
+                "#,
+                params![now],
+            )
+            .await
+            .map_err(|e| DatabaseError::Query(e.to_string()))?;
+        if count > 0 {
+            tracing::info!("Marked {} stale sandbox jobs as interrupted", count);
+        }
+        Ok(count)
+    }
+
+    async fn sandbox_job_summary(&self) -> Result {
+        let conn = self.connect()?;
+        let mut rows = conn
+            .query(
+                "SELECT status, COUNT(*) as cnt FROM agent_jobs WHERE source = 'sandbox' GROUP BY status",
+                (),
+            )
+            .await
+            .map_err(|e| DatabaseError::Query(e.to_string()))?;
+
+        let mut summary = SandboxJobSummary::default();
+        while let Some(row) = rows
+            .next()
+            .await
+            .map_err(|e| DatabaseError::Query(e.to_string()))?
+        {
+            let status = get_text(&row, 0);
+            let count = get_i64(&row, 1) as usize;
+            summary.total += count;
+            match status.as_str() {
+                "creating" => summary.creating += count,
+                "running" => summary.running += count,
+                "completed" => summary.completed += count,
+                "failed" => summary.failed += count,
+                "interrupted" => summary.interrupted += count,
+                _ => {}
+            }
+        }
+        Ok(summary)
+    }
+
+    async fn list_sandbox_jobs_for_user(
+        &self,
+        user_id: &str,
+    ) -> Result, DatabaseError> {
+        let conn = self.connect()?;
+        let mut rows = conn
+            .query(
+                r#"
+                SELECT id, title, status, user_id, project_dir,
+                       success, failure_reason, created_at, started_at, completed_at
+                FROM agent_jobs WHERE source = 'sandbox' AND user_id = ?1
+                ORDER BY created_at DESC
+                "#,
+                libsql::params![user_id],
+            )
+            .await
+            .map_err(|e| DatabaseError::Query(e.to_string()))?;
+
+        let mut jobs = Vec::new();
+        while let Some(row) = rows
+            .next()
+            .await
+            .map_err(|e| DatabaseError::Query(e.to_string()))?
+        {
+            jobs.push(SandboxJobRecord {
+                id: get_text(&row, 0).parse().unwrap_or_default(),
+                task: get_text(&row, 1),
+                status: get_text(&row, 2),
+                user_id: get_text(&row, 3),
+                project_dir: get_text(&row, 4),
+                success: get_opt_bool(&row, 5),
+                failure_reason: get_opt_text(&row, 6),
+                created_at: get_ts(&row, 7),
+                started_at: get_opt_ts(&row, 8),
+                completed_at: get_opt_ts(&row, 9),
+            });
+        }
+        Ok(jobs)
+    }
+
+    async fn sandbox_job_summary_for_user(
+        &self,
+        user_id: &str,
+    ) -> Result {
+        let conn = self.connect()?;
+        let mut rows = conn
+            .query(
+                "SELECT status, COUNT(*) as cnt FROM agent_jobs WHERE source = 'sandbox' AND user_id = ?1 GROUP BY status",
+                libsql::params![user_id],
+            )
+            .await
+            .map_err(|e| DatabaseError::Query(e.to_string()))?;
+
+        let mut summary = SandboxJobSummary::default();
+        while let Some(row) = rows
+            .next()
+            .await
+            .map_err(|e| DatabaseError::Query(e.to_string()))?
+        {
+            let status = get_text(&row, 0);
+            let count = get_i64(&row, 1) as usize;
+            summary.total += count;
+            match status.as_str() {
+                "creating" => summary.creating += count,
+                "running" => summary.running += count,
+                "completed" => summary.completed += count,
+                "failed" => summary.failed += count,
+                "interrupted" => summary.interrupted += count,
+                _ => {}
+            }
+        }
+        Ok(summary)
+    }
+
+    async fn sandbox_job_belongs_to_user(
+        &self,
+        job_id: Uuid,
+        user_id: &str,
+    ) -> Result {
+        let conn = self.connect()?;
+        let mut rows = conn
+            .query(
+                "SELECT 1 FROM agent_jobs WHERE id = ?1 AND user_id = ?2 AND source = 'sandbox'",
+                libsql::params![job_id.to_string(), user_id],
+            )
+            .await
+            .map_err(|e| DatabaseError::Query(e.to_string()))?;
+        let found = rows
+            .next()
+            .await
+            .map_err(|e| DatabaseError::Query(e.to_string()))?;
+        Ok(found.is_some())
+    }
+
+    async fn update_sandbox_job_mode(&self, id: Uuid, mode: &str) -> Result<(), DatabaseError> {
+        let conn = self.connect()?;
+        conn.execute(
+            "UPDATE agent_jobs SET job_mode = ?2 WHERE id = ?1",
+            params![id.to_string(), mode],
+        )
+        .await
+        .map_err(|e| DatabaseError::Query(e.to_string()))?;
+        Ok(())
+    }
+
+    async fn get_sandbox_job_mode(&self, id: Uuid) -> Result, DatabaseError> {
+        let conn = self.connect()?;
+        let mut rows = conn
+            .query(
+                "SELECT job_mode FROM agent_jobs WHERE id = ?1",
+                params![id.to_string()],
+            )
+            .await
+            .map_err(|e| DatabaseError::Query(e.to_string()))?;
+
+        match rows
+            .next()
+            .await
+            .map_err(|e| DatabaseError::Query(e.to_string()))?
+        {
+            Some(row) => Ok(Some(get_text(&row, 0))),
+            None => Ok(None),
+        }
+    }
+
+    // ==================== Job Events ====================
+
+    async fn save_job_event(
+        &self,
+        job_id: Uuid,
+        event_type: &str,
+        data: &serde_json::Value,
+    ) -> Result<(), DatabaseError> {
+        let conn = self.connect()?;
+        conn.execute(
+            "INSERT INTO job_events (job_id, event_type, data) VALUES (?1, ?2, ?3)",
+            params![job_id.to_string(), event_type, data.to_string()],
+        )
+        .await
+        .map_err(|e| DatabaseError::Query(e.to_string()))?;
+        Ok(())
+    }
+
+    async fn list_job_events(&self, job_id: Uuid) -> Result, DatabaseError> {
+        let conn = self.connect()?;
+        let mut rows = conn
+            .query(
+                r#"
+                SELECT id, job_id, event_type, data, created_at
+                FROM job_events WHERE job_id = ?1 ORDER BY id ASC
+                "#,
+                params![job_id.to_string()],
+            )
+            .await
+            .map_err(|e| DatabaseError::Query(e.to_string()))?;
+
+        let mut events = Vec::new();
+        while let Some(row) = rows
+            .next()
+            .await
+            .map_err(|e| DatabaseError::Query(e.to_string()))?
+        {
+            events.push(JobEventRecord {
+                id: get_i64(&row, 0),
+                job_id: get_text(&row, 1).parse().unwrap_or_default(),
+                event_type: get_text(&row, 2),
+                data: get_json(&row, 3),
+                created_at: get_ts(&row, 4),
+            });
+        }
+        Ok(events)
+    }
+
+    // ==================== Routines ====================
+
+    async fn create_routine(&self, routine: &Routine) -> Result<(), DatabaseError> {
+        let conn = self.connect()?;
+        let trigger_type = routine.trigger.type_tag();
+        let trigger_config = routine.trigger.to_config_json();
+        let action_type = routine.action.type_tag();
+        let action_config = routine.action.to_config_json();
+        let cooldown_secs = routine.guardrails.cooldown.as_secs() as i64;
+        let max_concurrent = routine.guardrails.max_concurrent as i64;
+        let dedup_window_secs = routine.guardrails.dedup_window.map(|d| d.as_secs() as i64);
+
+        conn.execute(
+                r#"
+                INSERT INTO routines (
+                    id, name, description, user_id, enabled,
+                    trigger_type, trigger_config, action_type, action_config,
+                    cooldown_secs, max_concurrent, dedup_window_secs,
+                    notify_channel, notify_user, notify_on_success, notify_on_failure, notify_on_attention,
+                    state, next_fire_at, created_at, updated_at
+                ) VALUES (
+                    ?1, ?2, ?3, ?4, ?5,
+                    ?6, ?7, ?8, ?9,
+                    ?10, ?11, ?12,
+                    ?13, ?14, ?15, ?16, ?17,
+                    ?18, ?19, ?20, ?21
+                )
+                "#,
+                params![
+                    routine.id.to_string(),
+                    routine.name.as_str(),
+                    routine.description.as_str(),
+                    routine.user_id.as_str(),
+                    routine.enabled as i64,
+                    trigger_type,
+                    trigger_config.to_string(),
+                    action_type,
+                    action_config.to_string(),
+                    cooldown_secs,
+                    max_concurrent,
+                    dedup_window_secs,
+                    opt_text(routine.notify.channel.as_deref()),
+                    routine.notify.user.as_str(),
+                    routine.notify.on_success as i64,
+                    routine.notify.on_failure as i64,
+                    routine.notify.on_attention as i64,
+                    routine.state.to_string(),
+                    fmt_opt_ts(&routine.next_fire_at),
+                    fmt_ts(&routine.created_at),
+                    fmt_ts(&routine.updated_at),
+                ],
+            )
+            .await
+            .map_err(|e| DatabaseError::Query(e.to_string()))?;
+        Ok(())
+    }
+
+    async fn get_routine(&self, id: Uuid) -> Result, DatabaseError> {
+        let conn = self.connect()?;
+        let mut rows = conn
+            .query(
+                &format!("SELECT {} FROM routines WHERE id = ?1", ROUTINE_COLUMNS),
+                params![id.to_string()],
+            )
+            .await
+            .map_err(|e| DatabaseError::Query(e.to_string()))?;
+
+        match rows
+            .next()
+            .await
+            .map_err(|e| DatabaseError::Query(e.to_string()))?
+        {
+            Some(row) => Ok(Some(row_to_routine_libsql(&row)?)),
+            None => Ok(None),
+        }
+    }
+
+    async fn get_routine_by_name(
+        &self,
+        user_id: &str,
+        name: &str,
+    ) -> Result, DatabaseError> {
+        let conn = self.connect()?;
+        let mut rows = conn
+            .query(
+                &format!(
+                    "SELECT {} FROM routines WHERE user_id = ?1 AND name = ?2",
+                    ROUTINE_COLUMNS
+                ),
+                params![user_id, name],
+            )
+            .await
+            .map_err(|e| DatabaseError::Query(e.to_string()))?;
+
+        match rows
+            .next()
+            .await
+            .map_err(|e| DatabaseError::Query(e.to_string()))?
+        {
+            Some(row) => Ok(Some(row_to_routine_libsql(&row)?)),
+            None => Ok(None),
+        }
+    }
+
+    async fn list_routines(&self, user_id: &str) -> Result, DatabaseError> {
+        let conn = self.connect()?;
+        let mut rows = conn
+            .query(
+                &format!(
+                    "SELECT {} FROM routines WHERE user_id = ?1 ORDER BY name",
+                    ROUTINE_COLUMNS
+                ),
+                params![user_id],
+            )
+            .await
+            .map_err(|e| DatabaseError::Query(e.to_string()))?;
+
+        let mut routines = Vec::new();
+        while let Some(row) = rows
+            .next()
+            .await
+            .map_err(|e| DatabaseError::Query(e.to_string()))?
+        {
+            routines.push(row_to_routine_libsql(&row)?);
+        }
+        Ok(routines)
+    }
+
+    async fn list_event_routines(&self) -> Result, DatabaseError> {
+        let conn = self.connect()?;
+        let mut rows = conn
+            .query(
+                &format!(
+                    "SELECT {} FROM routines WHERE enabled = 1 AND trigger_type = 'event'",
+                    ROUTINE_COLUMNS
+                ),
+                (),
+            )
+            .await
+            .map_err(|e| DatabaseError::Query(e.to_string()))?;
+
+        let mut routines = Vec::new();
+        while let Some(row) = rows
+            .next()
+            .await
+            .map_err(|e| DatabaseError::Query(e.to_string()))?
+        {
+            routines.push(row_to_routine_libsql(&row)?);
+        }
+        Ok(routines)
+    }
+
+    async fn list_due_cron_routines(&self) -> Result, DatabaseError> {
+        let conn = self.connect()?;
+        let now = fmt_ts(&Utc::now());
+        let mut rows = conn
+            .query(
+                &format!(
+                    "SELECT {} FROM routines WHERE enabled = 1 AND trigger_type = 'cron' AND next_fire_at IS NOT NULL AND next_fire_at <= ?1",
+                    ROUTINE_COLUMNS
+                ),
+                params![now],
+            )
+            .await
+            .map_err(|e| DatabaseError::Query(e.to_string()))?;
+
+        let mut routines = Vec::new();
+        while let Some(row) = rows
+            .next()
+            .await
+            .map_err(|e| DatabaseError::Query(e.to_string()))?
+        {
+            routines.push(row_to_routine_libsql(&row)?);
+        }
+        Ok(routines)
+    }
+
+    async fn update_routine(&self, routine: &Routine) -> Result<(), DatabaseError> {
+        let conn = self.connect()?;
+        let trigger_type = routine.trigger.type_tag();
+        let trigger_config = routine.trigger.to_config_json();
+        let action_type = routine.action.type_tag();
+        let action_config = routine.action.to_config_json();
+        let cooldown_secs = routine.guardrails.cooldown.as_secs() as i64;
+        let max_concurrent = routine.guardrails.max_concurrent as i64;
+        let dedup_window_secs = routine.guardrails.dedup_window.map(|d| d.as_secs() as i64);
+        let now = fmt_ts(&Utc::now());
+
+        conn.execute(
+            r#"
+                UPDATE routines SET
+                    name = ?2, description = ?3, enabled = ?4,
+                    trigger_type = ?5, trigger_config = ?6,
+                    action_type = ?7, action_config = ?8,
+                    cooldown_secs = ?9, max_concurrent = ?10, dedup_window_secs = ?11,
+                    notify_channel = ?12, notify_user = ?13,
+                    notify_on_success = ?14, notify_on_failure = ?15, notify_on_attention = ?16,
+                    state = ?17, next_fire_at = ?18,
+                    updated_at = ?19
+                WHERE id = ?1
+                "#,
+            params![
+                routine.id.to_string(),
+                routine.name.as_str(),
+                routine.description.as_str(),
+                routine.enabled as i64,
+                trigger_type,
+                trigger_config.to_string(),
+                action_type,
+                action_config.to_string(),
+                cooldown_secs,
+                max_concurrent,
+                dedup_window_secs,
+                opt_text(routine.notify.channel.as_deref()),
+                routine.notify.user.as_str(),
+                routine.notify.on_success as i64,
+                routine.notify.on_failure as i64,
+                routine.notify.on_attention as i64,
+                routine.state.to_string(),
+                fmt_opt_ts(&routine.next_fire_at),
+                now,
+            ],
+        )
+        .await
+        .map_err(|e| DatabaseError::Query(e.to_string()))?;
+        Ok(())
+    }
+
+    async fn update_routine_runtime(
+        &self,
+        id: Uuid,
+        last_run_at: DateTime,
+        next_fire_at: Option>,
+        run_count: u64,
+        consecutive_failures: u32,
+        state: &serde_json::Value,
+    ) -> Result<(), DatabaseError> {
+        let conn = self.connect()?;
+        let now = fmt_ts(&Utc::now());
+        conn.execute(
+            r#"
+                UPDATE routines SET
+                    last_run_at = ?2, next_fire_at = ?3,
+                    run_count = ?4, consecutive_failures = ?5,
+                    state = ?6, updated_at = ?7
+                WHERE id = ?1
+                "#,
+            params![
+                id.to_string(),
+                fmt_ts(&last_run_at),
+                fmt_opt_ts(&next_fire_at),
+                run_count as i64,
+                consecutive_failures as i64,
+                state.to_string(),
+                now,
+            ],
+        )
+        .await
+        .map_err(|e| DatabaseError::Query(e.to_string()))?;
+        Ok(())
+    }
+
+    async fn delete_routine(&self, id: Uuid) -> Result {
+        let conn = self.connect()?;
+        let count = conn
+            .execute(
+                "DELETE FROM routines WHERE id = ?1",
+                params![id.to_string()],
+            )
+            .await
+            .map_err(|e| DatabaseError::Query(e.to_string()))?;
+        Ok(count > 0)
+    }
+
+    // ==================== Routine Runs ====================
+
+    async fn create_routine_run(&self, run: &RoutineRun) -> Result<(), DatabaseError> {
+        let conn = self.connect()?;
+        conn.execute(
+            r#"
+                INSERT INTO routine_runs (
+                    id, routine_id, trigger_type, trigger_detail,
+                    started_at, status, job_id
+                ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
+                "#,
+            params![
+                run.id.to_string(),
+                run.routine_id.to_string(),
+                run.trigger_type.as_str(),
+                opt_text(run.trigger_detail.as_deref()),
+                fmt_ts(&run.started_at),
+                run.status.to_string(),
+                opt_text_owned(run.job_id.map(|id| id.to_string())),
+            ],
+        )
+        .await
+        .map_err(|e| DatabaseError::Query(e.to_string()))?;
+        Ok(())
+    }
+
+    async fn complete_routine_run(
+        &self,
+        id: Uuid,
+        status: RunStatus,
+        result_summary: Option<&str>,
+        tokens_used: Option,
+    ) -> Result<(), DatabaseError> {
+        let conn = self.connect()?;
+        let now = fmt_ts(&Utc::now());
+        conn.execute(
+            r#"
+                UPDATE routine_runs SET
+                    completed_at = ?5, status = ?2,
+                    result_summary = ?3, tokens_used = ?4
+                WHERE id = ?1
+                "#,
+            params![
+                id.to_string(),
+                status.to_string(),
+                opt_text(result_summary),
+                tokens_used.map(|t| t as i64),
+                now,
+            ],
+        )
+        .await
+        .map_err(|e| DatabaseError::Query(e.to_string()))?;
+        Ok(())
+    }
+
+    async fn list_routine_runs(
+        &self,
+        routine_id: Uuid,
+        limit: i64,
+    ) -> Result, DatabaseError> {
+        let conn = self.connect()?;
+        let mut rows = conn
+            .query(
+                &format!(
+                    "SELECT {} FROM routine_runs WHERE routine_id = ?1 ORDER BY started_at DESC LIMIT ?2",
+                    ROUTINE_RUN_COLUMNS
+                ),
+                params![routine_id.to_string(), limit],
+            )
+            .await
+            .map_err(|e| DatabaseError::Query(e.to_string()))?;
+
+        let mut runs = Vec::new();
+        while let Some(row) = rows
+            .next()
+            .await
+            .map_err(|e| DatabaseError::Query(e.to_string()))?
+        {
+            runs.push(row_to_routine_run_libsql(&row)?);
+        }
+        Ok(runs)
+    }
+
+    async fn count_running_routine_runs(&self, routine_id: Uuid) -> Result {
+        let conn = self.connect()?;
+        let mut rows = conn
+            .query(
+                "SELECT COUNT(*) as cnt FROM routine_runs WHERE routine_id = ?1 AND status = 'running'",
+                params![routine_id.to_string()],
+            )
+            .await
+            .map_err(|e| DatabaseError::Query(e.to_string()))?;
+
+        match rows
+            .next()
+            .await
+            .map_err(|e| DatabaseError::Query(e.to_string()))?
+        {
+            Some(row) => Ok(get_i64(&row, 0)),
+            None => Ok(0),
+        }
+    }
+
+    // ==================== Tool Failures ====================
+
+    async fn record_tool_failure(
+        &self,
+        tool_name: &str,
+        error_message: &str,
+    ) -> Result<(), DatabaseError> {
+        let conn = self.connect()?;
+        let now = fmt_ts(&Utc::now());
+        conn.execute(
+            r#"
+                INSERT INTO tool_failures (id, tool_name, error_message, error_count, last_failure)
+                VALUES (?1, ?2, ?3, 1, ?4)
+                ON CONFLICT (tool_name) DO UPDATE SET
+                    error_message = ?3,
+                    error_count = tool_failures.error_count + 1,
+                    last_failure = ?4
+                "#,
+            params![Uuid::new_v4().to_string(), tool_name, error_message, now],
+        )
+        .await
+        .map_err(|e| DatabaseError::Query(e.to_string()))?;
+        Ok(())
+    }
+
+    async fn get_broken_tools(&self, threshold: i32) -> Result, DatabaseError> {
+        let conn = self.connect()?;
+        let mut rows = conn
+            .query(
+                r#"
+                SELECT tool_name, error_message, error_count, first_failure, last_failure,
+                       last_build_result, repair_attempts
+                FROM tool_failures
+                WHERE error_count >= ?1 AND repaired_at IS NULL
+                ORDER BY error_count DESC
+                "#,
+                params![threshold as i64],
+            )
+            .await
+            .map_err(|e| DatabaseError::Query(e.to_string()))?;
+
+        let mut tools = Vec::new();
+        while let Some(row) = rows
+            .next()
+            .await
+            .map_err(|e| DatabaseError::Query(e.to_string()))?
+        {
+            tools.push(BrokenTool {
+                name: get_text(&row, 0),
+                last_error: get_opt_text(&row, 1),
+                failure_count: get_i64(&row, 2) as u32,
+                first_failure: get_ts(&row, 3),
+                last_failure: get_ts(&row, 4),
+                last_build_result: get_opt_text(&row, 5)
+                    .and_then(|s| serde_json::from_str(&s).ok()),
+                repair_attempts: get_i64(&row, 6) as u32,
+            });
+        }
+        Ok(tools)
+    }
+
+    async fn mark_tool_repaired(&self, tool_name: &str) -> Result<(), DatabaseError> {
+        let conn = self.connect()?;
+        let now = fmt_ts(&Utc::now());
+        conn.execute(
+            "UPDATE tool_failures SET repaired_at = ?2, error_count = 0 WHERE tool_name = ?1",
+            params![tool_name, now],
+        )
+        .await
+        .map_err(|e| DatabaseError::Query(e.to_string()))?;
+        Ok(())
+    }
+
+    async fn increment_repair_attempts(&self, tool_name: &str) -> Result<(), DatabaseError> {
+        let conn = self.connect()?;
+        conn.execute(
+            "UPDATE tool_failures SET repair_attempts = repair_attempts + 1 WHERE tool_name = ?1",
+            params![tool_name],
+        )
+        .await
+        .map_err(|e| DatabaseError::Query(e.to_string()))?;
+        Ok(())
+    }
+
+    // ==================== Settings ====================
+
+    async fn get_setting(
+        &self,
+        user_id: &str,
+        key: &str,
+    ) -> Result, DatabaseError> {
+        let conn = self.connect()?;
+        let mut rows = conn
+            .query(
+                "SELECT value FROM settings WHERE user_id = ?1 AND key = ?2",
+                params![user_id, key],
+            )
+            .await
+            .map_err(|e| DatabaseError::Query(e.to_string()))?;
+
+        match rows
+            .next()
+            .await
+            .map_err(|e| DatabaseError::Query(e.to_string()))?
+        {
+            Some(row) => Ok(Some(get_json(&row, 0))),
+            None => Ok(None),
+        }
+    }
+
+    async fn get_setting_full(
+        &self,
+        user_id: &str,
+        key: &str,
+    ) -> Result, DatabaseError> {
+        let conn = self.connect()?;
+        let mut rows = conn
+            .query(
+                "SELECT key, value, updated_at FROM settings WHERE user_id = ?1 AND key = ?2",
+                params![user_id, key],
+            )
+            .await
+            .map_err(|e| DatabaseError::Query(e.to_string()))?;
+
+        match rows
+            .next()
+            .await
+            .map_err(|e| DatabaseError::Query(e.to_string()))?
+        {
+            Some(row) => Ok(Some(SettingRow {
+                key: get_text(&row, 0),
+                value: get_json(&row, 1),
+                updated_at: get_ts(&row, 2),
+            })),
+            None => Ok(None),
+        }
+    }
+
+    async fn set_setting(
+        &self,
+        user_id: &str,
+        key: &str,
+        value: &serde_json::Value,
+    ) -> Result<(), DatabaseError> {
+        let conn = self.connect()?;
+        let now = fmt_ts(&Utc::now());
+        conn.execute(
+            r#"
+                INSERT INTO settings (user_id, key, value, updated_at)
+                VALUES (?1, ?2, ?3, ?4)
+                ON CONFLICT (user_id, key) DO UPDATE SET
+                    value = excluded.value,
+                    updated_at = ?4
+                "#,
+            params![user_id, key, value.to_string(), now],
+        )
+        .await
+        .map_err(|e| DatabaseError::Query(e.to_string()))?;
+        Ok(())
+    }
+
+    async fn delete_setting(&self, user_id: &str, key: &str) -> Result {
+        let conn = self.connect()?;
+        let count = conn
+            .execute(
+                "DELETE FROM settings WHERE user_id = ?1 AND key = ?2",
+                params![user_id, key],
+            )
+            .await
+            .map_err(|e| DatabaseError::Query(e.to_string()))?;
+        Ok(count > 0)
+    }
+
+    async fn list_settings(&self, user_id: &str) -> Result, DatabaseError> {
+        let conn = self.connect()?;
+        let mut rows = conn
+            .query(
+                "SELECT key, value, updated_at FROM settings WHERE user_id = ?1 ORDER BY key",
+                params![user_id],
+            )
+            .await
+            .map_err(|e| DatabaseError::Query(e.to_string()))?;
+
+        let mut settings = Vec::new();
+        while let Some(row) = rows
+            .next()
+            .await
+            .map_err(|e| DatabaseError::Query(e.to_string()))?
+        {
+            settings.push(SettingRow {
+                key: get_text(&row, 0),
+                value: get_json(&row, 1),
+                updated_at: get_ts(&row, 2),
+            });
+        }
+        Ok(settings)
+    }
+
+    async fn get_all_settings(
+        &self,
+        user_id: &str,
+    ) -> Result, DatabaseError> {
+        let conn = self.connect()?;
+        let mut rows = conn
+            .query(
+                "SELECT key, value FROM settings WHERE user_id = ?1",
+                params![user_id],
+            )
+            .await
+            .map_err(|e| DatabaseError::Query(e.to_string()))?;
+
+        let mut map = HashMap::new();
+        while let Some(row) = rows
+            .next()
+            .await
+            .map_err(|e| DatabaseError::Query(e.to_string()))?
+        {
+            map.insert(get_text(&row, 0), get_json(&row, 1));
+        }
+        Ok(map)
+    }
+
+    async fn set_all_settings(
+        &self,
+        user_id: &str,
+        settings: &HashMap,
+    ) -> Result<(), DatabaseError> {
+        let conn = self.connect()?;
+        let now = fmt_ts(&Utc::now());
+        conn.execute("BEGIN", ())
+            .await
+            .map_err(|e| DatabaseError::Query(e.to_string()))?;
+
+        for (key, value) in settings {
+            if let Err(e) = conn
+                .execute(
+                    r#"
+                    INSERT INTO settings (user_id, key, value, updated_at)
+                    VALUES (?1, ?2, ?3, ?4)
+                    ON CONFLICT (user_id, key) DO UPDATE SET
+                        value = excluded.value,
+                        updated_at = ?4
+                    "#,
+                    params![user_id, key.as_str(), value.to_string(), now.as_str()],
+                )
+                .await
+            {
+                let _ = conn.execute("ROLLBACK", ()).await;
+                return Err(DatabaseError::Query(e.to_string()));
+            }
+        }
+
+        conn.execute("COMMIT", ())
+            .await
+            .map_err(|e| DatabaseError::Query(e.to_string()))?;
+        Ok(())
+    }
+
+    async fn has_settings(&self, user_id: &str) -> Result {
+        let conn = self.connect()?;
+        let mut rows = conn
+            .query(
+                "SELECT COUNT(*) as cnt FROM settings WHERE user_id = ?1",
+                params![user_id],
+            )
+            .await
+            .map_err(|e| DatabaseError::Query(e.to_string()))?;
+
+        match rows
+            .next()
+            .await
+            .map_err(|e| DatabaseError::Query(e.to_string()))?
+        {
+            Some(row) => Ok(get_i64(&row, 0) > 0),
+            None => Ok(false),
+        }
+    }
+
+    // ==================== Workspace: Documents ====================
+
+    async fn get_document_by_path(
+        &self,
+        user_id: &str,
+        agent_id: Option,
+        path: &str,
+    ) -> Result {
+        let conn = self.connect().map_err(|e| WorkspaceError::SearchFailed {
+            reason: e.to_string(),
+        })?;
+        let agent_id_str = agent_id.map(|id| id.to_string());
+        let mut rows = conn
+            .query(
+                r#"
+                SELECT id, user_id, agent_id, path, content,
+                       created_at, updated_at, metadata
+                FROM memory_documents
+                WHERE user_id = ?1 AND agent_id IS ?2 AND path = ?3
+                "#,
+                params![user_id, agent_id_str.as_deref(), path],
+            )
+            .await
+            .map_err(|e| WorkspaceError::SearchFailed {
+                reason: format!("Query failed: {}", e),
+            })?;
+
+        match rows
+            .next()
+            .await
+            .map_err(|e| WorkspaceError::SearchFailed {
+                reason: format!("Query failed: {}", e),
+            })? {
+            Some(row) => Ok(row_to_memory_document(&row)),
+            None => Err(WorkspaceError::DocumentNotFound {
+                doc_type: path.to_string(),
+                user_id: user_id.to_string(),
+            }),
+        }
+    }
+
+    async fn get_document_by_id(&self, id: Uuid) -> Result {
+        let conn = self.connect().map_err(|e| WorkspaceError::SearchFailed {
+            reason: e.to_string(),
+        })?;
+        let mut rows = conn
+            .query(
+                r#"
+                SELECT id, user_id, agent_id, path, content,
+                       created_at, updated_at, metadata
+                FROM memory_documents WHERE id = ?1
+                "#,
+                params![id.to_string()],
+            )
+            .await
+            .map_err(|e| WorkspaceError::SearchFailed {
+                reason: format!("Query failed: {}", e),
+            })?;
+
+        match rows
+            .next()
+            .await
+            .map_err(|e| WorkspaceError::SearchFailed {
+                reason: format!("Query failed: {}", e),
+            })? {
+            Some(row) => Ok(row_to_memory_document(&row)),
+            None => Err(WorkspaceError::DocumentNotFound {
+                doc_type: "unknown".to_string(),
+                user_id: "unknown".to_string(),
+            }),
+        }
+    }
+
+    async fn get_or_create_document_by_path(
+        &self,
+        user_id: &str,
+        agent_id: Option,
+        path: &str,
+    ) -> Result {
+        // Try get
+        match self.get_document_by_path(user_id, agent_id, path).await {
+            Ok(doc) => return Ok(doc),
+            Err(WorkspaceError::DocumentNotFound { .. }) => {}
+            Err(e) => return Err(e),
+        }
+
+        // Create
+        let conn = self.connect().map_err(|e| WorkspaceError::SearchFailed {
+            reason: e.to_string(),
+        })?;
+        let id = Uuid::new_v4();
+        let agent_id_str = agent_id.map(|id| id.to_string());
+        conn.execute(
+            r#"
+                INSERT INTO memory_documents (id, user_id, agent_id, path, content, metadata)
+                VALUES (?1, ?2, ?3, ?4, '', '{}')
+                ON CONFLICT (user_id, agent_id, path) DO NOTHING
+                "#,
+            params![id.to_string(), user_id, agent_id_str.as_deref(), path],
+        )
+        .await
+        .map_err(|e| WorkspaceError::SearchFailed {
+            reason: format!("Insert failed: {}", e),
+        })?;
+
+        self.get_document_by_path(user_id, agent_id, path).await
+    }
+
+    async fn update_document(&self, id: Uuid, content: &str) -> Result<(), WorkspaceError> {
+        let conn = self.connect().map_err(|e| WorkspaceError::SearchFailed {
+            reason: e.to_string(),
+        })?;
+        let now = fmt_ts(&Utc::now());
+        conn.execute(
+            "UPDATE memory_documents SET content = ?2, updated_at = ?3 WHERE id = ?1",
+            params![id.to_string(), content, now],
+        )
+        .await
+        .map_err(|e| WorkspaceError::SearchFailed {
+            reason: format!("Update failed: {}", e),
+        })?;
+        Ok(())
+    }
+
+    async fn delete_document_by_path(
+        &self,
+        user_id: &str,
+        agent_id: Option,
+        path: &str,
+    ) -> Result<(), WorkspaceError> {
+        let doc = self.get_document_by_path(user_id, agent_id, path).await?;
+        self.delete_chunks(doc.id).await?;
+
+        let conn = self.connect().map_err(|e| WorkspaceError::SearchFailed {
+            reason: e.to_string(),
+        })?;
+        let agent_id_str = agent_id.map(|id| id.to_string());
+        conn.execute(
+            "DELETE FROM memory_documents WHERE user_id = ?1 AND agent_id IS ?2 AND path = ?3",
+            params![user_id, agent_id_str.as_deref(), path],
+        )
+        .await
+        .map_err(|e| WorkspaceError::SearchFailed {
+            reason: format!("Delete failed: {}", e),
+        })?;
+        Ok(())
+    }
+
+    async fn list_directory(
+        &self,
+        user_id: &str,
+        agent_id: Option,
+        directory: &str,
+    ) -> Result, WorkspaceError> {
+        let conn = self.connect().map_err(|e| WorkspaceError::SearchFailed {
+            reason: e.to_string(),
+        })?;
+        // Implement the list_workspace_files logic in Rust instead of PL/pgSQL.
+        let dir = if !directory.is_empty() && !directory.ends_with('/') {
+            format!("{}/", directory)
+        } else {
+            directory.to_string()
+        };
+
+        let agent_id_str = agent_id.map(|id| id.to_string());
+        let pattern = if dir.is_empty() {
+            "%".to_string()
+        } else {
+            format!("{}%", dir)
+        };
+
+        let mut rows = conn
+            .query(
+                r#"
+                SELECT path, updated_at, substr(content, 1, 200) as content_preview
+                FROM memory_documents
+                WHERE user_id = ?1 AND agent_id IS ?2
+                  AND (?3 = '%' OR path LIKE ?3)
+                ORDER BY path
+                "#,
+                params![user_id, agent_id_str.as_deref(), pattern],
+            )
+            .await
+            .map_err(|e| WorkspaceError::SearchFailed {
+                reason: format!("List directory failed: {}", e),
+            })?;
+
+        let mut entries_map: HashMap = HashMap::new();
+
+        while let Some(row) = rows
+            .next()
+            .await
+            .map_err(|e| WorkspaceError::SearchFailed {
+                reason: format!("Query failed: {}", e),
+            })?
+        {
+            let full_path = get_text(&row, 0);
+            let updated_at = get_opt_ts(&row, 1);
+            let content_preview = get_opt_text(&row, 2);
+
+            // Extract the immediate child name relative to directory
+            let relative = if dir.is_empty() {
+                &full_path
+            } else if let Some(stripped) = full_path.strip_prefix(&dir) {
+                stripped
+            } else {
+                continue;
+            };
+
+            let child_name = if let Some(slash_pos) = relative.find('/') {
+                &relative[..slash_pos]
+            } else {
+                relative
+            };
+
+            if child_name.is_empty() {
+                continue;
+            }
+
+            let is_dir = relative.contains('/');
+            let entry_path = if dir.is_empty() {
+                child_name.to_string()
+            } else {
+                format!("{}{}", dir, child_name)
+            };
+
+            entries_map
+                .entry(child_name.to_string())
+                .and_modify(|e| {
+                    // Mark as directory if any sub-paths exist
+                    if is_dir {
+                        e.is_directory = true;
+                        e.content_preview = None;
+                    }
+                    // Update to latest timestamp
+                    if let (Some(existing), Some(new)) = (&e.updated_at, &updated_at)
+                        && new > existing
+                    {
+                        e.updated_at = Some(*new);
+                    }
+                })
+                .or_insert(WorkspaceEntry {
+                    path: entry_path,
+                    is_directory: is_dir,
+                    updated_at,
+                    content_preview: if is_dir { None } else { content_preview },
+                });
+        }
+
+        let mut entries: Vec = entries_map.into_values().collect();
+        entries.sort_by(|a, b| a.path.cmp(&b.path));
+        Ok(entries)
+    }
+
+    async fn list_all_paths(
+        &self,
+        user_id: &str,
+        agent_id: Option,
+    ) -> Result, WorkspaceError> {
+        let conn = self.connect().map_err(|e| WorkspaceError::SearchFailed {
+            reason: e.to_string(),
+        })?;
+        let agent_id_str = agent_id.map(|id| id.to_string());
+        let mut rows = conn
+            .query(
+                "SELECT path FROM memory_documents WHERE user_id = ?1 AND agent_id IS ?2 ORDER BY path",
+                params![user_id, agent_id_str.as_deref()],
+            )
+            .await
+            .map_err(|e| WorkspaceError::SearchFailed {
+                reason: format!("List paths failed: {}", e),
+            })?;
+
+        let mut paths = Vec::new();
+        while let Some(row) = rows
+            .next()
+            .await
+            .map_err(|e| WorkspaceError::SearchFailed {
+                reason: format!("Query failed: {}", e),
+            })?
+        {
+            paths.push(get_text(&row, 0));
+        }
+        Ok(paths)
+    }
+
+    async fn list_documents(
+        &self,
+        user_id: &str,
+        agent_id: Option,
+    ) -> Result, WorkspaceError> {
+        let conn = self.connect().map_err(|e| WorkspaceError::SearchFailed {
+            reason: e.to_string(),
+        })?;
+        let agent_id_str = agent_id.map(|id| id.to_string());
+        let mut rows = conn
+            .query(
+                r#"
+                SELECT id, user_id, agent_id, path, content,
+                       created_at, updated_at, metadata
+                FROM memory_documents
+                WHERE user_id = ?1 AND agent_id IS ?2
+                ORDER BY updated_at DESC
+                "#,
+                params![user_id, agent_id_str.as_deref()],
+            )
+            .await
+            .map_err(|e| WorkspaceError::SearchFailed {
+                reason: format!("Query failed: {}", e),
+            })?;
+
+        let mut docs = Vec::new();
+        while let Some(row) = rows
+            .next()
+            .await
+            .map_err(|e| WorkspaceError::SearchFailed {
+                reason: format!("Query failed: {}", e),
+            })?
+        {
+            docs.push(row_to_memory_document(&row));
+        }
+        Ok(docs)
+    }
+
+    // ==================== Workspace: Chunks ====================
+
+    async fn delete_chunks(&self, document_id: Uuid) -> Result<(), WorkspaceError> {
+        let conn = self.connect().map_err(|e| WorkspaceError::ChunkingFailed {
+            reason: e.to_string(),
+        })?;
+        conn.execute(
+            "DELETE FROM memory_chunks WHERE document_id = ?1",
+            params![document_id.to_string()],
+        )
+        .await
+        .map_err(|e| WorkspaceError::ChunkingFailed {
+            reason: format!("Delete failed: {}", e),
+        })?;
+        Ok(())
+    }
+
+    async fn insert_chunk(
+        &self,
+        document_id: Uuid,
+        chunk_index: i32,
+        content: &str,
+        embedding: Option<&[f32]>,
+    ) -> Result {
+        let conn = self.connect().map_err(|e| WorkspaceError::ChunkingFailed {
+            reason: e.to_string(),
+        })?;
+        let id = Uuid::new_v4();
+        let embedding_blob = embedding.map(|e| {
+            // Convert f32 slice to bytes for F32_BLOB
+            let bytes: Vec = e.iter().flat_map(|f| f.to_le_bytes()).collect();
+            bytes
+        });
+
+        conn.execute(
+            r#"
+                INSERT INTO memory_chunks (id, document_id, chunk_index, content, embedding)
+                VALUES (?1, ?2, ?3, ?4, ?5)
+                "#,
+            params![
+                id.to_string(),
+                document_id.to_string(),
+                chunk_index as i64,
+                content,
+                embedding_blob.map(libsql::Value::Blob),
+            ],
+        )
+        .await
+        .map_err(|e| WorkspaceError::ChunkingFailed {
+            reason: format!("Insert failed: {}", e),
+        })?;
+        Ok(id)
+    }
+
+    async fn update_chunk_embedding(
+        &self,
+        chunk_id: Uuid,
+        embedding: &[f32],
+    ) -> Result<(), WorkspaceError> {
+        let conn = self
+            .connect()
+            .map_err(|e| WorkspaceError::EmbeddingFailed {
+                reason: e.to_string(),
+            })?;
+        let bytes: Vec = embedding.iter().flat_map(|f| f.to_le_bytes()).collect();
+
+        conn.execute(
+            "UPDATE memory_chunks SET embedding = ?2 WHERE id = ?1",
+            params![chunk_id.to_string(), libsql::Value::Blob(bytes)],
+        )
+        .await
+        .map_err(|e| WorkspaceError::EmbeddingFailed {
+            reason: format!("Update failed: {}", e),
+        })?;
+        Ok(())
+    }
+
+    async fn get_chunks_without_embeddings(
+        &self,
+        user_id: &str,
+        agent_id: Option,
+        limit: usize,
+    ) -> Result, WorkspaceError> {
+        let conn = self.connect().map_err(|e| WorkspaceError::SearchFailed {
+            reason: e.to_string(),
+        })?;
+        let agent_id_str = agent_id.map(|id| id.to_string());
+        let mut rows = conn
+            .query(
+                r#"
+                SELECT c.id, c.document_id, c.chunk_index, c.content, c.created_at
+                FROM memory_chunks c
+                JOIN memory_documents d ON d.id = c.document_id
+                WHERE d.user_id = ?1 AND d.agent_id IS ?2
+                  AND c.embedding IS NULL
+                LIMIT ?3
+                "#,
+                params![user_id, agent_id_str.as_deref(), limit as i64],
+            )
+            .await
+            .map_err(|e| WorkspaceError::SearchFailed {
+                reason: format!("Query failed: {}", e),
+            })?;
+
+        let mut chunks = Vec::new();
+        while let Some(row) = rows
+            .next()
+            .await
+            .map_err(|e| WorkspaceError::SearchFailed {
+                reason: format!("Query failed: {}", e),
+            })?
+        {
+            chunks.push(MemoryChunk {
+                id: get_text(&row, 0).parse().unwrap_or_default(),
+                document_id: get_text(&row, 1).parse().unwrap_or_default(),
+                chunk_index: get_i64(&row, 2) as i32,
+                content: get_text(&row, 3),
+                embedding: None,
+                created_at: get_ts(&row, 4),
+            });
+        }
+        Ok(chunks)
+    }
+
+    // ==================== Workspace: Search ====================
+
+    async fn hybrid_search(
+        &self,
+        user_id: &str,
+        agent_id: Option,
+        query: &str,
+        embedding: Option<&[f32]>,
+        config: &SearchConfig,
+    ) -> Result, WorkspaceError> {
+        let conn = self.connect().map_err(|e| WorkspaceError::SearchFailed {
+            reason: e.to_string(),
+        })?;
+        let agent_id_str = agent_id.map(|id| id.to_string());
+        let pre_limit = config.pre_fusion_limit as i64;
+
+        // FTS search using FTS5
+        let fts_results = if config.use_fts {
+            let mut rows = conn
+                .query(
+                    r#"
+                    SELECT c.id, c.document_id, c.content
+                    FROM memory_chunks_fts fts
+                    JOIN memory_chunks c ON c._rowid = fts.rowid
+                    JOIN memory_documents d ON d.id = c.document_id
+                    WHERE d.user_id = ?1 AND d.agent_id IS ?2
+                      AND memory_chunks_fts MATCH ?3
+                    ORDER BY rank
+                    LIMIT ?4
+                    "#,
+                    params![user_id, agent_id_str.as_deref(), query, pre_limit],
+                )
+                .await
+                .map_err(|e| WorkspaceError::SearchFailed {
+                    reason: format!("FTS query failed: {}", e),
+                })?;
+
+            let mut results = Vec::new();
+            while let Some(row) = rows
+                .next()
+                .await
+                .map_err(|e| WorkspaceError::SearchFailed {
+                    reason: format!("FTS row fetch failed: {}", e),
+                })?
+            {
+                results.push(RankedResult {
+                    chunk_id: get_text(&row, 0).parse().unwrap_or_default(),
+                    document_id: get_text(&row, 1).parse().unwrap_or_default(),
+                    content: get_text(&row, 2),
+                    rank: results.len() as u32 + 1,
+                });
+            }
+            results
+        } else {
+            Vec::new()
+        };
+
+        // Vector search using libsql_vector_idx
+        let vector_results = if let (true, Some(emb)) = (config.use_vector, embedding) {
+            // Format as JSON array string for vector() SQL function
+            let vector_json = format!(
+                "[{}]",
+                emb.iter()
+                    .map(|f| f.to_string())
+                    .collect::>()
+                    .join(",")
+            );
+
+            // vector_top_k returns rowids from the vector index.
+            // We join back to memory_chunks and filter by user/agent.
+            let mut rows = conn
+                .query(
+                    r#"
+                    SELECT c.id, c.document_id, c.content
+                    FROM vector_top_k('idx_memory_chunks_embedding', vector(?1), ?2) AS top_k
+                    JOIN memory_chunks c ON c._rowid = top_k.id
+                    JOIN memory_documents d ON d.id = c.document_id
+                    WHERE d.user_id = ?3 AND d.agent_id IS ?4
+                    "#,
+                    params![vector_json, pre_limit, user_id, agent_id_str.as_deref()],
+                )
+                .await
+                .map_err(|e| WorkspaceError::SearchFailed {
+                    reason: format!("Vector query failed: {}", e),
+                })?;
+
+            let mut results = Vec::new();
+            while let Some(row) = rows
+                .next()
+                .await
+                .map_err(|e| WorkspaceError::SearchFailed {
+                    reason: format!("Vector row fetch failed: {}", e),
+                })?
+            {
+                results.push(RankedResult {
+                    chunk_id: get_text(&row, 0).parse().unwrap_or_default(),
+                    document_id: get_text(&row, 1).parse().unwrap_or_default(),
+                    content: get_text(&row, 2),
+                    rank: results.len() as u32 + 1,
+                });
+            }
+            results
+        } else {
+            Vec::new()
+        };
+
+        if embedding.is_some() && !config.use_vector {
+            tracing::warn!(
+                "Embedding provided but vector search is disabled in config; using FTS-only results"
+            );
+        }
+
+        Ok(reciprocal_rank_fusion(fts_results, vector_results, config))
+    }
+}
+
+// ==================== Row conversion helpers ====================
+
+fn row_to_memory_document(row: &libsql::Row) -> MemoryDocument {
+    MemoryDocument {
+        id: get_text(row, 0).parse().unwrap_or_default(),
+        user_id: get_text(row, 1),
+        agent_id: get_opt_text(row, 2).and_then(|s| s.parse().ok()),
+        path: get_text(row, 3),
+        content: get_text(row, 4),
+        created_at: get_ts(row, 5),
+        updated_at: get_ts(row, 6),
+        metadata: get_json(row, 7),
+    }
+}
+
+fn row_to_routine_libsql(row: &libsql::Row) -> Result {
+    let trigger_type = get_text(row, 5);
+    let trigger_config = get_json(row, 6);
+    let action_type = get_text(row, 7);
+    let action_config = get_json(row, 8);
+    let cooldown_secs = get_i64(row, 9);
+    let max_concurrent = get_i64(row, 10);
+    let dedup_window_secs: Option = row.get::(11).ok();
+
+    let trigger =
+        Trigger::from_db(&trigger_type, trigger_config).map_err(DatabaseError::Serialization)?;
+    let action = RoutineAction::from_db(&action_type, action_config)
+        .map_err(DatabaseError::Serialization)?;
+
+    Ok(Routine {
+        id: get_text(row, 0).parse().unwrap_or_default(),
+        name: get_text(row, 1),
+        description: get_text(row, 2),
+        user_id: get_text(row, 3),
+        enabled: get_i64(row, 4) != 0,
+        trigger,
+        action,
+        guardrails: RoutineGuardrails {
+            cooldown: std::time::Duration::from_secs(cooldown_secs as u64),
+            max_concurrent: max_concurrent as u32,
+            dedup_window: dedup_window_secs.map(|s| std::time::Duration::from_secs(s as u64)),
+        },
+        notify: NotifyConfig {
+            channel: get_opt_text(row, 12),
+            user: get_text(row, 13),
+            on_success: get_i64(row, 14) != 0,
+            on_failure: get_i64(row, 15) != 0,
+            on_attention: get_i64(row, 16) != 0,
+        },
+        state: get_json(row, 17),
+        last_run_at: get_opt_ts(row, 18),
+        next_fire_at: get_opt_ts(row, 19),
+        run_count: get_i64(row, 20) as u64,
+        consecutive_failures: get_i64(row, 21) as u32,
+        created_at: get_ts(row, 22),
+        updated_at: get_ts(row, 23),
+    })
+}
+
+fn row_to_routine_run_libsql(row: &libsql::Row) -> Result {
+    let status_str = get_text(row, 5);
+    let status: RunStatus = status_str
+        .parse()
+        .map_err(|e: String| DatabaseError::Serialization(e))?;
+
+    Ok(RoutineRun {
+        id: get_text(row, 0).parse().unwrap_or_default(),
+        routine_id: get_text(row, 1).parse().unwrap_or_default(),
+        trigger_type: get_text(row, 2),
+        trigger_detail: get_opt_text(row, 3),
+        started_at: get_ts(row, 4),
+        completed_at: get_opt_ts(row, 6),
+        status,
+        result_summary: get_opt_text(row, 7),
+        tokens_used: row.get::(8).ok().map(|v| v as i32),
+        job_id: get_opt_text(row, 9).and_then(|s| s.parse().ok()),
+        created_at: get_ts(row, 10),
+    })
+}
diff --git a/src/db/libsql_migrations.rs b/src/db/libsql_migrations.rs
new file mode 100644
index 00000000..1480ed7d
--- /dev/null
+++ b/src/db/libsql_migrations.rs
@@ -0,0 +1,549 @@
+//! SQLite-dialect migrations for the libSQL/Turso backend.
+//!
+//! Consolidates all PostgreSQL migrations (V1-V8) into a single SQLite-compatible
+//! schema. Run once on database creation; idempotent via `IF NOT EXISTS`.
+
+/// Consolidated schema for libSQL.
+///
+/// Translates PostgreSQL types and features:
+/// - `UUID` -> `TEXT` (store as hex string)
+/// - `TIMESTAMPTZ` -> `TEXT` (ISO-8601)
+/// - `JSONB` -> `TEXT` (JSON encoded)
+/// - `BYTEA` -> `BLOB`
+/// - `NUMERIC` -> `TEXT` (preserve precision for rust_decimal)
+/// - `TEXT[]` -> `TEXT` (JSON array)
+/// - `VECTOR(1536)` -> `F32_BLOB(1536)` (libsql native)
+/// - `TSVECTOR` -> FTS5 virtual table
+/// - `BIGSERIAL` -> `INTEGER PRIMARY KEY AUTOINCREMENT`
+/// - PL/pgSQL functions -> SQLite triggers
+pub const SCHEMA: &str = r#"
+
+-- ==================== Migration tracking ====================
+
+CREATE TABLE IF NOT EXISTS _migrations (
+    version INTEGER PRIMARY KEY,
+    name TEXT NOT NULL,
+    applied_at TEXT NOT NULL DEFAULT (datetime('now'))
+);
+
+-- ==================== Conversations ====================
+
+CREATE TABLE IF NOT EXISTS conversations (
+    id TEXT PRIMARY KEY,
+    channel TEXT NOT NULL,
+    user_id TEXT NOT NULL,
+    thread_id TEXT,
+    started_at TEXT NOT NULL DEFAULT (datetime('now')),
+    last_activity TEXT NOT NULL DEFAULT (datetime('now')),
+    metadata TEXT NOT NULL DEFAULT '{}'
+);
+
+CREATE INDEX IF NOT EXISTS idx_conversations_channel ON conversations(channel);
+CREATE INDEX IF NOT EXISTS idx_conversations_user ON conversations(user_id);
+CREATE INDEX IF NOT EXISTS idx_conversations_last_activity ON conversations(last_activity);
+
+CREATE TABLE IF NOT EXISTS conversation_messages (
+    id TEXT PRIMARY KEY,
+    conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
+    role TEXT NOT NULL,
+    content TEXT NOT NULL,
+    created_at TEXT NOT NULL DEFAULT (datetime('now'))
+);
+
+CREATE INDEX IF NOT EXISTS idx_conversation_messages_conversation
+    ON conversation_messages(conversation_id);
+
+-- ==================== Agent Jobs ====================
+
+CREATE TABLE IF NOT EXISTS agent_jobs (
+    id TEXT PRIMARY KEY,
+    marketplace_job_id TEXT,
+    conversation_id TEXT REFERENCES conversations(id),
+    title TEXT NOT NULL,
+    description TEXT NOT NULL,
+    category TEXT,
+    status TEXT NOT NULL,
+    source TEXT NOT NULL,
+    user_id TEXT NOT NULL DEFAULT 'default',
+    project_dir TEXT,
+    job_mode TEXT NOT NULL DEFAULT 'worker',
+    budget_amount TEXT,
+    budget_token TEXT,
+    bid_amount TEXT,
+    estimated_cost TEXT,
+    estimated_time_secs INTEGER,
+    estimated_value TEXT,
+    actual_cost TEXT,
+    actual_time_secs INTEGER,
+    success INTEGER,
+    failure_reason TEXT,
+    stuck_since TEXT,
+    repair_attempts INTEGER NOT NULL DEFAULT 0,
+    created_at TEXT NOT NULL DEFAULT (datetime('now')),
+    started_at TEXT,
+    completed_at TEXT
+);
+
+CREATE INDEX IF NOT EXISTS idx_agent_jobs_status ON agent_jobs(status);
+CREATE INDEX IF NOT EXISTS idx_agent_jobs_marketplace ON agent_jobs(marketplace_job_id);
+CREATE INDEX IF NOT EXISTS idx_agent_jobs_conversation ON agent_jobs(conversation_id);
+CREATE INDEX IF NOT EXISTS idx_agent_jobs_source ON agent_jobs(source);
+CREATE INDEX IF NOT EXISTS idx_agent_jobs_user ON agent_jobs(user_id);
+CREATE INDEX IF NOT EXISTS idx_agent_jobs_created ON agent_jobs(created_at DESC);
+
+CREATE TABLE IF NOT EXISTS job_actions (
+    id TEXT PRIMARY KEY,
+    job_id TEXT NOT NULL REFERENCES agent_jobs(id) ON DELETE CASCADE,
+    sequence_num INTEGER NOT NULL,
+    tool_name TEXT NOT NULL,
+    input TEXT NOT NULL,
+    output_raw TEXT,
+    output_sanitized TEXT,
+    sanitization_warnings TEXT,
+    cost TEXT,
+    duration_ms INTEGER,
+    success INTEGER NOT NULL,
+    error_message TEXT,
+    created_at TEXT NOT NULL DEFAULT (datetime('now')),
+    UNIQUE(job_id, sequence_num)
+);
+
+CREATE INDEX IF NOT EXISTS idx_job_actions_job_id ON job_actions(job_id);
+CREATE INDEX IF NOT EXISTS idx_job_actions_tool ON job_actions(tool_name);
+
+-- ==================== Dynamic Tools ====================
+
+CREATE TABLE IF NOT EXISTS dynamic_tools (
+    id TEXT PRIMARY KEY,
+    name TEXT NOT NULL UNIQUE,
+    description TEXT NOT NULL,
+    parameters_schema TEXT NOT NULL,
+    code TEXT NOT NULL,
+    sandbox_config TEXT NOT NULL,
+    created_by_job_id TEXT REFERENCES agent_jobs(id),
+    success_count INTEGER NOT NULL DEFAULT 0,
+    failure_count INTEGER NOT NULL DEFAULT 0,
+    last_error TEXT,
+    status TEXT NOT NULL DEFAULT 'active',
+    created_at TEXT NOT NULL DEFAULT (datetime('now')),
+    updated_at TEXT NOT NULL DEFAULT (datetime('now'))
+);
+
+CREATE INDEX IF NOT EXISTS idx_dynamic_tools_status ON dynamic_tools(status);
+CREATE INDEX IF NOT EXISTS idx_dynamic_tools_name ON dynamic_tools(name);
+
+-- ==================== LLM Calls ====================
+
+CREATE TABLE IF NOT EXISTS llm_calls (
+    id TEXT PRIMARY KEY,
+    job_id TEXT REFERENCES agent_jobs(id) ON DELETE CASCADE,
+    conversation_id TEXT REFERENCES conversations(id),
+    provider TEXT NOT NULL,
+    model TEXT NOT NULL,
+    input_tokens INTEGER NOT NULL,
+    output_tokens INTEGER NOT NULL,
+    cost TEXT NOT NULL,
+    purpose TEXT,
+    created_at TEXT NOT NULL DEFAULT (datetime('now'))
+);
+
+CREATE INDEX IF NOT EXISTS idx_llm_calls_job ON llm_calls(job_id);
+CREATE INDEX IF NOT EXISTS idx_llm_calls_conversation ON llm_calls(conversation_id);
+CREATE INDEX IF NOT EXISTS idx_llm_calls_provider ON llm_calls(provider);
+
+-- ==================== Estimation ====================
+
+CREATE TABLE IF NOT EXISTS estimation_snapshots (
+    id TEXT PRIMARY KEY,
+    job_id TEXT NOT NULL REFERENCES agent_jobs(id) ON DELETE CASCADE,
+    category TEXT NOT NULL,
+    tool_names TEXT NOT NULL DEFAULT '[]',
+    estimated_cost TEXT NOT NULL,
+    actual_cost TEXT,
+    estimated_time_secs INTEGER NOT NULL,
+    actual_time_secs INTEGER,
+    estimated_value TEXT NOT NULL,
+    actual_value TEXT,
+    created_at TEXT NOT NULL DEFAULT (datetime('now'))
+);
+
+CREATE INDEX IF NOT EXISTS idx_estimation_category ON estimation_snapshots(category);
+CREATE INDEX IF NOT EXISTS idx_estimation_job ON estimation_snapshots(job_id);
+
+-- ==================== Self Repair ====================
+
+CREATE TABLE IF NOT EXISTS repair_attempts (
+    id TEXT PRIMARY KEY,
+    target_type TEXT NOT NULL,
+    target_id TEXT NOT NULL,
+    diagnosis TEXT NOT NULL,
+    action_taken TEXT NOT NULL,
+    success INTEGER NOT NULL,
+    error_message TEXT,
+    created_at TEXT NOT NULL DEFAULT (datetime('now'))
+);
+
+CREATE INDEX IF NOT EXISTS idx_repair_attempts_target ON repair_attempts(target_type, target_id);
+CREATE INDEX IF NOT EXISTS idx_repair_attempts_created ON repair_attempts(created_at);
+
+-- ==================== Workspace: Memory Documents ====================
+
+CREATE TABLE IF NOT EXISTS memory_documents (
+    id TEXT PRIMARY KEY,
+    user_id TEXT NOT NULL,
+    agent_id TEXT,
+    path TEXT NOT NULL,
+    content TEXT NOT NULL,
+    created_at TEXT NOT NULL DEFAULT (datetime('now')),
+    updated_at TEXT NOT NULL DEFAULT (datetime('now')),
+    metadata TEXT NOT NULL DEFAULT '{}',
+    UNIQUE (user_id, agent_id, path)
+);
+
+CREATE INDEX IF NOT EXISTS idx_memory_documents_user ON memory_documents(user_id);
+CREATE INDEX IF NOT EXISTS idx_memory_documents_path ON memory_documents(user_id, path);
+CREATE INDEX IF NOT EXISTS idx_memory_documents_updated ON memory_documents(updated_at DESC);
+
+-- Trigger to auto-update updated_at on memory_documents
+CREATE TRIGGER IF NOT EXISTS update_memory_documents_updated_at
+    AFTER UPDATE ON memory_documents
+    FOR EACH ROW
+    WHEN NEW.updated_at = OLD.updated_at
+    BEGIN
+        UPDATE memory_documents SET updated_at = datetime('now') WHERE id = NEW.id;
+    END;
+
+-- ==================== Workspace: Memory Chunks ====================
+
+CREATE TABLE IF NOT EXISTS memory_chunks (
+    _rowid INTEGER PRIMARY KEY AUTOINCREMENT,
+    id TEXT NOT NULL UNIQUE,
+    document_id TEXT NOT NULL REFERENCES memory_documents(id) ON DELETE CASCADE,
+    chunk_index INTEGER NOT NULL,
+    content TEXT NOT NULL,
+    embedding F32_BLOB(1536),
+    created_at TEXT NOT NULL DEFAULT (datetime('now')),
+    UNIQUE (document_id, chunk_index)
+);
+
+CREATE INDEX IF NOT EXISTS idx_memory_chunks_document ON memory_chunks(document_id);
+
+-- Vector index for semantic search (libSQL native)
+CREATE INDEX IF NOT EXISTS idx_memory_chunks_embedding
+    ON memory_chunks (libsql_vector_idx(embedding));
+
+-- FTS5 virtual table for full-text search
+CREATE VIRTUAL TABLE IF NOT EXISTS memory_chunks_fts USING fts5(
+    content,
+    content='memory_chunks',
+    content_rowid='_rowid'
+);
+
+-- Triggers to keep FTS5 in sync with memory_chunks
+CREATE TRIGGER IF NOT EXISTS memory_chunks_fts_insert AFTER INSERT ON memory_chunks BEGIN
+    INSERT INTO memory_chunks_fts(rowid, content) VALUES (new._rowid, new.content);
+END;
+
+CREATE TRIGGER IF NOT EXISTS memory_chunks_fts_delete AFTER DELETE ON memory_chunks BEGIN
+    INSERT INTO memory_chunks_fts(memory_chunks_fts, rowid, content)
+        VALUES ('delete', old._rowid, old.content);
+END;
+
+CREATE TRIGGER IF NOT EXISTS memory_chunks_fts_update AFTER UPDATE ON memory_chunks BEGIN
+    INSERT INTO memory_chunks_fts(memory_chunks_fts, rowid, content)
+        VALUES ('delete', old._rowid, old.content);
+    INSERT INTO memory_chunks_fts(rowid, content) VALUES (new._rowid, new.content);
+END;
+
+-- ==================== Workspace: Heartbeat State ====================
+
+CREATE TABLE IF NOT EXISTS heartbeat_state (
+    id TEXT PRIMARY KEY,
+    user_id TEXT NOT NULL,
+    agent_id TEXT,
+    last_run TEXT,
+    next_run TEXT,
+    interval_seconds INTEGER NOT NULL DEFAULT 1800,
+    enabled INTEGER NOT NULL DEFAULT 1,
+    consecutive_failures INTEGER NOT NULL DEFAULT 0,
+    last_checks TEXT NOT NULL DEFAULT '{}',
+    UNIQUE (user_id, agent_id)
+);
+
+CREATE INDEX IF NOT EXISTS idx_heartbeat_user ON heartbeat_state(user_id);
+
+-- ==================== Secrets ====================
+
+CREATE TABLE IF NOT EXISTS secrets (
+    id TEXT PRIMARY KEY,
+    user_id TEXT NOT NULL,
+    name TEXT NOT NULL,
+    encrypted_value BLOB NOT NULL,
+    key_salt BLOB NOT NULL,
+    provider TEXT,
+    expires_at TEXT,
+    last_used_at TEXT,
+    usage_count INTEGER NOT NULL DEFAULT 0,
+    created_at TEXT NOT NULL DEFAULT (datetime('now')),
+    updated_at TEXT NOT NULL DEFAULT (datetime('now')),
+    UNIQUE (user_id, name)
+);
+
+CREATE INDEX IF NOT EXISTS idx_secrets_user ON secrets(user_id);
+
+-- ==================== WASM Tools ====================
+
+CREATE TABLE IF NOT EXISTS wasm_tools (
+    id TEXT PRIMARY KEY,
+    user_id TEXT NOT NULL,
+    name TEXT NOT NULL,
+    version TEXT NOT NULL DEFAULT '1.0.0',
+    description TEXT NOT NULL,
+    wasm_binary BLOB NOT NULL,
+    binary_hash BLOB NOT NULL,
+    parameters_schema TEXT NOT NULL,
+    source_url TEXT,
+    trust_level TEXT NOT NULL DEFAULT 'user',
+    status TEXT NOT NULL DEFAULT 'active',
+    created_at TEXT NOT NULL DEFAULT (datetime('now')),
+    updated_at TEXT NOT NULL DEFAULT (datetime('now')),
+    UNIQUE (user_id, name, version)
+);
+
+CREATE INDEX IF NOT EXISTS idx_wasm_tools_user ON wasm_tools(user_id);
+CREATE INDEX IF NOT EXISTS idx_wasm_tools_name ON wasm_tools(user_id, name);
+CREATE INDEX IF NOT EXISTS idx_wasm_tools_status ON wasm_tools(status);
+
+-- ==================== Tool Capabilities ====================
+
+CREATE TABLE IF NOT EXISTS tool_capabilities (
+    id TEXT PRIMARY KEY,
+    wasm_tool_id TEXT NOT NULL REFERENCES wasm_tools(id) ON DELETE CASCADE,
+    http_allowlist TEXT NOT NULL DEFAULT '[]',
+    allowed_secrets TEXT NOT NULL DEFAULT '[]',
+    tool_aliases TEXT NOT NULL DEFAULT '{}',
+    requests_per_minute INTEGER NOT NULL DEFAULT 60,
+    requests_per_hour INTEGER NOT NULL DEFAULT 1000,
+    max_request_body_bytes INTEGER NOT NULL DEFAULT 1048576,
+    max_response_body_bytes INTEGER NOT NULL DEFAULT 10485760,
+    workspace_read_prefixes TEXT NOT NULL DEFAULT '[]',
+    http_timeout_secs INTEGER NOT NULL DEFAULT 30,
+    created_at TEXT NOT NULL DEFAULT (datetime('now')),
+    updated_at TEXT NOT NULL DEFAULT (datetime('now')),
+    UNIQUE (wasm_tool_id)
+);
+
+-- ==================== Leak Detection Patterns ====================
+
+CREATE TABLE IF NOT EXISTS leak_detection_patterns (
+    id TEXT PRIMARY KEY,
+    name TEXT NOT NULL UNIQUE,
+    pattern TEXT NOT NULL,
+    severity TEXT NOT NULL DEFAULT 'high',
+    action TEXT NOT NULL DEFAULT 'block',
+    enabled INTEGER NOT NULL DEFAULT 1,
+    created_at TEXT NOT NULL DEFAULT (datetime('now'))
+);
+
+-- ==================== Rate Limit State ====================
+
+CREATE TABLE IF NOT EXISTS tool_rate_limit_state (
+    id TEXT PRIMARY KEY,
+    wasm_tool_id TEXT NOT NULL REFERENCES wasm_tools(id) ON DELETE CASCADE,
+    user_id TEXT NOT NULL,
+    minute_window_start TEXT NOT NULL DEFAULT (datetime('now')),
+    minute_count INTEGER NOT NULL DEFAULT 0,
+    hour_window_start TEXT NOT NULL DEFAULT (datetime('now')),
+    hour_count INTEGER NOT NULL DEFAULT 0,
+    UNIQUE (wasm_tool_id, user_id)
+);
+
+-- ==================== Secret Usage Audit Log ====================
+
+CREATE TABLE IF NOT EXISTS secret_usage_log (
+    id TEXT PRIMARY KEY,
+    secret_id TEXT NOT NULL REFERENCES secrets(id) ON DELETE CASCADE,
+    wasm_tool_id TEXT REFERENCES wasm_tools(id) ON DELETE SET NULL,
+    user_id TEXT NOT NULL,
+    target_host TEXT NOT NULL,
+    target_path TEXT,
+    success INTEGER NOT NULL,
+    error_message TEXT,
+    created_at TEXT NOT NULL DEFAULT (datetime('now'))
+);
+
+CREATE INDEX IF NOT EXISTS idx_secret_usage_user ON secret_usage_log(user_id);
+
+-- ==================== Leak Detection Events ====================
+
+CREATE TABLE IF NOT EXISTS leak_detection_events (
+    id TEXT PRIMARY KEY,
+    pattern_id TEXT REFERENCES leak_detection_patterns(id) ON DELETE SET NULL,
+    wasm_tool_id TEXT REFERENCES wasm_tools(id) ON DELETE SET NULL,
+    user_id TEXT NOT NULL,
+    source TEXT NOT NULL,
+    action_taken TEXT NOT NULL,
+    context_preview TEXT,
+    created_at TEXT NOT NULL DEFAULT (datetime('now'))
+);
+
+-- ==================== Tool Failures ====================
+
+CREATE TABLE IF NOT EXISTS tool_failures (
+    id TEXT PRIMARY KEY,
+    tool_name TEXT NOT NULL UNIQUE,
+    error_message TEXT,
+    error_count INTEGER DEFAULT 1,
+    first_failure TEXT DEFAULT (datetime('now')),
+    last_failure TEXT DEFAULT (datetime('now')),
+    last_build_result TEXT,
+    repaired_at TEXT,
+    repair_attempts INTEGER DEFAULT 0
+);
+
+CREATE INDEX IF NOT EXISTS idx_tool_failures_name ON tool_failures(tool_name);
+
+-- ==================== Job Events ====================
+
+CREATE TABLE IF NOT EXISTS job_events (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    job_id TEXT NOT NULL REFERENCES agent_jobs(id),
+    event_type TEXT NOT NULL,
+    data TEXT NOT NULL,
+    created_at TEXT NOT NULL DEFAULT (datetime('now'))
+);
+
+CREATE INDEX IF NOT EXISTS idx_job_events_job ON job_events(job_id, id);
+
+-- ==================== Routines ====================
+
+CREATE TABLE IF NOT EXISTS routines (
+    id TEXT PRIMARY KEY,
+    name TEXT NOT NULL,
+    description TEXT NOT NULL DEFAULT '',
+    user_id TEXT NOT NULL,
+    enabled INTEGER NOT NULL DEFAULT 1,
+    trigger_type TEXT NOT NULL,
+    trigger_config TEXT NOT NULL,
+    action_type TEXT NOT NULL,
+    action_config TEXT NOT NULL,
+    cooldown_secs INTEGER NOT NULL DEFAULT 300,
+    max_concurrent INTEGER NOT NULL DEFAULT 1,
+    dedup_window_secs INTEGER,
+    notify_channel TEXT,
+    notify_user TEXT NOT NULL DEFAULT 'default',
+    notify_on_success INTEGER NOT NULL DEFAULT 0,
+    notify_on_failure INTEGER NOT NULL DEFAULT 1,
+    notify_on_attention INTEGER NOT NULL DEFAULT 1,
+    state TEXT NOT NULL DEFAULT '{}',
+    last_run_at TEXT,
+    next_fire_at TEXT,
+    run_count INTEGER NOT NULL DEFAULT 0,
+    consecutive_failures INTEGER NOT NULL DEFAULT 0,
+    created_at TEXT NOT NULL DEFAULT (datetime('now')),
+    updated_at TEXT NOT NULL DEFAULT (datetime('now')),
+    UNIQUE (user_id, name)
+);
+
+CREATE INDEX IF NOT EXISTS idx_routines_user ON routines(user_id);
+
+-- ==================== Routine Runs ====================
+
+CREATE TABLE IF NOT EXISTS routine_runs (
+    id TEXT PRIMARY KEY,
+    routine_id TEXT NOT NULL REFERENCES routines(id) ON DELETE CASCADE,
+    trigger_type TEXT NOT NULL,
+    trigger_detail TEXT,
+    started_at TEXT NOT NULL DEFAULT (datetime('now')),
+    completed_at TEXT,
+    status TEXT NOT NULL DEFAULT 'running',
+    result_summary TEXT,
+    tokens_used INTEGER,
+    job_id TEXT REFERENCES agent_jobs(id),
+    created_at TEXT NOT NULL DEFAULT (datetime('now'))
+);
+
+CREATE INDEX IF NOT EXISTS idx_routine_runs_routine ON routine_runs(routine_id);
+
+-- ==================== Settings ====================
+
+CREATE TABLE IF NOT EXISTS settings (
+    user_id TEXT NOT NULL,
+    key TEXT NOT NULL,
+    value TEXT NOT NULL,
+    updated_at TEXT NOT NULL DEFAULT (datetime('now')),
+    PRIMARY KEY (user_id, key)
+);
+
+CREATE INDEX IF NOT EXISTS idx_settings_user ON settings(user_id);
+
+-- ==================== Missing indexes (parity with PostgreSQL) ====================
+
+-- agent_jobs
+CREATE INDEX IF NOT EXISTS idx_agent_jobs_stuck ON agent_jobs(stuck_since);
+
+-- secrets
+CREATE INDEX IF NOT EXISTS idx_secrets_provider ON secrets(provider);
+CREATE INDEX IF NOT EXISTS idx_secrets_expires ON secrets(expires_at);
+
+-- wasm_tools
+CREATE INDEX IF NOT EXISTS idx_wasm_tools_trust ON wasm_tools(trust_level);
+
+-- tool_capabilities
+CREATE INDEX IF NOT EXISTS idx_tool_capabilities_tool ON tool_capabilities(wasm_tool_id);
+
+-- leak_detection_patterns
+CREATE INDEX IF NOT EXISTS idx_leak_patterns_enabled ON leak_detection_patterns(enabled);
+
+-- tool_rate_limit_state
+CREATE INDEX IF NOT EXISTS idx_rate_limit_tool ON tool_rate_limit_state(wasm_tool_id);
+
+-- secret_usage_log
+CREATE INDEX IF NOT EXISTS idx_secret_usage_secret ON secret_usage_log(secret_id);
+CREATE INDEX IF NOT EXISTS idx_secret_usage_tool ON secret_usage_log(wasm_tool_id);
+CREATE INDEX IF NOT EXISTS idx_secret_usage_created ON secret_usage_log(created_at DESC);
+
+-- leak_detection_events
+CREATE INDEX IF NOT EXISTS idx_leak_events_pattern ON leak_detection_events(pattern_id);
+CREATE INDEX IF NOT EXISTS idx_leak_events_tool ON leak_detection_events(wasm_tool_id);
+CREATE INDEX IF NOT EXISTS idx_leak_events_user ON leak_detection_events(user_id);
+CREATE INDEX IF NOT EXISTS idx_leak_events_created ON leak_detection_events(created_at DESC);
+
+-- tool_failures
+CREATE INDEX IF NOT EXISTS idx_tool_failures_count ON tool_failures(error_count DESC);
+CREATE INDEX IF NOT EXISTS idx_tool_failures_unrepaired ON tool_failures(tool_name);
+
+-- routines
+CREATE INDEX IF NOT EXISTS idx_routines_next_fire ON routines(next_fire_at);
+CREATE INDEX IF NOT EXISTS idx_routines_event_triggers ON routines(user_id);
+
+-- routine_runs
+CREATE INDEX IF NOT EXISTS idx_routine_runs_status ON routine_runs(status);
+
+-- heartbeat_state
+CREATE INDEX IF NOT EXISTS idx_heartbeat_next_run ON heartbeat_state(next_run);
+
+-- ==================== Seed data ====================
+
+-- Pre-populate leak detection patterns (matches PostgreSQL V2 migration).
+INSERT OR IGNORE INTO leak_detection_patterns (id, name, pattern, severity, action, enabled, created_at) VALUES
+    ('550e8400-e29b-41d4-a716-446655440001', 'openai_api_key', 'sk-(?:proj-)?[a-zA-Z0-9]{20,}(?:T3BlbkFJ[a-zA-Z0-9_-]*)?', 'critical', 'block', 1, datetime('now')),
+    ('550e8400-e29b-41d4-a716-446655440002', 'anthropic_api_key', 'sk-ant-api[a-zA-Z0-9_-]{90,}', 'critical', 'block', 1, datetime('now')),
+    ('550e8400-e29b-41d4-a716-446655440003', 'aws_access_key', 'AKIA[0-9A-Z]{16}', 'critical', 'block', 1, datetime('now')),
+    ('550e8400-e29b-41d4-a716-446655440004', 'aws_secret_key', '(?`.
+
+#[cfg(feature = "postgres")]
+pub mod postgres;
+
+#[cfg(feature = "libsql")]
+pub mod libsql_backend;
+
+#[cfg(feature = "libsql")]
+pub mod libsql_migrations;
+
+use std::collections::HashMap;
+use std::sync::Arc;
+
+use async_trait::async_trait;
+use chrono::{DateTime, Utc};
+use rust_decimal::Decimal;
+use uuid::Uuid;
+
+use crate::agent::BrokenTool;
+use crate::agent::routine::{Routine, RoutineRun, RunStatus};
+use crate::context::{ActionRecord, JobContext, JobState};
+use crate::error::DatabaseError;
+use crate::error::WorkspaceError;
+use crate::history::{
+    ConversationMessage, ConversationSummary, JobEventRecord, LlmCallRecord, SandboxJobRecord,
+    SandboxJobSummary, SettingRow,
+};
+use crate::workspace::{MemoryChunk, MemoryDocument, WorkspaceEntry};
+use crate::workspace::{SearchConfig, SearchResult};
+
+/// Create a database backend from configuration, run migrations, and return it.
+///
+/// This is the shared helper for CLI commands and other call sites that need
+/// a simple `Arc` without retaining backend-specific handles
+/// (e.g., `pg_pool` or `libsql_conn` for the secrets store). The main agent
+/// startup in `main.rs` uses its own initialization block because it also
+/// captures those backend-specific handles.
+pub async fn connect_from_config(
+    config: &crate::config::DatabaseConfig,
+) -> Result, DatabaseError> {
+    match config.backend {
+        #[cfg(feature = "libsql")]
+        crate::config::DatabaseBackend::LibSql => {
+            use secrecy::ExposeSecret as _;
+
+            let default_path = crate::config::default_libsql_path();
+            let db_path = config.libsql_path.as_deref().unwrap_or(&default_path);
+
+            let backend = if let Some(ref url) = config.libsql_url {
+                let token = config.libsql_auth_token.as_ref().ok_or_else(|| {
+                    DatabaseError::Pool(
+                        "LIBSQL_AUTH_TOKEN required when LIBSQL_URL is set".to_string(),
+                    )
+                })?;
+                libsql_backend::LibSqlBackend::new_remote_replica(
+                    db_path,
+                    url,
+                    token.expose_secret(),
+                )
+                .await
+                .map_err(|e| DatabaseError::Pool(e.to_string()))?
+            } else {
+                libsql_backend::LibSqlBackend::new_local(db_path)
+                    .await
+                    .map_err(|e| DatabaseError::Pool(e.to_string()))?
+            };
+            backend.run_migrations().await?;
+            Ok(Arc::new(backend))
+        }
+        #[cfg(feature = "postgres")]
+        _ => {
+            let pg = postgres::PgBackend::new(config)
+                .await
+                .map_err(|e| DatabaseError::Pool(e.to_string()))?;
+            pg.run_migrations().await?;
+            Ok(Arc::new(pg))
+        }
+        #[cfg(not(feature = "postgres"))]
+        _ => Err(DatabaseError::Pool(
+            "No database backend available. Enable 'postgres' or 'libsql' feature.".to_string(),
+        )),
+    }
+}
+
+/// Backend-agnostic database trait.
+///
+/// Combines all persistence operations from Store, Repository, and related
+/// stores into a single trait that can be implemented for different backends.
+#[async_trait]
+pub trait Database: Send + Sync {
+    /// Run schema migrations for this backend.
+    async fn run_migrations(&self) -> Result<(), DatabaseError>;
+
+    // ==================== Conversations ====================
+
+    /// Create a new conversation.
+    async fn create_conversation(
+        &self,
+        channel: &str,
+        user_id: &str,
+        thread_id: Option<&str>,
+    ) -> Result;
+
+    /// Update conversation last activity.
+    async fn touch_conversation(&self, id: Uuid) -> Result<(), DatabaseError>;
+
+    /// Add a message to a conversation.
+    async fn add_conversation_message(
+        &self,
+        conversation_id: Uuid,
+        role: &str,
+        content: &str,
+    ) -> Result;
+
+    /// Ensure a conversation row exists (upsert).
+    async fn ensure_conversation(
+        &self,
+        id: Uuid,
+        channel: &str,
+        user_id: &str,
+        thread_id: Option<&str>,
+    ) -> Result<(), DatabaseError>;
+
+    /// List conversations with a title preview.
+    async fn list_conversations_with_preview(
+        &self,
+        user_id: &str,
+        channel: &str,
+        limit: i64,
+    ) -> Result, DatabaseError>;
+
+    /// Get or create the singleton assistant conversation.
+    async fn get_or_create_assistant_conversation(
+        &self,
+        user_id: &str,
+        channel: &str,
+    ) -> Result;
+
+    /// Create a conversation with specific metadata.
+    async fn create_conversation_with_metadata(
+        &self,
+        channel: &str,
+        user_id: &str,
+        metadata: &serde_json::Value,
+    ) -> Result;
+
+    /// Load messages with cursor-based pagination.
+    async fn list_conversation_messages_paginated(
+        &self,
+        conversation_id: Uuid,
+        before: Option>,
+        limit: i64,
+    ) -> Result<(Vec, bool), DatabaseError>;
+
+    /// Merge a single key into conversation metadata.
+    async fn update_conversation_metadata_field(
+        &self,
+        id: Uuid,
+        key: &str,
+        value: &serde_json::Value,
+    ) -> Result<(), DatabaseError>;
+
+    /// Read conversation metadata.
+    async fn get_conversation_metadata(
+        &self,
+        id: Uuid,
+    ) -> Result, DatabaseError>;
+
+    /// Load all messages for a conversation.
+    async fn list_conversation_messages(
+        &self,
+        conversation_id: Uuid,
+    ) -> Result, DatabaseError>;
+
+    /// Check if a conversation belongs to a specific user.
+    async fn conversation_belongs_to_user(
+        &self,
+        conversation_id: Uuid,
+        user_id: &str,
+    ) -> Result;
+
+    // ==================== Jobs ====================
+
+    /// Save a job context.
+    async fn save_job(&self, ctx: &JobContext) -> Result<(), DatabaseError>;
+
+    /// Get a job by ID.
+    async fn get_job(&self, id: Uuid) -> Result, DatabaseError>;
+
+    /// Update job status.
+    async fn update_job_status(
+        &self,
+        id: Uuid,
+        status: JobState,
+        failure_reason: Option<&str>,
+    ) -> Result<(), DatabaseError>;
+
+    /// Mark job as stuck.
+    async fn mark_job_stuck(&self, id: Uuid) -> Result<(), DatabaseError>;
+
+    /// Get stuck jobs.
+    async fn get_stuck_jobs(&self) -> Result, DatabaseError>;
+
+    // ==================== Actions ====================
+
+    /// Save a job action.
+    async fn save_action(&self, job_id: Uuid, action: &ActionRecord) -> Result<(), DatabaseError>;
+
+    /// Get actions for a job.
+    async fn get_job_actions(&self, job_id: Uuid) -> Result, DatabaseError>;
+
+    // ==================== LLM Calls ====================
+
+    /// Record an LLM call.
+    async fn record_llm_call(&self, record: &LlmCallRecord<'_>) -> Result;
+
+    // ==================== Estimation Snapshots ====================
+
+    /// Save an estimation snapshot.
+    async fn save_estimation_snapshot(
+        &self,
+        job_id: Uuid,
+        category: &str,
+        tool_names: &[String],
+        estimated_cost: Decimal,
+        estimated_time_secs: i32,
+        estimated_value: Decimal,
+    ) -> Result;
+
+    /// Update estimation snapshot with actual values.
+    async fn update_estimation_actuals(
+        &self,
+        id: Uuid,
+        actual_cost: Decimal,
+        actual_time_secs: i32,
+        actual_value: Option,
+    ) -> Result<(), DatabaseError>;
+
+    // ==================== Sandbox Jobs ====================
+
+    /// Insert a new sandbox job.
+    async fn save_sandbox_job(&self, job: &SandboxJobRecord) -> Result<(), DatabaseError>;
+
+    /// Get a sandbox job by ID.
+    async fn get_sandbox_job(&self, id: Uuid) -> Result, DatabaseError>;
+
+    /// List all sandbox jobs, most recent first.
+    async fn list_sandbox_jobs(&self) -> Result, DatabaseError>;
+
+    /// Update sandbox job status.
+    async fn update_sandbox_job_status(
+        &self,
+        id: Uuid,
+        status: &str,
+        success: Option,
+        message: Option<&str>,
+        started_at: Option>,
+        completed_at: Option>,
+    ) -> Result<(), DatabaseError>;
+
+    /// Mark stale sandbox jobs as interrupted.
+    async fn cleanup_stale_sandbox_jobs(&self) -> Result;
+
+    /// Get sandbox job summary.
+    async fn sandbox_job_summary(&self) -> Result;
+
+    /// List sandbox jobs for a specific user, most recent first.
+    async fn list_sandbox_jobs_for_user(
+        &self,
+        user_id: &str,
+    ) -> Result, DatabaseError>;
+
+    /// Get sandbox job summary for a specific user.
+    async fn sandbox_job_summary_for_user(
+        &self,
+        user_id: &str,
+    ) -> Result;
+
+    /// Check if a sandbox job belongs to a specific user.
+    async fn sandbox_job_belongs_to_user(
+        &self,
+        job_id: Uuid,
+        user_id: &str,
+    ) -> Result;
+
+    /// Update sandbox job mode.
+    async fn update_sandbox_job_mode(&self, id: Uuid, mode: &str) -> Result<(), DatabaseError>;
+
+    /// Get sandbox job mode.
+    async fn get_sandbox_job_mode(&self, id: Uuid) -> Result, DatabaseError>;
+
+    // ==================== Job Events ====================
+
+    /// Persist a job event.
+    async fn save_job_event(
+        &self,
+        job_id: Uuid,
+        event_type: &str,
+        data: &serde_json::Value,
+    ) -> Result<(), DatabaseError>;
+
+    /// Load all job events.
+    async fn list_job_events(&self, job_id: Uuid) -> Result, DatabaseError>;
+
+    // ==================== Routines ====================
+
+    /// Create a new routine.
+    async fn create_routine(&self, routine: &Routine) -> Result<(), DatabaseError>;
+
+    /// Get a routine by ID.
+    async fn get_routine(&self, id: Uuid) -> Result, DatabaseError>;
+
+    /// Get a routine by user_id and name.
+    async fn get_routine_by_name(
+        &self,
+        user_id: &str,
+        name: &str,
+    ) -> Result, DatabaseError>;
+
+    /// List routines for a user.
+    async fn list_routines(&self, user_id: &str) -> Result, DatabaseError>;
+
+    /// List all enabled event routines.
+    async fn list_event_routines(&self) -> Result, DatabaseError>;
+
+    /// List due cron routines.
+    async fn list_due_cron_routines(&self) -> Result, DatabaseError>;
+
+    /// Update a routine.
+    async fn update_routine(&self, routine: &Routine) -> Result<(), DatabaseError>;
+
+    /// Update runtime state after a routine fires.
+    async fn update_routine_runtime(
+        &self,
+        id: Uuid,
+        last_run_at: DateTime,
+        next_fire_at: Option>,
+        run_count: u64,
+        consecutive_failures: u32,
+        state: &serde_json::Value,
+    ) -> Result<(), DatabaseError>;
+
+    /// Delete a routine.
+    async fn delete_routine(&self, id: Uuid) -> Result;
+
+    // ==================== Routine Runs ====================
+
+    /// Record a routine run starting.
+    async fn create_routine_run(&self, run: &RoutineRun) -> Result<(), DatabaseError>;
+
+    /// Complete a routine run.
+    async fn complete_routine_run(
+        &self,
+        id: Uuid,
+        status: RunStatus,
+        result_summary: Option<&str>,
+        tokens_used: Option,
+    ) -> Result<(), DatabaseError>;
+
+    /// List recent runs for a routine.
+    async fn list_routine_runs(
+        &self,
+        routine_id: Uuid,
+        limit: i64,
+    ) -> Result, DatabaseError>;
+
+    /// Count currently running runs for a routine.
+    async fn count_running_routine_runs(&self, routine_id: Uuid) -> Result;
+
+    // ==================== Tool Failures ====================
+
+    /// Record a tool failure (upsert).
+    async fn record_tool_failure(
+        &self,
+        tool_name: &str,
+        error_message: &str,
+    ) -> Result<(), DatabaseError>;
+
+    /// Get broken tools exceeding threshold.
+    async fn get_broken_tools(&self, threshold: i32) -> Result, DatabaseError>;
+
+    /// Mark a tool as repaired.
+    async fn mark_tool_repaired(&self, tool_name: &str) -> Result<(), DatabaseError>;
+
+    /// Increment repair attempts.
+    async fn increment_repair_attempts(&self, tool_name: &str) -> Result<(), DatabaseError>;
+
+    // ==================== Settings ====================
+
+    /// Get a single setting.
+    async fn get_setting(
+        &self,
+        user_id: &str,
+        key: &str,
+    ) -> Result, DatabaseError>;
+
+    /// Get a single setting with metadata.
+    async fn get_setting_full(
+        &self,
+        user_id: &str,
+        key: &str,
+    ) -> Result, DatabaseError>;
+
+    /// Set a single setting (upsert).
+    async fn set_setting(
+        &self,
+        user_id: &str,
+        key: &str,
+        value: &serde_json::Value,
+    ) -> Result<(), DatabaseError>;
+
+    /// Delete a single setting.
+    async fn delete_setting(&self, user_id: &str, key: &str) -> Result;
+
+    /// List all settings for a user.
+    async fn list_settings(&self, user_id: &str) -> Result, DatabaseError>;
+
+    /// Get all settings as a flat map.
+    async fn get_all_settings(
+        &self,
+        user_id: &str,
+    ) -> Result, DatabaseError>;
+
+    /// Bulk-write settings atomically.
+    async fn set_all_settings(
+        &self,
+        user_id: &str,
+        settings: &HashMap,
+    ) -> Result<(), DatabaseError>;
+
+    /// Check if settings exist for a user.
+    async fn has_settings(&self, user_id: &str) -> Result;
+
+    // ==================== Workspace: Documents ====================
+
+    /// Get a document by path.
+    async fn get_document_by_path(
+        &self,
+        user_id: &str,
+        agent_id: Option,
+        path: &str,
+    ) -> Result;
+
+    /// Get a document by ID.
+    async fn get_document_by_id(&self, id: Uuid) -> Result;
+
+    /// Get or create a document by path.
+    async fn get_or_create_document_by_path(
+        &self,
+        user_id: &str,
+        agent_id: Option,
+        path: &str,
+    ) -> Result;
+
+    /// Update a document's content.
+    async fn update_document(&self, id: Uuid, content: &str) -> Result<(), WorkspaceError>;
+
+    /// Delete a document by path.
+    async fn delete_document_by_path(
+        &self,
+        user_id: &str,
+        agent_id: Option,
+        path: &str,
+    ) -> Result<(), WorkspaceError>;
+
+    /// List files and directories in a directory path.
+    async fn list_directory(
+        &self,
+        user_id: &str,
+        agent_id: Option,
+        directory: &str,
+    ) -> Result, WorkspaceError>;
+
+    /// List all file paths in the workspace.
+    async fn list_all_paths(
+        &self,
+        user_id: &str,
+        agent_id: Option,
+    ) -> Result, WorkspaceError>;
+
+    /// List all documents for a user.
+    async fn list_documents(
+        &self,
+        user_id: &str,
+        agent_id: Option,
+    ) -> Result, WorkspaceError>;
+
+    // ==================== Workspace: Chunks ====================
+
+    /// Delete all chunks for a document.
+    async fn delete_chunks(&self, document_id: Uuid) -> Result<(), WorkspaceError>;
+
+    /// Insert a chunk.
+    async fn insert_chunk(
+        &self,
+        document_id: Uuid,
+        chunk_index: i32,
+        content: &str,
+        embedding: Option<&[f32]>,
+    ) -> Result;
+
+    /// Update a chunk's embedding.
+    async fn update_chunk_embedding(
+        &self,
+        chunk_id: Uuid,
+        embedding: &[f32],
+    ) -> Result<(), WorkspaceError>;
+
+    /// Get chunks without embeddings for backfilling.
+    async fn get_chunks_without_embeddings(
+        &self,
+        user_id: &str,
+        agent_id: Option,
+        limit: usize,
+    ) -> Result, WorkspaceError>;
+
+    // ==================== Workspace: Search ====================
+
+    /// Perform hybrid search combining FTS and vector similarity.
+    async fn hybrid_search(
+        &self,
+        user_id: &str,
+        agent_id: Option,
+        query: &str,
+        embedding: Option<&[f32]>,
+        config: &SearchConfig,
+    ) -> Result, WorkspaceError>;
+}
diff --git a/src/db/postgres.rs b/src/db/postgres.rs
new file mode 100644
index 00000000..9676144c
--- /dev/null
+++ b/src/db/postgres.rs
@@ -0,0 +1,627 @@
+//! PostgreSQL backend for the Database trait.
+//!
+//! Delegates to the existing `Store` (history) and `Repository` (workspace)
+//! implementations, avoiding SQL duplication.
+
+use std::collections::HashMap;
+
+use async_trait::async_trait;
+use chrono::{DateTime, Utc};
+use deadpool_postgres::Pool;
+use rust_decimal::Decimal;
+use uuid::Uuid;
+
+use crate::agent::BrokenTool;
+use crate::agent::routine::{Routine, RoutineRun, RunStatus};
+use crate::config::DatabaseConfig;
+use crate::context::{ActionRecord, JobContext, JobState};
+use crate::db::Database;
+use crate::error::{DatabaseError, WorkspaceError};
+use crate::history::{
+    ConversationMessage, ConversationSummary, JobEventRecord, LlmCallRecord, SandboxJobRecord,
+    SandboxJobSummary, SettingRow, Store,
+};
+use crate::workspace::{
+    MemoryChunk, MemoryDocument, Repository, SearchConfig, SearchResult, WorkspaceEntry,
+};
+
+/// PostgreSQL database backend.
+///
+/// Wraps the existing `Store` (for history/conversations/jobs/routines/settings)
+/// and `Repository` (for workspace documents/chunks/search) to implement the
+/// unified `Database` trait.
+pub struct PgBackend {
+    store: Store,
+    repo: Repository,
+}
+
+impl PgBackend {
+    /// Create a new PostgreSQL backend from configuration.
+    pub async fn new(config: &DatabaseConfig) -> Result {
+        let store = Store::new(config).await?;
+        let repo = Repository::new(store.pool());
+        Ok(Self { store, repo })
+    }
+
+    /// Get a clone of the connection pool.
+    ///
+    /// Useful for sharing with components that still need raw pool access.
+    pub fn pool(&self) -> Pool {
+        self.store.pool()
+    }
+}
+
+#[async_trait]
+impl Database for PgBackend {
+    async fn run_migrations(&self) -> Result<(), DatabaseError> {
+        self.store.run_migrations().await
+    }
+
+    // ==================== Conversations ====================
+
+    async fn create_conversation(
+        &self,
+        channel: &str,
+        user_id: &str,
+        thread_id: Option<&str>,
+    ) -> Result {
+        self.store
+            .create_conversation(channel, user_id, thread_id)
+            .await
+    }
+
+    async fn touch_conversation(&self, id: Uuid) -> Result<(), DatabaseError> {
+        self.store.touch_conversation(id).await
+    }
+
+    async fn add_conversation_message(
+        &self,
+        conversation_id: Uuid,
+        role: &str,
+        content: &str,
+    ) -> Result {
+        self.store
+            .add_conversation_message(conversation_id, role, content)
+            .await
+    }
+
+    async fn ensure_conversation(
+        &self,
+        id: Uuid,
+        channel: &str,
+        user_id: &str,
+        thread_id: Option<&str>,
+    ) -> Result<(), DatabaseError> {
+        self.store
+            .ensure_conversation(id, channel, user_id, thread_id)
+            .await
+    }
+
+    async fn list_conversations_with_preview(
+        &self,
+        user_id: &str,
+        channel: &str,
+        limit: i64,
+    ) -> Result, DatabaseError> {
+        self.store
+            .list_conversations_with_preview(user_id, channel, limit)
+            .await
+    }
+
+    async fn get_or_create_assistant_conversation(
+        &self,
+        user_id: &str,
+        channel: &str,
+    ) -> Result {
+        self.store
+            .get_or_create_assistant_conversation(user_id, channel)
+            .await
+    }
+
+    async fn create_conversation_with_metadata(
+        &self,
+        channel: &str,
+        user_id: &str,
+        metadata: &serde_json::Value,
+    ) -> Result {
+        self.store
+            .create_conversation_with_metadata(channel, user_id, metadata)
+            .await
+    }
+
+    async fn list_conversation_messages_paginated(
+        &self,
+        conversation_id: Uuid,
+        before: Option>,
+        limit: i64,
+    ) -> Result<(Vec, bool), DatabaseError> {
+        self.store
+            .list_conversation_messages_paginated(conversation_id, before, limit)
+            .await
+    }
+
+    async fn update_conversation_metadata_field(
+        &self,
+        id: Uuid,
+        key: &str,
+        value: &serde_json::Value,
+    ) -> Result<(), DatabaseError> {
+        self.store
+            .update_conversation_metadata_field(id, key, value)
+            .await
+    }
+
+    async fn get_conversation_metadata(
+        &self,
+        id: Uuid,
+    ) -> Result, DatabaseError> {
+        self.store.get_conversation_metadata(id).await
+    }
+
+    async fn list_conversation_messages(
+        &self,
+        conversation_id: Uuid,
+    ) -> Result, DatabaseError> {
+        self.store.list_conversation_messages(conversation_id).await
+    }
+
+    async fn conversation_belongs_to_user(
+        &self,
+        conversation_id: Uuid,
+        user_id: &str,
+    ) -> Result {
+        self.store
+            .conversation_belongs_to_user(conversation_id, user_id)
+            .await
+    }
+
+    // ==================== Jobs ====================
+
+    async fn save_job(&self, ctx: &JobContext) -> Result<(), DatabaseError> {
+        self.store.save_job(ctx).await
+    }
+
+    async fn get_job(&self, id: Uuid) -> Result, DatabaseError> {
+        self.store.get_job(id).await
+    }
+
+    async fn update_job_status(
+        &self,
+        id: Uuid,
+        status: JobState,
+        failure_reason: Option<&str>,
+    ) -> Result<(), DatabaseError> {
+        self.store
+            .update_job_status(id, status, failure_reason)
+            .await
+    }
+
+    async fn mark_job_stuck(&self, id: Uuid) -> Result<(), DatabaseError> {
+        self.store.mark_job_stuck(id).await
+    }
+
+    async fn get_stuck_jobs(&self) -> Result, DatabaseError> {
+        self.store.get_stuck_jobs().await
+    }
+
+    // ==================== Actions ====================
+
+    async fn save_action(&self, job_id: Uuid, action: &ActionRecord) -> Result<(), DatabaseError> {
+        self.store.save_action(job_id, action).await
+    }
+
+    async fn get_job_actions(&self, job_id: Uuid) -> Result, DatabaseError> {
+        self.store.get_job_actions(job_id).await
+    }
+
+    // ==================== LLM Calls ====================
+
+    async fn record_llm_call(&self, record: &LlmCallRecord<'_>) -> Result {
+        self.store.record_llm_call(record).await
+    }
+
+    // ==================== Estimation Snapshots ====================
+
+    async fn save_estimation_snapshot(
+        &self,
+        job_id: Uuid,
+        category: &str,
+        tool_names: &[String],
+        estimated_cost: Decimal,
+        estimated_time_secs: i32,
+        estimated_value: Decimal,
+    ) -> Result {
+        self.store
+            .save_estimation_snapshot(
+                job_id,
+                category,
+                tool_names,
+                estimated_cost,
+                estimated_time_secs,
+                estimated_value,
+            )
+            .await
+    }
+
+    async fn update_estimation_actuals(
+        &self,
+        id: Uuid,
+        actual_cost: Decimal,
+        actual_time_secs: i32,
+        actual_value: Option,
+    ) -> Result<(), DatabaseError> {
+        self.store
+            .update_estimation_actuals(id, actual_cost, actual_time_secs, actual_value)
+            .await
+    }
+
+    // ==================== Sandbox Jobs ====================
+
+    async fn save_sandbox_job(&self, job: &SandboxJobRecord) -> Result<(), DatabaseError> {
+        self.store.save_sandbox_job(job).await
+    }
+
+    async fn get_sandbox_job(&self, id: Uuid) -> Result, DatabaseError> {
+        self.store.get_sandbox_job(id).await
+    }
+
+    async fn list_sandbox_jobs(&self) -> Result, DatabaseError> {
+        self.store.list_sandbox_jobs().await
+    }
+
+    async fn update_sandbox_job_status(
+        &self,
+        id: Uuid,
+        status: &str,
+        success: Option,
+        message: Option<&str>,
+        started_at: Option>,
+        completed_at: Option>,
+    ) -> Result<(), DatabaseError> {
+        self.store
+            .update_sandbox_job_status(id, status, success, message, started_at, completed_at)
+            .await
+    }
+
+    async fn cleanup_stale_sandbox_jobs(&self) -> Result {
+        self.store.cleanup_stale_sandbox_jobs().await
+    }
+
+    async fn sandbox_job_summary(&self) -> Result {
+        self.store.sandbox_job_summary().await
+    }
+
+    async fn list_sandbox_jobs_for_user(
+        &self,
+        user_id: &str,
+    ) -> Result, DatabaseError> {
+        self.store.list_sandbox_jobs_for_user(user_id).await
+    }
+
+    async fn sandbox_job_summary_for_user(
+        &self,
+        user_id: &str,
+    ) -> Result {
+        self.store.sandbox_job_summary_for_user(user_id).await
+    }
+
+    async fn sandbox_job_belongs_to_user(
+        &self,
+        job_id: Uuid,
+        user_id: &str,
+    ) -> Result {
+        self.store
+            .sandbox_job_belongs_to_user(job_id, user_id)
+            .await
+    }
+
+    async fn update_sandbox_job_mode(&self, id: Uuid, mode: &str) -> Result<(), DatabaseError> {
+        self.store.update_sandbox_job_mode(id, mode).await
+    }
+
+    async fn get_sandbox_job_mode(&self, id: Uuid) -> Result, DatabaseError> {
+        self.store.get_sandbox_job_mode(id).await
+    }
+
+    // ==================== Job Events ====================
+
+    async fn save_job_event(
+        &self,
+        job_id: Uuid,
+        event_type: &str,
+        data: &serde_json::Value,
+    ) -> Result<(), DatabaseError> {
+        self.store.save_job_event(job_id, event_type, data).await
+    }
+
+    async fn list_job_events(&self, job_id: Uuid) -> Result, DatabaseError> {
+        self.store.list_job_events(job_id).await
+    }
+
+    // ==================== Routines ====================
+
+    async fn create_routine(&self, routine: &Routine) -> Result<(), DatabaseError> {
+        self.store.create_routine(routine).await
+    }
+
+    async fn get_routine(&self, id: Uuid) -> Result, DatabaseError> {
+        self.store.get_routine(id).await
+    }
+
+    async fn get_routine_by_name(
+        &self,
+        user_id: &str,
+        name: &str,
+    ) -> Result, DatabaseError> {
+        self.store.get_routine_by_name(user_id, name).await
+    }
+
+    async fn list_routines(&self, user_id: &str) -> Result, DatabaseError> {
+        self.store.list_routines(user_id).await
+    }
+
+    async fn list_event_routines(&self) -> Result, DatabaseError> {
+        self.store.list_event_routines().await
+    }
+
+    async fn list_due_cron_routines(&self) -> Result, DatabaseError> {
+        self.store.list_due_cron_routines().await
+    }
+
+    async fn update_routine(&self, routine: &Routine) -> Result<(), DatabaseError> {
+        self.store.update_routine(routine).await
+    }
+
+    async fn update_routine_runtime(
+        &self,
+        id: Uuid,
+        last_run_at: DateTime,
+        next_fire_at: Option>,
+        run_count: u64,
+        consecutive_failures: u32,
+        state: &serde_json::Value,
+    ) -> Result<(), DatabaseError> {
+        self.store
+            .update_routine_runtime(
+                id,
+                last_run_at,
+                next_fire_at,
+                run_count,
+                consecutive_failures,
+                state,
+            )
+            .await
+    }
+
+    async fn delete_routine(&self, id: Uuid) -> Result {
+        self.store.delete_routine(id).await
+    }
+
+    // ==================== Routine Runs ====================
+
+    async fn create_routine_run(&self, run: &RoutineRun) -> Result<(), DatabaseError> {
+        self.store.create_routine_run(run).await
+    }
+
+    async fn complete_routine_run(
+        &self,
+        id: Uuid,
+        status: RunStatus,
+        result_summary: Option<&str>,
+        tokens_used: Option,
+    ) -> Result<(), DatabaseError> {
+        self.store
+            .complete_routine_run(id, status, result_summary, tokens_used)
+            .await
+    }
+
+    async fn list_routine_runs(
+        &self,
+        routine_id: Uuid,
+        limit: i64,
+    ) -> Result, DatabaseError> {
+        self.store.list_routine_runs(routine_id, limit).await
+    }
+
+    async fn count_running_routine_runs(&self, routine_id: Uuid) -> Result {
+        self.store.count_running_routine_runs(routine_id).await
+    }
+
+    // ==================== Tool Failures ====================
+
+    async fn record_tool_failure(
+        &self,
+        tool_name: &str,
+        error_message: &str,
+    ) -> Result<(), DatabaseError> {
+        self.store
+            .record_tool_failure(tool_name, error_message)
+            .await
+    }
+
+    async fn get_broken_tools(&self, threshold: i32) -> Result, DatabaseError> {
+        self.store.get_broken_tools(threshold).await
+    }
+
+    async fn mark_tool_repaired(&self, tool_name: &str) -> Result<(), DatabaseError> {
+        self.store.mark_tool_repaired(tool_name).await
+    }
+
+    async fn increment_repair_attempts(&self, tool_name: &str) -> Result<(), DatabaseError> {
+        self.store.increment_repair_attempts(tool_name).await
+    }
+
+    // ==================== Settings ====================
+
+    async fn get_setting(
+        &self,
+        user_id: &str,
+        key: &str,
+    ) -> Result, DatabaseError> {
+        self.store.get_setting(user_id, key).await
+    }
+
+    async fn get_setting_full(
+        &self,
+        user_id: &str,
+        key: &str,
+    ) -> Result, DatabaseError> {
+        self.store.get_setting_full(user_id, key).await
+    }
+
+    async fn set_setting(
+        &self,
+        user_id: &str,
+        key: &str,
+        value: &serde_json::Value,
+    ) -> Result<(), DatabaseError> {
+        self.store.set_setting(user_id, key, value).await
+    }
+
+    async fn delete_setting(&self, user_id: &str, key: &str) -> Result {
+        self.store.delete_setting(user_id, key).await
+    }
+
+    async fn list_settings(&self, user_id: &str) -> Result, DatabaseError> {
+        self.store.list_settings(user_id).await
+    }
+
+    async fn get_all_settings(
+        &self,
+        user_id: &str,
+    ) -> Result, DatabaseError> {
+        self.store.get_all_settings(user_id).await
+    }
+
+    async fn set_all_settings(
+        &self,
+        user_id: &str,
+        settings: &HashMap,
+    ) -> Result<(), DatabaseError> {
+        self.store.set_all_settings(user_id, settings).await
+    }
+
+    async fn has_settings(&self, user_id: &str) -> Result {
+        self.store.has_settings(user_id).await
+    }
+
+    // ==================== Workspace: Documents ====================
+
+    async fn get_document_by_path(
+        &self,
+        user_id: &str,
+        agent_id: Option,
+        path: &str,
+    ) -> Result {
+        self.repo
+            .get_document_by_path(user_id, agent_id, path)
+            .await
+    }
+
+    async fn get_document_by_id(&self, id: Uuid) -> Result {
+        self.repo.get_document_by_id(id).await
+    }
+
+    async fn get_or_create_document_by_path(
+        &self,
+        user_id: &str,
+        agent_id: Option,
+        path: &str,
+    ) -> Result {
+        self.repo
+            .get_or_create_document_by_path(user_id, agent_id, path)
+            .await
+    }
+
+    async fn update_document(&self, id: Uuid, content: &str) -> Result<(), WorkspaceError> {
+        self.repo.update_document(id, content).await
+    }
+
+    async fn delete_document_by_path(
+        &self,
+        user_id: &str,
+        agent_id: Option,
+        path: &str,
+    ) -> Result<(), WorkspaceError> {
+        self.repo
+            .delete_document_by_path(user_id, agent_id, path)
+            .await
+    }
+
+    async fn list_directory(
+        &self,
+        user_id: &str,
+        agent_id: Option,
+        directory: &str,
+    ) -> Result, WorkspaceError> {
+        self.repo.list_directory(user_id, agent_id, directory).await
+    }
+
+    async fn list_all_paths(
+        &self,
+        user_id: &str,
+        agent_id: Option,
+    ) -> Result, WorkspaceError> {
+        self.repo.list_all_paths(user_id, agent_id).await
+    }
+
+    async fn list_documents(
+        &self,
+        user_id: &str,
+        agent_id: Option,
+    ) -> Result, WorkspaceError> {
+        self.repo.list_documents(user_id, agent_id).await
+    }
+
+    // ==================== Workspace: Chunks ====================
+
+    async fn delete_chunks(&self, document_id: Uuid) -> Result<(), WorkspaceError> {
+        self.repo.delete_chunks(document_id).await
+    }
+
+    async fn insert_chunk(
+        &self,
+        document_id: Uuid,
+        chunk_index: i32,
+        content: &str,
+        embedding: Option<&[f32]>,
+    ) -> Result {
+        self.repo
+            .insert_chunk(document_id, chunk_index, content, embedding)
+            .await
+    }
+
+    async fn update_chunk_embedding(
+        &self,
+        chunk_id: Uuid,
+        embedding: &[f32],
+    ) -> Result<(), WorkspaceError> {
+        self.repo.update_chunk_embedding(chunk_id, embedding).await
+    }
+
+    async fn get_chunks_without_embeddings(
+        &self,
+        user_id: &str,
+        agent_id: Option,
+        limit: usize,
+    ) -> Result, WorkspaceError> {
+        self.repo
+            .get_chunks_without_embeddings(user_id, agent_id, limit)
+            .await
+    }
+
+    // ==================== Workspace: Search ====================
+
+    async fn hybrid_search(
+        &self,
+        user_id: &str,
+        agent_id: Option,
+        query: &str,
+        embedding: Option<&[f32]>,
+        config: &SearchConfig,
+    ) -> Result, WorkspaceError> {
+        self.repo
+            .hybrid_search(user_id, agent_id, query, embedding, config)
+            .await
+    }
+}
diff --git a/src/error.rs b/src/error.rs
index 189589d0..acb3598a 100644
--- a/src/error.rs
+++ b/src/error.rs
@@ -87,14 +87,21 @@ pub enum DatabaseError {
     #[error("Serialization error: {0}")]
     Serialization(String),
 
+    #[cfg(feature = "postgres")]
     #[error("PostgreSQL error: {0}")]
     Postgres(#[from] tokio_postgres::Error),
 
+    #[cfg(feature = "postgres")]
     #[error("Pool build error: {0}")]
     PoolBuild(#[from] deadpool_postgres::BuildError),
 
+    #[cfg(feature = "postgres")]
     #[error("Pool runtime error: {0}")]
     PoolRuntime(#[from] deadpool_postgres::PoolError),
+
+    #[cfg(feature = "libsql")]
+    #[error("LibSQL error: {0}")]
+    LibSql(#[from] libsql::Error),
 }
 
 /// Channel-related errors.
diff --git a/src/extensions/manager.rs b/src/extensions/manager.rs
index d8ecefc6..e6e7d264 100644
--- a/src/extensions/manager.rs
+++ b/src/extensions/manager.rs
@@ -57,7 +57,7 @@ pub struct ExtensionManager {
     _tunnel_url: Option,
     user_id: String,
     /// Optional database store for DB-backed MCP config.
-    store: Option>,
+    store: Option>,
 }
 
 impl ExtensionManager {
@@ -71,7 +71,7 @@ impl ExtensionManager {
         wasm_channels_dir: PathBuf,
         tunnel_url: Option,
         user_id: String,
-        store: Option>,
+        store: Option>,
     ) -> Self {
         Self {
             registry: ExtensionRegistry::new(),
@@ -351,7 +351,7 @@ impl ExtensionManager {
     ) -> Result
     {
         if let Some(ref store) = self.store {
-            crate::tools::mcp::config::load_mcp_servers_from_db(store, &self.user_id).await
+            crate::tools::mcp::config::load_mcp_servers_from_db(store.as_ref(), &self.user_id).await
         } else {
             crate::tools::mcp::config::load_mcp_servers().await
         }
@@ -375,7 +375,8 @@ impl ExtensionManager {
     ) -> Result<(), crate::tools::mcp::config::ConfigError> {
         config.validate()?;
         if let Some(ref store) = self.store {
-            crate::tools::mcp::config::add_mcp_server_db(store, &self.user_id, config).await
+            crate::tools::mcp::config::add_mcp_server_db(store.as_ref(), &self.user_id, config)
+                .await
         } else {
             crate::tools::mcp::config::add_mcp_server(config).await
         }
@@ -386,7 +387,8 @@ impl ExtensionManager {
         name: &str,
     ) -> Result<(), crate::tools::mcp::config::ConfigError> {
         if let Some(ref store) = self.store {
-            crate::tools::mcp::config::remove_mcp_server_db(store, &self.user_id, name).await
+            crate::tools::mcp::config::remove_mcp_server_db(store.as_ref(), &self.user_id, name)
+                .await
         } else {
             crate::tools::mcp::config::remove_mcp_server(name).await
         }
diff --git a/src/history/mod.rs b/src/history/mod.rs
index a9e5df35..4f448cfc 100644
--- a/src/history/mod.rs
+++ b/src/history/mod.rs
@@ -5,11 +5,15 @@
 //! - Learning from past executions
 //! - Analytics and metrics
 
+#[cfg(feature = "postgres")]
 mod analytics;
 mod store;
 
+#[cfg(feature = "postgres")]
 pub use analytics::{JobStats, ToolStats};
+#[cfg(feature = "postgres")]
+pub use store::Store;
 pub use store::{
     ConversationMessage, ConversationSummary, JobEventRecord, LlmCallRecord, SandboxJobRecord,
-    SandboxJobSummary, Store,
+    SandboxJobSummary, SettingRow,
 };
diff --git a/src/history/store.rs b/src/history/store.rs
index 0f20bc62..17e85598 100644
--- a/src/history/store.rs
+++ b/src/history/store.rs
@@ -1,13 +1,18 @@
 //! PostgreSQL store for persisting agent data.
 
 use chrono::{DateTime, Utc};
+#[cfg(feature = "postgres")]
 use deadpool_postgres::{Config, Pool, Runtime};
 use rust_decimal::Decimal;
+#[cfg(feature = "postgres")]
 use tokio_postgres::NoTls;
 use uuid::Uuid;
 
+#[cfg(feature = "postgres")]
 use crate::config::DatabaseConfig;
+#[cfg(feature = "postgres")]
 use crate::context::{ActionRecord, JobContext, JobState};
+#[cfg(feature = "postgres")]
 use crate::error::DatabaseError;
 
 /// Record for an LLM call to be persisted.
@@ -24,10 +29,12 @@ pub struct LlmCallRecord<'a> {
 }
 
 /// Database store for the agent.
+#[cfg(feature = "postgres")]
 pub struct Store {
     pool: Pool,
 }
 
+#[cfg(feature = "postgres")]
 impl Store {
     /// Create a new store and connect to the database.
     pub async fn new(config: &DatabaseConfig) -> Result {
@@ -144,7 +151,12 @@ impl Store {
                 actual_cost, repair_attempts, created_at, started_at, completed_at
             ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
             ON CONFLICT (id) DO UPDATE SET
+                title = EXCLUDED.title,
+                description = EXCLUDED.description,
+                category = EXCLUDED.category,
                 status = EXCLUDED.status,
+                estimated_cost = EXCLUDED.estimated_cost,
+                estimated_time_secs = EXCLUDED.estimated_time_secs,
                 actual_cost = EXCLUDED.actual_cost,
                 repair_attempts = EXCLUDED.repair_attempts,
                 started_at = EXCLUDED.started_at,
@@ -466,6 +478,7 @@ pub struct SandboxJobSummary {
     pub interrupted: usize,
 }
 
+#[cfg(feature = "postgres")]
 impl Store {
     /// Insert a new sandbox job into `agent_jobs`.
     pub async fn save_sandbox_job(&self, job: &SandboxJobRecord) -> Result<(), DatabaseError> {
@@ -742,6 +755,7 @@ pub struct JobEventRecord {
     pub created_at: DateTime,
 }
 
+#[cfg(feature = "postgres")]
 impl Store {
     /// Persist a job event (fire-and-forget from orchestrator handler).
     pub async fn save_job_event(
@@ -814,10 +828,12 @@ impl Store {
 
 // ==================== Routines ====================
 
+#[cfg(feature = "postgres")]
 use crate::agent::routine::{
     NotifyConfig, Routine, RoutineAction, RoutineGuardrails, RoutineRun, RunStatus, Trigger,
 };
 
+#[cfg(feature = "postgres")]
 impl Store {
     /// Create a new routine.
     pub async fn create_routine(&self, routine: &Routine) -> Result<(), DatabaseError> {
@@ -1118,6 +1134,7 @@ impl Store {
     }
 }
 
+#[cfg(feature = "postgres")]
 fn row_to_routine(row: &tokio_postgres::Row) -> Result {
     let trigger_type: String = row.get("trigger_type");
     let trigger_config: serde_json::Value = row.get("trigger_config");
@@ -1162,6 +1179,7 @@ fn row_to_routine(row: &tokio_postgres::Row) -> Result {
     })
 }
 
+#[cfg(feature = "postgres")]
 fn row_to_routine_run(row: &tokio_postgres::Row) -> Result {
     let status_str: String = row.get("status");
     let status: RunStatus = status_str
@@ -1207,6 +1225,7 @@ pub struct ConversationMessage {
     pub created_at: DateTime,
 }
 
+#[cfg(feature = "postgres")]
 impl Store {
     /// Ensure a conversation row exists for a given UUID.
     ///
@@ -1477,6 +1496,7 @@ impl Store {
     }
 }
 
+#[cfg(feature = "postgres")]
 fn parse_job_state(s: &str) -> JobState {
     match s {
         "pending" => JobState::Pending,
@@ -1493,8 +1513,10 @@ fn parse_job_state(s: &str) -> JobState {
 
 // ==================== Tool Failures ====================
 
+#[cfg(feature = "postgres")]
 use crate::agent::BrokenTool;
 
+#[cfg(feature = "postgres")]
 impl Store {
     /// Record a tool failure (upsert: increment count if exists).
     pub async fn record_tool_failure(
@@ -1588,6 +1610,7 @@ pub struct SettingRow {
     pub updated_at: DateTime,
 }
 
+#[cfg(feature = "postgres")]
 impl Store {
     /// Get a single setting by key.
     pub async fn get_setting(
diff --git a/src/lib.rs b/src/lib.rs
index 7185d3f3..ed72e3df 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -44,6 +44,7 @@ pub mod channels;
 pub mod cli;
 pub mod config;
 pub mod context;
+pub mod db;
 pub mod error;
 pub mod estimation;
 pub mod evaluation;
diff --git a/src/llm/session.rs b/src/llm/session.rs
index 1350a5ac..c4f0ebc2 100644
--- a/src/llm/session.rs
+++ b/src/llm/session.rs
@@ -62,7 +62,7 @@ pub struct SessionManager {
     /// Prevents thundering herd during concurrent 401s.
     renewal_lock: Mutex<()>,
     /// Optional database store for persisting session to the settings table.
-    store: RwLock>>,
+    store: RwLock>>,
     /// User ID for DB settings (default: "default").
     user_id: RwLock,
 }
@@ -125,7 +125,7 @@ impl SessionManager {
     /// When a store is attached, session tokens are saved to the `settings`
     /// table (key: `nearai.session_token`) in addition to the disk file.
     /// On load, DB is preferred over disk.
-    pub async fn attach_store(&self, store: Arc, user_id: &str) {
+    pub async fn attach_store(&self, store: Arc, user_id: &str) {
         *self.store.write().await = Some(store);
         *self.user_id.write().await = user_id.to_string();
 
diff --git a/src/main.rs b/src/main.rs
index cc619eb8..97355aaa 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -17,13 +17,11 @@ use ironclaw::{
         web::log_layer::{LogBroadcaster, WebLogLayer},
     },
     cli::{
-        Cli, Command, run_mcp_command, run_memory_command, run_pairing_command, run_status_command,
-        run_tool_command,
+        Cli, Command, run_mcp_command, run_pairing_command, run_status_command, run_tool_command,
     },
     config::Config,
     context::ContextManager,
     extensions::ExtensionManager,
-    history::Store,
     llm::{SessionConfig, create_llm_provider, create_session_manager},
     orchestrator::{
         ContainerJobConfig, ContainerJobManager, OrchestratorApi, TokenStore,
@@ -31,8 +29,7 @@ use ironclaw::{
     },
     pairing::PairingStore,
     safety::SafetyLayer,
-    secrets::{PostgresSecretsStore, SecretsCrypto, SecretsStore},
-    setup::{SetupConfig, SetupWizard},
+    secrets::SecretsStore,
     tools::{
         ToolRegistry,
         mcp::{McpClient, McpSessionManager, config::load_mcp_servers_from_db, is_authenticated},
@@ -41,6 +38,14 @@ use ironclaw::{
     workspace::{EmbeddingProvider, NearAiEmbeddings, OpenAiEmbeddings, Workspace},
 };
 
+#[cfg(feature = "libsql")]
+use ironclaw::secrets::LibSqlSecretsStore;
+#[cfg(feature = "postgres")]
+use ironclaw::secrets::PostgresSecretsStore;
+use ironclaw::secrets::SecretsCrypto;
+#[cfg(any(feature = "postgres", feature = "libsql"))]
+use ironclaw::setup::{SetupConfig, SetupWizard};
+
 #[tokio::main]
 async fn main() -> anyhow::Result<()> {
     let cli = Cli::parse();
@@ -89,8 +94,6 @@ async fn main() -> anyhow::Result<()> {
             let config = Config::from_env()
                 .await
                 .map_err(|e| anyhow::anyhow!("{}", e))?;
-            let store = ironclaw::history::Store::new(&config.database).await?;
-            store.run_migrations().await?;
 
             // Set up embeddings if available
             let session = ironclaw::llm::create_session_manager(ironclaw::llm::SessionConfig {
@@ -130,7 +133,14 @@ async fn main() -> anyhow::Result<()> {
                     None
                 };
 
-            return run_memory_command(mem_cmd.clone(), store.pool(), embeddings).await;
+            // Create a Database-trait-backed workspace for the memory command
+            let db: Arc =
+                ironclaw::db::connect_from_config(&config.database)
+                    .await
+                    .map_err(|e| anyhow::anyhow!("{}", e))?;
+
+            return ironclaw::cli::run_memory_command_with_db(mem_cmd.clone(), db, embeddings)
+                .await;
         }
         Some(Command::Pairing(pairing_cmd)) => {
             tracing_subscriber::fmt()
@@ -210,15 +220,13 @@ async fn main() -> anyhow::Result<()> {
                 model
             );
 
-            // Load allowed tools from config (env var or defaults).
-            let claude_config = ironclaw::config::ClaudeCodeConfig::from_env();
             let config = ironclaw::worker::claude_bridge::ClaudeBridgeConfig {
                 job_id: *job_id,
                 orchestrator_url: orchestrator_url.clone(),
                 max_turns: *max_turns,
                 model: model.clone(),
                 timeout: std::time::Duration::from_secs(1800),
-                allowed_tools: claude_config.allowed_tools,
+                allowed_tools: Vec::new(),
             };
 
             let runtime = ironclaw::worker::ClaudeBridgeRuntime::new(config)
@@ -238,12 +246,20 @@ async fn main() -> anyhow::Result<()> {
             // Load .env before running onboarding wizard
             let _ = dotenvy::dotenv();
 
-            let config = SetupConfig {
-                skip_auth: *skip_auth,
-                channels_only: *channels_only,
-            };
-            let mut wizard = SetupWizard::with_config(config);
-            wizard.run().await?;
+            #[cfg(any(feature = "postgres", feature = "libsql"))]
+            {
+                let config = SetupConfig {
+                    skip_auth: *skip_auth,
+                    channels_only: *channels_only,
+                };
+                let mut wizard = SetupWizard::with_config(config);
+                wizard.run().await?;
+            }
+            #[cfg(not(any(feature = "postgres", feature = "libsql")))]
+            {
+                let _ = (skip_auth, channels_only);
+                eprintln!("Onboarding wizard requires the 'postgres' or 'libsql' feature.");
+            }
             return Ok(());
         }
         None | Some(Command::Run) => {
@@ -255,6 +271,7 @@ async fn main() -> anyhow::Result<()> {
     let _ = dotenvy::dotenv();
 
     // Enhanced first-run detection
+    #[cfg(any(feature = "postgres", feature = "libsql"))]
     if !cli.no_onboard
         && let Some(reason) = check_onboard_needed().await
     {
@@ -326,23 +343,86 @@ async fn main() -> anyhow::Result<()> {
     tracing::info!("Loaded configuration for agent: {}", config.agent.name);
     tracing::info!("LLM backend: {}", config.llm.backend);
 
-    // Initialize database store (optional for testing)
-    let store = if cli.no_db {
+    // Initialize database backend.
+    //
+    // Creates an `Arc` that all consumers share.
+    // Backend is selected by the `DATABASE_BACKEND` env var / config.
+    //
+    // NOTE: For simpler call sites (CLI commands, Memory handler) use the shared
+    // helper `ironclaw::db::connect_from_config()`. This block is kept inline
+    // because it also captures backend-specific handles (`pg_pool`, `libsql_db`)
+    // needed by the secrets store.
+    #[cfg(feature = "postgres")]
+    let mut pg_pool: Option = None;
+    #[cfg(feature = "libsql")]
+    let mut libsql_db: Option> = None;
+
+    let db: Option> = if cli.no_db {
         tracing::warn!("Running without database connection");
         None
     } else {
-        let store = Store::new(&config.database).await?;
-        store.run_migrations().await?;
-        tracing::info!("Database connected and migrations applied");
+        match config.database.backend {
+            #[cfg(feature = "libsql")]
+            ironclaw::config::DatabaseBackend::LibSql => {
+                use ironclaw::db::Database as _;
+                use ironclaw::db::libsql_backend::LibSqlBackend;
+                use secrecy::ExposeSecret as _;
 
+                let default_path = ironclaw::config::default_libsql_path();
+                let db_path = config
+                    .database
+                    .libsql_path
+                    .as_deref()
+                    .unwrap_or(&default_path);
+
+                let backend = if let Some(ref url) = config.database.libsql_url {
+                    let token = config.database.libsql_auth_token.as_ref().ok_or_else(|| {
+                        anyhow::anyhow!("LIBSQL_AUTH_TOKEN is required when LIBSQL_URL is set")
+                    })?;
+                    LibSqlBackend::new_remote_replica(db_path, url, token.expose_secret()).await?
+                } else {
+                    LibSqlBackend::new_local(db_path).await?
+                };
+                backend.run_migrations().await?;
+                tracing::info!("libSQL database connected and migrations applied");
+
+                // Capture the Database handle for SecretsStore (connection-per-op)
+                libsql_db = Some(backend.shared_db());
+
+                Some(Arc::new(backend) as Arc)
+            }
+            #[cfg(feature = "postgres")]
+            _ => {
+                use ironclaw::db::Database as _;
+                let pg = ironclaw::db::postgres::PgBackend::new(&config.database)
+                    .await
+                    .map_err(|e| anyhow::anyhow!("{}", e))?;
+                pg.run_migrations()
+                    .await
+                    .map_err(|e| anyhow::anyhow!("{}", e))?;
+                tracing::info!("PostgreSQL database connected and migrations applied");
+
+                pg_pool = Some(pg.pool());
+                Some(Arc::new(pg) as Arc)
+            }
+            #[cfg(not(feature = "postgres"))]
+            _ => {
+                anyhow::bail!(
+                    "No database backend available. Enable 'postgres' or 'libsql' feature."
+                );
+            }
+        }
+    };
+
+    // Post-init operations using the database
+    if let Some(ref db) = db {
         // One-time migration: move disk config files into the DB settings table.
-        if let Err(e) = ironclaw::bootstrap::migrate_disk_to_db(&store, "default").await {
+        if let Err(e) = ironclaw::bootstrap::migrate_disk_to_db(db.as_ref(), "default").await {
             tracing::warn!("Disk-to-DB settings migration failed: {}", e);
         }
 
         // Reload config from DB now that we have a connection.
-        // Priority: env var > DB setting > default.
-        match Config::from_db(&store, "default", &bootstrap).await {
+        match Config::from_db(db.as_ref(), "default", &bootstrap).await {
             Ok(db_config) => {
                 config = db_config;
                 tracing::info!("Configuration reloaded from database");
@@ -355,19 +435,14 @@ async fn main() -> anyhow::Result<()> {
             }
         }
 
-        let store = Arc::new(store);
-
-        // Attach store to session manager so tokens save to DB too
-        session.attach_store(Arc::clone(&store), "default").await;
+        // Attach DB to session manager so tokens save to DB too
+        session.attach_store(Arc::clone(db), "default").await;
 
         // Mark any jobs left in "running" or "creating" state as "interrupted".
-        if let Err(e) = store.cleanup_stale_sandbox_jobs().await {
+        if let Err(e) = db.cleanup_stale_sandbox_jobs().await {
             tracing::warn!("Failed to cleanup stale sandbox jobs: {}", e);
         }
-
-        Some(store)
-    };
-
+    }
     // Initialize LLM provider (clone session so we can reuse it for embeddings)
     let llm = create_llm_provider(&config.llm, session.clone())?;
     tracing::info!("LLM provider initialized: {}", llm.model_name());
@@ -421,8 +496,8 @@ async fn main() -> anyhow::Result<()> {
     };
 
     // Register memory tools if database is available
-    if let Some(ref store) = store {
-        let mut workspace = Workspace::new("default", store.pool());
+    if let Some(ref db) = db {
+        let mut workspace = Workspace::new_with_db("default", Arc::clone(db));
         if let Some(ref emb) = embeddings {
             workspace = workspace.with_embeddings(emb.clone());
         }
@@ -445,20 +520,46 @@ async fn main() -> anyhow::Result<()> {
         tracing::info!("Builder mode enabled");
     }
 
-    // Create secrets store if master key is configured (needed for MCP auth and WASM channels)
+    // Create secrets store if master key is configured (needed for MCP auth and WASM channels).
+    //
+    // When both `postgres` and `libsql` features are compiled, the runtime-selected
+    // backend determines which store is created: whichever DB init branch ran will
+    // have set its handle (pg_pool or libsql_db), and the or_else chain picks it up.
     let secrets_store: Option> =
-        if let (Some(store), Some(master_key)) = (&store, config.secrets.master_key()) {
+        if let Some(master_key) = config.secrets.master_key() {
             match SecretsCrypto::new(master_key.clone()) {
-                Ok(crypto) => Some(Arc::new(PostgresSecretsStore::new(
-                    store.pool(),
-                    Arc::new(crypto),
-                ))),
+                Ok(crypto) => {
+                    let crypto = Arc::new(crypto);
+                    let store: Option> = None;
+
+                    #[cfg(feature = "libsql")]
+                    let store = store.or_else(|| {
+                        libsql_db.take().map(|db| {
+                            Arc::new(LibSqlSecretsStore::new(db, Arc::clone(&crypto)))
+                                as Arc
+                        })
+                    });
+
+                    #[cfg(feature = "postgres")]
+                    let store = store.or_else(|| {
+                        pg_pool.as_ref().map(|pool| {
+                            Arc::new(PostgresSecretsStore::new(pool.clone(), Arc::clone(&crypto)))
+                                as Arc
+                        })
+                    });
+
+                    store
+                }
                 Err(e) => {
                     tracing::warn!("Failed to initialize secrets crypto: {}", e);
+                    #[cfg(feature = "libsql")]
+                    let _ = libsql_db.take();
                     None
                 }
             }
         } else {
+            #[cfg(feature = "libsql")]
+            let _ = libsql_db.take();
             None
         };
 
@@ -522,8 +623,8 @@ async fn main() -> anyhow::Result<()> {
 
     let mcp_servers_future = async {
         if let Some(ref secrets) = secrets_store {
-            let servers_result = if let Some(ref s) = store {
-                load_mcp_servers_from_db(s, "default").await
+            let servers_result = if let Some(ref d) = db {
+                load_mcp_servers_from_db(d.as_ref(), "default").await
             } else {
                 ironclaw::tools::mcp::config::load_mcp_servers().await
             };
@@ -636,7 +737,7 @@ async fn main() -> anyhow::Result<()> {
             config.channels.wasm_channels_dir.clone(),
             config.tunnel.public_url.clone(),
             "default".to_string(),
-            store.clone(),
+            db.clone(),
         ));
         tools.register_extension_tools(Arc::clone(&manager));
         tracing::info!("Extension manager initialized with in-chat discovery tools");
@@ -699,7 +800,7 @@ async fn main() -> anyhow::Result<()> {
             token_store,
             job_event_tx: job_event_tx.clone(),
             prompt_queue: Arc::clone(&prompt_queue),
-            store: store.clone(),
+            store: db.clone(),
         };
 
         tokio::spawn(async move {
@@ -936,13 +1037,15 @@ async fn main() -> anyhow::Result<()> {
     };
 
     // Create workspace for agent (shared with memory tools)
-    let workspace = store.as_ref().map(|s| {
-        let mut ws = Workspace::new("default", s.pool());
+    let workspace = if let Some(ref db_ref) = db {
+        let mut ws = Workspace::new_with_db("default", Arc::clone(db_ref));
         if let Some(ref emb) = embeddings {
             ws = ws.with_embeddings(emb.clone());
         }
-        Arc::new(ws)
-    });
+        Some(Arc::new(ws))
+    } else {
+        None
+    };
 
     // Seed workspace with core identity files on first boot
     if let Some(ref ws) = workspace {
@@ -980,7 +1083,7 @@ async fn main() -> anyhow::Result<()> {
     tools.register_job_tools(
         Arc::clone(&context_manager),
         container_job_manager.clone(),
-        store.clone(),
+        db.clone(),
     );
 
     // Add web gateway channel if configured
@@ -995,13 +1098,12 @@ async fn main() -> anyhow::Result<()> {
         if let Some(ref ext_mgr) = extension_manager {
             gw = gw.with_extension_manager(Arc::clone(ext_mgr));
         }
-        if let Some(ref s) = store {
-            gw = gw.with_store(Arc::clone(s));
+        if let Some(ref d) = db {
+            gw = gw.with_store(Arc::clone(d));
         }
         if let Some(ref jm) = container_job_manager {
             gw = gw.with_job_manager(Arc::clone(jm));
         }
-        gw = gw.with_llm_provider(Arc::clone(&llm));
         if config.sandbox.enabled {
             gw = gw.with_prompt_queue(Arc::clone(&prompt_queue));
 
@@ -1034,7 +1136,7 @@ async fn main() -> anyhow::Result<()> {
 
     // Create and run the agent
     let deps = AgentDeps {
-        store,
+        store: db,
         llm,
         safety,
         tools,
@@ -1068,11 +1170,17 @@ async fn main() -> anyhow::Result<()> {
 /// Check if onboarding is needed and return the reason.
 ///
 /// Returns `Some(reason)` if onboarding should be triggered, `None` otherwise.
+#[cfg(any(feature = "postgres", feature = "libsql"))]
 async fn check_onboard_needed() -> Option<&'static str> {
     let bootstrap = ironclaw::bootstrap::BootstrapConfig::load();
 
     // Database not configured (and not in env)
-    if bootstrap.database_url.is_none() && std::env::var("DATABASE_URL").is_err() {
+    let has_db = bootstrap.database_url.is_some()
+        || std::env::var("DATABASE_URL").is_ok()
+        || std::env::var("LIBSQL_PATH").is_ok()
+        || ironclaw::config::default_libsql_path().exists();
+
+    if !has_db {
         return Some("Database not configured");
     }
 
diff --git a/src/orchestrator/api.rs b/src/orchestrator/api.rs
index 8923bc55..012ec270 100644
--- a/src/orchestrator/api.rs
+++ b/src/orchestrator/api.rs
@@ -15,7 +15,7 @@ use tokio::sync::{Mutex, broadcast};
 use uuid::Uuid;
 
 use crate::channels::web::types::SseEvent;
-use crate::history::Store;
+use crate::db::Database;
 use crate::llm::{CompletionRequest, LlmProvider, ToolCompletionRequest};
 use crate::orchestrator::auth::{TokenStore, worker_auth_middleware};
 use crate::orchestrator::job_manager::ContainerJobManager;
@@ -43,7 +43,7 @@ pub struct OrchestratorState {
     /// Buffered follow-up prompts for sandbox jobs, keyed by job_id.
     pub prompt_queue: Arc>>>,
     /// Database handle for persisting job events.
-    pub store: Option>,
+    pub store: Option>,
 }
 
 /// The orchestrator's internal API server.
diff --git a/src/secrets/mod.rs b/src/secrets/mod.rs
index d2f8696d..8547fbfc 100644
--- a/src/secrets/mod.rs
+++ b/src/secrets/mod.rs
@@ -64,7 +64,11 @@ mod store;
 mod types;
 
 pub use crypto::SecretsCrypto;
-pub use store::{PostgresSecretsStore, SecretsStore};
+#[cfg(feature = "libsql")]
+pub use store::LibSqlSecretsStore;
+#[cfg(feature = "postgres")]
+pub use store::PostgresSecretsStore;
+pub use store::SecretsStore;
 pub use types::{
     CreateSecretParams, CredentialLocation, CredentialMapping, DecryptedSecret, Secret,
     SecretError, SecretRef,
diff --git a/src/secrets/store.rs b/src/secrets/store.rs
index 442bbcb9..cf532a01 100644
--- a/src/secrets/store.rs
+++ b/src/secrets/store.rs
@@ -10,6 +10,7 @@ use std::sync::Arc;
 
 use async_trait::async_trait;
 use chrono::Utc;
+#[cfg(feature = "postgres")]
 use deadpool_postgres::Pool;
 use secrecy::ExposeSecret;
 use uuid::Uuid;
@@ -61,11 +62,13 @@ pub trait SecretsStore: Send + Sync {
 }
 
 /// PostgreSQL implementation of SecretsStore.
+#[cfg(feature = "postgres")]
 pub struct PostgresSecretsStore {
     pool: Pool,
     crypto: Arc,
 }
 
+#[cfg(feature = "postgres")]
 impl PostgresSecretsStore {
     /// Create a new store with the given database pool and crypto instance.
     pub fn new(pool: Pool, crypto: Arc) -> Self {
@@ -73,6 +76,7 @@ impl PostgresSecretsStore {
     }
 }
 
+#[cfg(feature = "postgres")]
 #[async_trait]
 impl SecretsStore for PostgresSecretsStore {
     async fn create(
@@ -283,6 +287,7 @@ impl SecretsStore for PostgresSecretsStore {
     }
 }
 
+#[cfg(feature = "postgres")]
 fn row_to_secret(row: &tokio_postgres::Row) -> Secret {
     Secret {
         id: row.get("id"),
@@ -299,6 +304,332 @@ fn row_to_secret(row: &tokio_postgres::Row) -> Secret {
     }
 }
 
+// ==================== libSQL implementation ====================
+
+/// libSQL/Turso implementation of SecretsStore.
+///
+/// Holds an `Arc` handle and creates a fresh connection per operation,
+/// matching the connection-per-request pattern used by the main `LibSqlBackend`.
+#[cfg(feature = "libsql")]
+pub struct LibSqlSecretsStore {
+    db: Arc,
+    crypto: Arc,
+}
+
+#[cfg(feature = "libsql")]
+impl LibSqlSecretsStore {
+    /// Create a new store with the given shared libsql database handle and crypto instance.
+    pub fn new(db: Arc, crypto: Arc) -> Self {
+        Self { db, crypto }
+    }
+
+    fn connect(&self) -> Result {
+        self.db
+            .connect()
+            .map_err(|e| SecretError::Database(format!("Connection failed: {}", e)))
+    }
+}
+
+#[cfg(feature = "libsql")]
+#[async_trait]
+impl SecretsStore for LibSqlSecretsStore {
+    async fn create(
+        &self,
+        user_id: &str,
+        params: CreateSecretParams,
+    ) -> Result {
+        let plaintext = params.value.expose_secret().as_bytes();
+        let (encrypted_value, key_salt) = self.crypto.encrypt(plaintext)?;
+
+        let id = Uuid::new_v4();
+        let now = Utc::now();
+        let now_str = now.to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
+        let expires_at_str = params
+            .expires_at
+            .map(|dt| dt.to_rfc3339_opts(chrono::SecondsFormat::Millis, true));
+
+        // Start transaction for atomic upsert + read-back
+        let conn = self.connect()?;
+        let tx = conn
+            .transaction()
+            .await
+            .map_err(|e| SecretError::Database(e.to_string()))?;
+
+        tx.execute(
+                r#"
+                INSERT INTO secrets (id, user_id, name, encrypted_value, key_salt, provider, expires_at, created_at, updated_at)
+                VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?8)
+                ON CONFLICT (user_id, name) DO UPDATE SET
+                    encrypted_value = excluded.encrypted_value,
+                    key_salt = excluded.key_salt,
+                    provider = excluded.provider,
+                    expires_at = excluded.expires_at,
+                    updated_at = ?8
+                "#,
+                libsql::params![
+                    id.to_string(),
+                    user_id,
+                    params.name.as_str(),
+                    libsql::Value::Blob(encrypted_value.clone()),
+                    libsql::Value::Blob(key_salt.clone()),
+                    libsql_opt_text(params.provider.as_deref()),
+                    libsql_opt_text(expires_at_str.as_deref()),
+                    now_str.as_str(),
+                ],
+            )
+            .await
+            .map_err(|e| SecretError::Database(e.to_string()))?;
+
+        // Read back the row (may have been upserted)
+        let mut rows = tx
+            .query(
+                r#"
+                SELECT id, user_id, name, encrypted_value, key_salt, provider, expires_at,
+                       last_used_at, usage_count, created_at, updated_at
+                FROM secrets
+                WHERE user_id = ?1 AND name = ?2
+                "#,
+                libsql::params![user_id, params.name.as_str()],
+            )
+            .await
+            .map_err(|e| SecretError::Database(e.to_string()))?;
+
+        let row = rows
+            .next()
+            .await
+            .map_err(|e| SecretError::Database(e.to_string()))?
+            .ok_or_else(|| SecretError::Database("Insert succeeded but row not found".into()))?;
+
+        let secret = libsql_row_to_secret(&row)?;
+
+        tx.commit()
+            .await
+            .map_err(|e| SecretError::Database(e.to_string()))?;
+
+        Ok(secret)
+    }
+
+    async fn get(&self, user_id: &str, name: &str) -> Result {
+        let conn = self.connect()?;
+        let mut rows = conn
+            .query(
+                r#"
+                SELECT id, user_id, name, encrypted_value, key_salt, provider, expires_at,
+                       last_used_at, usage_count, created_at, updated_at
+                FROM secrets
+                WHERE user_id = ?1 AND name = ?2
+                "#,
+                libsql::params![user_id, name],
+            )
+            .await
+            .map_err(|e| SecretError::Database(e.to_string()))?;
+
+        match rows
+            .next()
+            .await
+            .map_err(|e| SecretError::Database(e.to_string()))?
+        {
+            Some(row) => {
+                let secret = libsql_row_to_secret(&row)?;
+
+                if let Some(expires_at) = secret.expires_at
+                    && expires_at < Utc::now()
+                {
+                    return Err(SecretError::Expired);
+                }
+
+                Ok(secret)
+            }
+            None => Err(SecretError::NotFound(name.to_string())),
+        }
+    }
+
+    async fn get_decrypted(
+        &self,
+        user_id: &str,
+        name: &str,
+    ) -> Result {
+        let secret = self.get(user_id, name).await?;
+        self.crypto
+            .decrypt(&secret.encrypted_value, &secret.key_salt)
+    }
+
+    async fn exists(&self, user_id: &str, name: &str) -> Result {
+        let conn = self.connect()?;
+        let mut rows = conn
+            .query(
+                "SELECT 1 FROM secrets WHERE user_id = ?1 AND name = ?2",
+                libsql::params![user_id, name],
+            )
+            .await
+            .map_err(|e| SecretError::Database(e.to_string()))?;
+
+        Ok(rows
+            .next()
+            .await
+            .map_err(|e| SecretError::Database(e.to_string()))?
+            .is_some())
+    }
+
+    async fn list(&self, user_id: &str) -> Result, SecretError> {
+        let conn = self.connect()?;
+        let mut rows = conn
+            .query(
+                "SELECT name, provider FROM secrets WHERE user_id = ?1 ORDER BY name",
+                libsql::params![user_id],
+            )
+            .await
+            .map_err(|e| SecretError::Database(e.to_string()))?;
+
+        let mut refs = Vec::new();
+        while let Some(row) = rows
+            .next()
+            .await
+            .map_err(|e| SecretError::Database(e.to_string()))?
+        {
+            refs.push(SecretRef {
+                name: row.get::(0).unwrap_or_default(),
+                provider: row.get::(1).ok(),
+            });
+        }
+        Ok(refs)
+    }
+
+    async fn delete(&self, user_id: &str, name: &str) -> Result {
+        let conn = self.connect()?;
+        let affected = conn
+            .execute(
+                "DELETE FROM secrets WHERE user_id = ?1 AND name = ?2",
+                libsql::params![user_id, name],
+            )
+            .await
+            .map_err(|e| SecretError::Database(e.to_string()))?;
+
+        Ok(affected > 0)
+    }
+
+    async fn record_usage(&self, secret_id: Uuid) -> Result<(), SecretError> {
+        let now = Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
+        let conn = self.connect()?;
+
+        conn.execute(
+            r#"
+                UPDATE secrets
+                SET last_used_at = ?1, usage_count = usage_count + 1
+                WHERE id = ?2
+                "#,
+            libsql::params![now.as_str(), secret_id.to_string()],
+        )
+        .await
+        .map_err(|e| SecretError::Database(e.to_string()))?;
+
+        Ok(())
+    }
+
+    async fn is_accessible(
+        &self,
+        user_id: &str,
+        secret_name: &str,
+        allowed_secrets: &[String],
+    ) -> Result {
+        if !self.exists(user_id, secret_name).await? {
+            return Ok(false);
+        }
+
+        for pattern in allowed_secrets {
+            if pattern == secret_name {
+                return Ok(true);
+            }
+
+            if let Some(prefix) = pattern.strip_suffix('*')
+                && secret_name.starts_with(prefix)
+            {
+                return Ok(true);
+            }
+        }
+
+        Ok(false)
+    }
+}
+
+#[cfg(feature = "libsql")]
+fn libsql_opt_text(s: Option<&str>) -> libsql::Value {
+    match s {
+        Some(s) => libsql::Value::Text(s.to_string()),
+        None => libsql::Value::Null,
+    }
+}
+
+#[cfg(feature = "libsql")]
+fn libsql_parse_timestamp(s: &str) -> Result, SecretError> {
+    if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(s) {
+        return Ok(dt.with_timezone(&Utc));
+    }
+    if let Ok(ndt) = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S%.f") {
+        return Ok(ndt.and_utc());
+    }
+    if let Ok(ndt) = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") {
+        return Ok(ndt.and_utc());
+    }
+    Err(SecretError::Database(format!(
+        "unparseable timestamp: {:?}",
+        s
+    )))
+}
+
+#[cfg(feature = "libsql")]
+fn libsql_row_to_secret(row: &libsql::Row) -> Result {
+    let id_str: String = row
+        .get(0)
+        .map_err(|e| SecretError::Database(e.to_string()))?;
+    let user_id: String = row
+        .get(1)
+        .map_err(|e| SecretError::Database(e.to_string()))?;
+    let name: String = row
+        .get(2)
+        .map_err(|e| SecretError::Database(e.to_string()))?;
+    let encrypted_value: Vec = row
+        .get(3)
+        .map_err(|e| SecretError::Database(e.to_string()))?;
+    let key_salt: Vec = row
+        .get(4)
+        .map_err(|e| SecretError::Database(e.to_string()))?;
+    let provider: Option = row.get::(5).ok().filter(|s| !s.is_empty());
+    let expires_at = row
+        .get::(6)
+        .ok()
+        .filter(|s| !s.is_empty())
+        .and_then(|s| libsql_parse_timestamp(&s).ok());
+    let last_used_at = row
+        .get::(7)
+        .ok()
+        .filter(|s| !s.is_empty())
+        .and_then(|s| libsql_parse_timestamp(&s).ok());
+    let usage_count: i64 = row.get::(8).unwrap_or(0);
+    let created_at_str: String = row
+        .get(9)
+        .map_err(|e| SecretError::Database(e.to_string()))?;
+    let updated_at_str: String = row
+        .get(10)
+        .map_err(|e| SecretError::Database(e.to_string()))?;
+
+    Ok(Secret {
+        id: id_str
+            .parse()
+            .map_err(|e: uuid::Error| SecretError::Database(e.to_string()))?,
+        user_id,
+        name,
+        encrypted_value,
+        key_salt,
+        provider,
+        expires_at,
+        last_used_at,
+        usage_count,
+        created_at: libsql_parse_timestamp(&created_at_str)?,
+        updated_at: libsql_parse_timestamp(&updated_at_str)?,
+    })
+}
+
 /// In-memory implementation for testing.
 #[cfg(test)]
 pub mod testing {
diff --git a/src/settings.rs b/src/settings.rs
index 59165c55..08cc6b6a 100644
--- a/src/settings.rs
+++ b/src/settings.rs
@@ -15,6 +15,10 @@ pub struct Settings {
     pub onboard_completed: bool,
 
     // === Step 1: Database ===
+    /// Database backend: "postgres" or "libsql".
+    #[serde(default)]
+    pub database_backend: Option,
+
     /// Database connection URL (postgres://...).
     #[serde(default)]
     pub database_url: Option,
@@ -23,6 +27,14 @@ pub struct Settings {
     #[serde(default)]
     pub database_pool_size: Option,
 
+    /// Path to local libSQL database file.
+    #[serde(default)]
+    pub libsql_path: Option,
+
+    /// Turso cloud URL for remote replica sync.
+    #[serde(default)]
+    pub libsql_url: Option,
+
     // === Step 2: Security ===
     /// Source for the secrets master key.
     #[serde(default)]
diff --git a/src/setup/channels.rs b/src/setup/channels.rs
index 3de5cb23..77fcc38d 100644
--- a/src/setup/channels.rs
+++ b/src/setup/channels.rs
@@ -12,7 +12,9 @@ use reqwest::Client;
 use secrecy::{ExposeSecret, SecretString};
 use serde::Deserialize;
 
-use crate::secrets::{CreateSecretParams, PostgresSecretsStore, SecretsCrypto, SecretsStore};
+#[cfg(feature = "postgres")]
+use crate::secrets::SecretsCrypto;
+use crate::secrets::{CreateSecretParams, SecretsStore};
 use crate::settings::Settings;
 use crate::setup::prompts::{
     confirm, input, optional_input, print_error, print_info, print_success, secret_input,
@@ -20,15 +22,24 @@ use crate::setup::prompts::{
 
 /// Context for saving secrets during setup.
 pub struct SecretsContext {
-    store: PostgresSecretsStore,
+    store: Arc,
     user_id: String,
 }
 
 impl SecretsContext {
-    /// Create a new secrets context.
+    /// Create a new secrets context from a trait-object store.
+    pub fn from_store(store: Arc, user_id: &str) -> Self {
+        Self {
+            store,
+            user_id: user_id.to_string(),
+        }
+    }
+
+    /// Create a new secrets context from a PostgreSQL pool and crypto.
+    #[cfg(feature = "postgres")]
     pub fn new(pool: deadpool_postgres::Pool, crypto: Arc, user_id: &str) -> Self {
         Self {
-            store: PostgresSecretsStore::new(pool, crypto),
+            store: Arc::new(crate::secrets::PostgresSecretsStore::new(pool, crypto)),
             user_id: user_id.to_string(),
         }
     }
diff --git a/src/setup/mod.rs b/src/setup/mod.rs
index 71bda14a..ca4d4c56 100644
--- a/src/setup/mod.rs
+++ b/src/setup/mod.rs
@@ -20,6 +20,7 @@
 
 mod channels;
 mod prompts;
+#[cfg(any(feature = "postgres", feature = "libsql"))]
 mod wizard;
 
 pub use channels::{
@@ -29,4 +30,5 @@ pub use prompts::{
     confirm, input, optional_input, print_error, print_header, print_info, print_step,
     print_success, secret_input, select_many, select_one,
 };
+#[cfg(any(feature = "postgres", feature = "libsql"))]
 pub use wizard::{SetupConfig, SetupWizard};
diff --git a/src/setup/wizard.rs b/src/setup/wizard.rs
index 632d4272..696a7a08 100644
--- a/src/setup/wizard.rs
+++ b/src/setup/wizard.rs
@@ -12,15 +12,17 @@
 use std::collections::{HashMap, HashSet};
 use std::sync::Arc;
 
+#[cfg(feature = "postgres")]
 use deadpool_postgres::{Config as PoolConfig, Runtime};
 use secrecy::SecretString;
+#[cfg(feature = "postgres")]
 use tokio_postgres::NoTls;
 
 use crate::channels::wasm::{
     ChannelCapabilitiesFile, available_channel_names, install_bundled_channel,
 };
 use crate::llm::{SessionConfig, SessionManager};
-use crate::secrets::SecretsCrypto;
+use crate::secrets::{SecretsCrypto, SecretsStore};
 use crate::settings::{KeySource, Settings};
 use crate::setup::channels::{
     SecretsContext, setup_http, setup_telegram, setup_tunnel, setup_wasm_channel,
@@ -66,8 +68,12 @@ pub struct SetupWizard {
     config: SetupConfig,
     settings: Settings,
     session_manager: Option>,
-    /// Database pool (created during setup).
+    /// Database pool (created during setup, postgres only).
+    #[cfg(feature = "postgres")]
     db_pool: Option,
+    /// libSQL backend (created during setup, libsql only).
+    #[cfg(feature = "libsql")]
+    db_backend: Option,
     /// Secrets crypto (created during setup).
     secrets_crypto: Option>,
 }
@@ -79,7 +85,10 @@ impl SetupWizard {
             config: SetupConfig::default(),
             settings: Settings::load(),
             session_manager: None,
+            #[cfg(feature = "postgres")]
             db_pool: None,
+            #[cfg(feature = "libsql")]
+            db_backend: None,
             secrets_crypto: None,
         }
     }
@@ -90,7 +99,10 @@ impl SetupWizard {
             config,
             settings: Settings::load(),
             session_manager: None,
+            #[cfg(feature = "postgres")]
             db_pool: None,
+            #[cfg(feature = "libsql")]
+            db_backend: None,
             secrets_crypto: None,
         }
     }
@@ -153,19 +165,48 @@ impl SetupWizard {
 
     /// Step 1: Database connection.
     async fn step_database(&mut self) -> Result<(), SetupError> {
-        // Check if we have an existing URL in env or settings
+        // Determine which backend to use based on compile-time features.
+        // When both features are enabled, prefer the currently configured backend
+        // or default to postgres.
+        #[cfg(all(feature = "postgres", feature = "libsql"))]
+        {
+            let backend = std::env::var("DATABASE_BACKEND")
+                .ok()
+                .or_else(|| self.settings.database_backend.clone())
+                .unwrap_or_else(|| "postgres".to_string());
+
+            if backend == "libsql" || backend == "turso" || backend == "sqlite" {
+                return self.step_database_libsql().await;
+            }
+            return self.step_database_postgres().await;
+        }
+
+        #[cfg(all(feature = "postgres", not(feature = "libsql")))]
+        {
+            return self.step_database_postgres().await;
+        }
+
+        #[cfg(all(feature = "libsql", not(feature = "postgres")))]
+        {
+            return self.step_database_libsql().await;
+        }
+    }
+
+    /// Step 1 (postgres): Database connection via PostgreSQL URL.
+    #[cfg(feature = "postgres")]
+    async fn step_database_postgres(&mut self) -> Result<(), SetupError> {
+        self.settings.database_backend = Some("postgres".to_string());
+
         let existing_url = std::env::var("DATABASE_URL")
             .ok()
             .or_else(|| self.settings.database_url.clone());
 
         if let Some(ref url) = existing_url {
-            // Mask the password for display
             let display_url = mask_password_in_url(url);
             print_info(&format!("Existing database URL: {}", display_url));
 
             if confirm("Use this database?", true).map_err(SetupError::Io)? {
-                // Test the connection
-                if let Err(e) = self.test_database_connection(url).await {
+                if let Err(e) = self.test_database_connection_postgres(url).await {
                     print_error(&format!("Connection failed: {}", e));
                     print_info("Let's configure a new database URL.");
                 } else {
@@ -176,7 +217,6 @@ impl SetupWizard {
             }
         }
 
-        // Prompt for new URL
         println!();
         print_info("Enter your PostgreSQL connection URL.");
         print_info("Format: postgres://user:password@host:port/database");
@@ -190,15 +230,13 @@ impl SetupWizard {
                 continue;
             }
 
-            // Test the connection
             print_info("Testing connection...");
-            match self.test_database_connection(&url).await {
+            match self.test_database_connection_postgres(&url).await {
                 Ok(()) => {
                     print_success("Database connection successful");
 
-                    // Ask if we should run migrations
                     if confirm("Run database migrations?", true).map_err(SetupError::Io)? {
-                        self.run_migrations().await?;
+                        self.run_migrations_postgres().await?;
                     }
 
                     self.settings.database_url = Some(url);
@@ -216,8 +254,115 @@ impl SetupWizard {
         }
     }
 
-    /// Test database connection and store the pool.
-    async fn test_database_connection(&mut self, url: &str) -> Result<(), SetupError> {
+    /// Step 1 (libsql): Database connection via local file or Turso remote replica.
+    #[cfg(feature = "libsql")]
+    async fn step_database_libsql(&mut self) -> Result<(), SetupError> {
+        self.settings.database_backend = Some("libsql".to_string());
+
+        let default_path = crate::config::default_libsql_path();
+        let default_path_str = default_path.to_string_lossy().to_string();
+
+        // Check for existing configuration
+        let existing_path = std::env::var("LIBSQL_PATH")
+            .ok()
+            .or_else(|| self.settings.libsql_path.clone());
+
+        if let Some(ref path) = existing_path {
+            print_info(&format!("Existing database path: {}", path));
+            if confirm("Use this database?", true).map_err(SetupError::Io)? {
+                let turso_url = std::env::var("LIBSQL_URL")
+                    .ok()
+                    .or_else(|| self.settings.libsql_url.clone());
+                let turso_token = std::env::var("LIBSQL_AUTH_TOKEN").ok();
+
+                match self
+                    .test_database_connection_libsql(
+                        path,
+                        turso_url.as_deref(),
+                        turso_token.as_deref(),
+                    )
+                    .await
+                {
+                    Ok(()) => {
+                        print_success("Database connection successful");
+                        self.settings.libsql_path = Some(path.clone());
+                        if let Some(url) = turso_url {
+                            self.settings.libsql_url = Some(url);
+                        }
+                        return Ok(());
+                    }
+                    Err(e) => {
+                        print_error(&format!("Connection failed: {}", e));
+                        print_info("Let's configure a new database path.");
+                    }
+                }
+            }
+        }
+
+        println!();
+        print_info("IronClaw uses an embedded SQLite database (libSQL).");
+        print_info("No external database server required.");
+        println!();
+
+        let path_input = optional_input(
+            "Database file path",
+            Some(&format!("default: {}", default_path_str)),
+        )
+        .map_err(SetupError::Io)?;
+
+        let db_path = path_input.unwrap_or(default_path_str.clone());
+
+        // Ask about Turso cloud sync
+        println!();
+        let use_turso =
+            confirm("Enable Turso cloud sync (remote replica)?", false).map_err(SetupError::Io)?;
+
+        let (turso_url, turso_token) = if use_turso {
+            print_info("Enter your Turso database URL and auth token.");
+            print_info("Format: libsql://your-db.turso.io");
+            println!();
+
+            let url = input("Turso URL").map_err(SetupError::Io)?;
+            if url.is_empty() {
+                print_error("Turso URL is required for cloud sync.");
+                (None, None)
+            } else {
+                let token = input("Auth token").map_err(SetupError::Io)?;
+                if token.is_empty() {
+                    print_error("Auth token is required for cloud sync.");
+                    (None, None)
+                } else {
+                    (Some(url), Some(token))
+                }
+            }
+        } else {
+            (None, None)
+        };
+
+        print_info("Testing connection...");
+        match self
+            .test_database_connection_libsql(&db_path, turso_url.as_deref(), turso_token.as_deref())
+            .await
+        {
+            Ok(()) => {
+                print_success("Database connection successful");
+
+                // Always run migrations for libsql (they're idempotent)
+                self.run_migrations_libsql().await?;
+
+                self.settings.libsql_path = Some(db_path);
+                if let Some(url) = turso_url {
+                    self.settings.libsql_url = Some(url);
+                }
+                Ok(())
+            }
+            Err(e) => Err(SetupError::Database(format!("Connection failed: {}", e))),
+        }
+    }
+
+    /// Test PostgreSQL connection and store the pool.
+    #[cfg(feature = "postgres")]
+    async fn test_database_connection_postgres(&mut self, url: &str) -> Result<(), SetupError> {
         let mut cfg = PoolConfig::new();
         cfg.url = Some(url.to_string());
         cfg.pool = Some(deadpool_postgres::PoolConfig {
@@ -229,7 +374,6 @@ impl SetupWizard {
             .create_pool(Some(Runtime::Tokio1), NoTls)
             .map_err(|e| SetupError::Database(format!("Failed to create pool: {}", e)))?;
 
-        // Test the connection
         let _ = pool
             .get()
             .await
@@ -239,8 +383,36 @@ impl SetupWizard {
         Ok(())
     }
 
-    /// Run database migrations.
-    async fn run_migrations(&self) -> Result<(), SetupError> {
+    /// Test libSQL connection and store the backend.
+    #[cfg(feature = "libsql")]
+    async fn test_database_connection_libsql(
+        &mut self,
+        path: &str,
+        turso_url: Option<&str>,
+        turso_token: Option<&str>,
+    ) -> Result<(), SetupError> {
+        use crate::db::libsql_backend::LibSqlBackend;
+        use std::path::Path;
+
+        let db_path = Path::new(path);
+
+        let backend = if let (Some(url), Some(token)) = (turso_url, turso_token) {
+            LibSqlBackend::new_remote_replica(db_path, url, token)
+                .await
+                .map_err(|e| SetupError::Database(format!("Failed to connect: {}", e)))?
+        } else {
+            LibSqlBackend::new_local(db_path)
+                .await
+                .map_err(|e| SetupError::Database(format!("Failed to open database: {}", e)))?
+        };
+
+        self.db_backend = Some(backend);
+        Ok(())
+    }
+
+    /// Run PostgreSQL migrations.
+    #[cfg(feature = "postgres")]
+    async fn run_migrations_postgres(&self) -> Result<(), SetupError> {
         if let Some(ref pool) = self.db_pool {
             use refinery::embed_migrations;
             embed_migrations!("migrations");
@@ -262,6 +434,24 @@ impl SetupWizard {
         Ok(())
     }
 
+    /// Run libSQL migrations.
+    #[cfg(feature = "libsql")]
+    async fn run_migrations_libsql(&self) -> Result<(), SetupError> {
+        if let Some(ref backend) = self.db_backend {
+            use crate::db::Database;
+
+            print_info("Running migrations...");
+
+            backend
+                .run_migrations()
+                .await
+                .map_err(|e| SetupError::Database(format!("Migration failed: {}", e)))?;
+
+            print_success("Migrations applied");
+        }
+        Ok(())
+    }
+
     /// Step 2: Security (secrets master key).
     async fn step_security(&mut self) -> Result<(), SetupError> {
         // Check current configuration
@@ -532,24 +722,6 @@ impl SetupWizard {
 
     /// Initialize secrets context for channel setup.
     async fn init_secrets_context(&mut self) -> Result {
-        // Get database pool (should be set from step 1)
-        let pool = if let Some(ref p) = self.db_pool {
-            p.clone()
-        } else {
-            // Fall back to creating one from settings/env
-            let url = self
-                .settings
-                .database_url
-                .clone()
-                .or_else(|| std::env::var("DATABASE_URL").ok())
-                .ok_or_else(|| SetupError::Config("Database URL not configured".to_string()))?;
-
-            self.test_database_connection(&url).await?;
-            // Ensure secrets-related tables exist for channels-only onboarding flows.
-            self.run_migrations().await?;
-            self.db_pool.clone().unwrap()
-        };
-
         // Get crypto (should be set from step 2, or load from keychain/env)
         let crypto = if let Some(ref c) = self.secrets_crypto {
             Arc::clone(c)
@@ -571,7 +743,74 @@ impl SetupWizard {
             Arc::clone(self.secrets_crypto.as_ref().unwrap())
         };
 
-        Ok(SecretsContext::new(pool, crypto, "default"))
+        // Create backend-appropriate secrets store
+        #[cfg(feature = "postgres")]
+        {
+            // Try postgres path first when postgres feature is available
+            if let Some(store) = self.create_postgres_secrets_store(&crypto).await? {
+                return Ok(SecretsContext::from_store(store, "default"));
+            }
+        }
+
+        #[cfg(feature = "libsql")]
+        {
+            if let Some(store) = self.create_libsql_secrets_store(&crypto)? {
+                return Ok(SecretsContext::from_store(store, "default"));
+            }
+        }
+
+        Err(SetupError::Config(
+            "No database backend available for secrets storage".to_string(),
+        ))
+    }
+
+    /// Create a PostgreSQL secrets store from the current pool.
+    #[cfg(feature = "postgres")]
+    async fn create_postgres_secrets_store(
+        &mut self,
+        crypto: &Arc,
+    ) -> Result>, SetupError> {
+        let pool = if let Some(ref p) = self.db_pool {
+            p.clone()
+        } else {
+            // Fall back to creating one from settings/env
+            let url = self
+                .settings
+                .database_url
+                .clone()
+                .or_else(|| std::env::var("DATABASE_URL").ok());
+
+            if let Some(url) = url {
+                self.test_database_connection_postgres(&url).await?;
+                self.run_migrations_postgres().await?;
+                self.db_pool.clone().unwrap()
+            } else {
+                return Ok(None);
+            }
+        };
+
+        let store: Arc = Arc::new(crate::secrets::PostgresSecretsStore::new(
+            pool,
+            Arc::clone(crypto),
+        ));
+        Ok(Some(store))
+    }
+
+    /// Create a libSQL secrets store from the current backend.
+    #[cfg(feature = "libsql")]
+    fn create_libsql_secrets_store(
+        &self,
+        crypto: &Arc,
+    ) -> Result>, SetupError> {
+        if let Some(ref backend) = self.db_backend {
+            let store: Arc = Arc::new(crate::secrets::LibSqlSecretsStore::new(
+                backend.shared_db(),
+                Arc::clone(crypto),
+            ));
+            Ok(Some(store))
+        } else {
+            Ok(None)
+        }
     }
 
     /// Step 6: Channel configuration.
@@ -793,8 +1032,27 @@ impl SetupWizard {
         println!("Configuration Summary:");
         println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
 
-        if self.settings.database_url.is_some() {
-            println!("  Database: configured");
+        let backend = self
+            .settings
+            .database_backend
+            .as_deref()
+            .unwrap_or("postgres");
+        match backend {
+            "libsql" => {
+                if let Some(ref path) = self.settings.libsql_path {
+                    println!("  Database: libSQL ({})", path);
+                } else {
+                    println!("  Database: libSQL (default path)");
+                }
+                if self.settings.libsql_url.is_some() {
+                    println!("  Turso sync: enabled");
+                }
+            }
+            _ => {
+                if self.settings.database_url.is_some() {
+                    println!("  Database: PostgreSQL (configured)");
+                }
+            }
         }
 
         match self.settings.secrets_master_key_source {
@@ -874,6 +1132,7 @@ impl Default for SetupWizard {
 }
 
 /// Mask password in a database URL for display.
+#[cfg(feature = "postgres")]
 fn mask_password_in_url(url: &str) -> String {
     // URL format: scheme://user:password@host/database
     // Find "://" to locate start of credentials
@@ -1064,6 +1323,7 @@ mod tests {
     }
 
     #[test]
+    #[cfg(feature = "postgres")]
     fn test_mask_password_in_url() {
         assert_eq!(
             mask_password_in_url("postgres://user:secret@localhost/db"),
diff --git a/src/tools/builtin/job.rs b/src/tools/builtin/job.rs
index 86552c34..015c3b13 100644
--- a/src/tools/builtin/job.rs
+++ b/src/tools/builtin/job.rs
@@ -15,7 +15,8 @@ use chrono::Utc;
 use uuid::Uuid;
 
 use crate::context::{ContextManager, JobContext, JobState};
-use crate::history::{SandboxJobRecord, Store};
+use crate::db::Database;
+use crate::history::SandboxJobRecord;
 use crate::orchestrator::job_manager::{ContainerJobManager, JobMode};
 use crate::tools::tool::{Tool, ToolError, ToolOutput};
 
@@ -27,7 +28,7 @@ use crate::tools::tool::{Tool, ToolError, ToolOutput};
 pub struct CreateJobTool {
     context_manager: Arc,
     job_manager: Option>,
-    store: Option>,
+    store: Option>,
 }
 
 impl CreateJobTool {
@@ -43,7 +44,7 @@ impl CreateJobTool {
     pub fn with_sandbox(
         mut self,
         job_manager: Arc,
-        store: Option>,
+        store: Option>,
     ) -> Self {
         self.job_manager = Some(job_manager);
         self.store = store;
diff --git a/src/tools/builtin/memory.rs b/src/tools/builtin/memory.rs
index a64e5863..8a2bbbdb 100644
--- a/src/tools/builtin/memory.rs
+++ b/src/tools/builtin/memory.rs
@@ -482,7 +482,7 @@ impl Tool for MemoryTreeTool {
     }
 }
 
-#[cfg(test)]
+#[cfg(all(test, feature = "postgres"))]
 mod tests {
     use super::*;
 
diff --git a/src/tools/builtin/routine.rs b/src/tools/builtin/routine.rs
index decf7357..338601bc 100644
--- a/src/tools/builtin/routine.rs
+++ b/src/tools/builtin/routine.rs
@@ -19,18 +19,18 @@ use crate::agent::routine::{
 };
 use crate::agent::routine_engine::RoutineEngine;
 use crate::context::JobContext;
-use crate::history::Store;
+use crate::db::Database;
 use crate::tools::tool::{Tool, ToolError, ToolOutput};
 
 // ==================== routine_create ====================
 
 pub struct RoutineCreateTool {
-    store: Arc,
+    store: Arc,
     engine: Arc,
 }
 
 impl RoutineCreateTool {
-    pub fn new(store: Arc, engine: Arc) -> Self {
+    pub fn new(store: Arc, engine: Arc) -> Self {
         Self { store, engine }
     }
 }
@@ -277,11 +277,11 @@ impl Tool for RoutineCreateTool {
 // ==================== routine_list ====================
 
 pub struct RoutineListTool {
-    store: Arc,
+    store: Arc,
 }
 
 impl RoutineListTool {
-    pub fn new(store: Arc) -> Self {
+    pub fn new(store: Arc) -> Self {
         Self { store }
     }
 }
@@ -351,12 +351,12 @@ impl Tool for RoutineListTool {
 // ==================== routine_update ====================
 
 pub struct RoutineUpdateTool {
-    store: Arc,
+    store: Arc,
     engine: Arc,
 }
 
 impl RoutineUpdateTool {
-    pub fn new(store: Arc, engine: Arc) -> Self {
+    pub fn new(store: Arc, engine: Arc) -> Self {
         Self { store, engine }
     }
 }
@@ -474,12 +474,12 @@ impl Tool for RoutineUpdateTool {
 // ==================== routine_delete ====================
 
 pub struct RoutineDeleteTool {
-    store: Arc,
+    store: Arc,
     engine: Arc,
 }
 
 impl RoutineDeleteTool {
-    pub fn new(store: Arc, engine: Arc) -> Self {
+    pub fn new(store: Arc, engine: Arc) -> Self {
         Self { store, engine }
     }
 }
@@ -551,11 +551,11 @@ impl Tool for RoutineDeleteTool {
 // ==================== routine_history ====================
 
 pub struct RoutineHistoryTool {
-    store: Arc,
+    store: Arc,
 }
 
 impl RoutineHistoryTool {
-    pub fn new(store: Arc) -> Self {
+    pub fn new(store: Arc) -> Self {
         Self { store }
     }
 }
diff --git a/src/tools/mcp/config.rs b/src/tools/mcp/config.rs
index f7041934..6f57d5c4 100644
--- a/src/tools/mcp/config.rs
+++ b/src/tools/mcp/config.rs
@@ -333,7 +333,7 @@ pub async fn get_mcp_server(name: &str) -> Result
 ///
 /// Falls back to the disk file if DB has no entry.
 pub async fn load_mcp_servers_from_db(
-    store: &crate::history::Store,
+    store: &dyn crate::db::Database,
     user_id: &str,
 ) -> Result {
     match store.get_setting(user_id, "mcp_servers").await {
@@ -357,7 +357,7 @@ pub async fn load_mcp_servers_from_db(
 
 /// Save MCP server configurations to the database settings table.
 pub async fn save_mcp_servers_to_db(
-    store: &crate::history::Store,
+    store: &dyn crate::db::Database,
     user_id: &str,
     config: &McpServersFile,
 ) -> Result<(), ConfigError> {
@@ -371,7 +371,7 @@ pub async fn save_mcp_servers_to_db(
 
 /// Add a new MCP server configuration (DB-backed).
 pub async fn add_mcp_server_db(
-    store: &crate::history::Store,
+    store: &dyn crate::db::Database,
     user_id: &str,
     config: McpServerConfig,
 ) -> Result<(), ConfigError> {
@@ -386,7 +386,7 @@ pub async fn add_mcp_server_db(
 
 /// Remove an MCP server by name (DB-backed).
 pub async fn remove_mcp_server_db(
-    store: &crate::history::Store,
+    store: &dyn crate::db::Database,
     user_id: &str,
     name: &str,
 ) -> Result<(), ConfigError> {
diff --git a/src/tools/registry.rs b/src/tools/registry.rs
index 906e048d..ae28ce5b 100644
--- a/src/tools/registry.rs
+++ b/src/tools/registry.rs
@@ -6,8 +6,8 @@ use std::sync::Arc;
 use tokio::sync::RwLock;
 
 use crate::context::ContextManager;
+use crate::db::Database;
 use crate::extensions::ExtensionManager;
-use crate::history::Store;
 use crate::llm::{LlmProvider, ToolDefinition};
 use crate::orchestrator::job_manager::ContainerJobManager;
 use crate::safety::SafetyLayer;
@@ -243,7 +243,7 @@ impl ToolRegistry {
         &self,
         context_manager: Arc,
         job_manager: Option>,
-        store: Option>,
+        store: Option>,
     ) {
         let mut create_tool = CreateJobTool::new(Arc::clone(&context_manager));
         if let Some(jm) = job_manager {
@@ -276,7 +276,7 @@ impl ToolRegistry {
     /// of routines (scheduled and event-driven tasks).
     pub fn register_routine_tools(
         &self,
-        store: Arc,
+        store: Arc,
         engine: Arc,
     ) {
         use crate::tools::builtin::{
diff --git a/src/tools/wasm/mod.rs b/src/tools/wasm/mod.rs
index fea18e44..c80c511e 100644
--- a/src/tools/wasm/mod.rs
+++ b/src/tools/wasm/mod.rs
@@ -108,10 +108,13 @@ pub use credential_injector::{CredentialInjector, InjectedCredentials, Injection
 pub use rate_limiter::{LimitType, RateLimitError, RateLimitResult, RateLimiter};
 
 // Storage (V2)
+#[cfg(feature = "libsql")]
+pub use storage::LibSqlWasmToolStore;
+#[cfg(feature = "postgres")]
+pub use storage::PostgresWasmToolStore;
 pub use storage::{
-    PostgresWasmToolStore, StoreToolParams, StoredCapabilities, StoredWasmTool,
-    StoredWasmToolWithBinary, ToolStatus, TrustLevel, WasmStorageError, WasmToolStore,
-    compute_binary_hash, verify_binary_integrity,
+    StoreToolParams, StoredCapabilities, StoredWasmTool, StoredWasmToolWithBinary, ToolStatus,
+    TrustLevel, WasmStorageError, WasmToolStore, compute_binary_hash, verify_binary_integrity,
 };
 
 // Loader
diff --git a/src/tools/wasm/storage.rs b/src/tools/wasm/storage.rs
index d29a8afb..40d9857f 100644
--- a/src/tools/wasm/storage.rs
+++ b/src/tools/wasm/storage.rs
@@ -16,6 +16,7 @@ use std::collections::HashMap;
 
 use async_trait::async_trait;
 use chrono::{DateTime, Utc};
+#[cfg(feature = "postgres")]
 use deadpool_postgres::Pool;
 use uuid::Uuid;
 
@@ -263,16 +264,19 @@ pub fn verify_binary_integrity(binary: &[u8], expected_hash: &[u8]) -> bool {
 }
 
 /// PostgreSQL implementation of WasmToolStore.
+#[cfg(feature = "postgres")]
 pub struct PostgresWasmToolStore {
     pool: Pool,
 }
 
+#[cfg(feature = "postgres")]
 impl PostgresWasmToolStore {
     pub fn new(pool: Pool) -> Self {
         Self { pool }
     }
 }
 
+#[cfg(feature = "postgres")]
 #[async_trait]
 impl WasmToolStore for PostgresWasmToolStore {
     async fn store(&self, params: StoreToolParams) -> Result {
@@ -538,6 +542,7 @@ impl WasmToolStore for PostgresWasmToolStore {
     }
 }
 
+#[cfg(feature = "postgres")]
 fn row_to_tool(row: &tokio_postgres::Row) -> Result {
     let trust_level_str: String = row.get("trust_level");
     let status_str: String = row.get("status");
@@ -559,6 +564,459 @@ fn row_to_tool(row: &tokio_postgres::Row) -> Result` handle and creates a fresh connection per operation,
+/// matching the connection-per-request pattern used by the main `LibSqlBackend`.
+#[cfg(feature = "libsql")]
+pub struct LibSqlWasmToolStore {
+    db: std::sync::Arc,
+}
+
+#[cfg(feature = "libsql")]
+impl LibSqlWasmToolStore {
+    pub fn new(db: std::sync::Arc) -> Self {
+        Self { db }
+    }
+
+    fn connect(&self) -> Result {
+        self.db
+            .connect()
+            .map_err(|e| WasmStorageError::Database(format!("Connection failed: {}", e)))
+    }
+}
+
+#[cfg(feature = "libsql")]
+#[async_trait]
+impl WasmToolStore for LibSqlWasmToolStore {
+    async fn store(&self, params: StoreToolParams) -> Result {
+        let binary_hash = compute_binary_hash(¶ms.wasm_binary);
+        let id = Uuid::new_v4();
+        let now = Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
+        let schema_str = serde_json::to_string(¶ms.parameters_schema)
+            .map_err(|e| WasmStorageError::InvalidData(e.to_string()))?;
+
+        // Wrap INSERT + read-back in a transaction to prevent TOCTOU races
+        let conn = self.connect()?;
+        let tx = conn
+            .transaction()
+            .await
+            .map_err(|e| WasmStorageError::Database(e.to_string()))?;
+
+        tx.execute(
+            r#"
+                INSERT INTO wasm_tools (
+                    id, user_id, name, version, description, wasm_binary, binary_hash,
+                    parameters_schema, source_url, trust_level, status, created_at, updated_at
+                )
+                VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, 'active', ?11, ?11)
+                ON CONFLICT (user_id, name, version) DO UPDATE SET
+                    description = excluded.description,
+                    wasm_binary = excluded.wasm_binary,
+                    binary_hash = excluded.binary_hash,
+                    parameters_schema = excluded.parameters_schema,
+                    source_url = excluded.source_url,
+                    updated_at = ?11
+                "#,
+            libsql::params![
+                id.to_string(),
+                params.user_id.as_str(),
+                params.name.as_str(),
+                params.version.as_str(),
+                params.description.as_str(),
+                libsql::Value::Blob(params.wasm_binary),
+                libsql::Value::Blob(binary_hash),
+                schema_str.as_str(),
+                libsql_wasm_opt_text(params.source_url.as_deref()),
+                params.trust_level.to_string(),
+                now.as_str(),
+            ],
+        )
+        .await
+        .map_err(|e| WasmStorageError::Database(e.to_string()))?;
+
+        // Read back the row within the same transaction
+        let mut rows = tx
+            .query(
+                r#"
+                SELECT id, user_id, name, version, description, parameters_schema,
+                       source_url, trust_level, status, created_at, updated_at
+                FROM wasm_tools
+                WHERE user_id = ?1 AND name = ?2
+                ORDER BY version DESC
+                LIMIT 1
+                "#,
+                libsql::params![params.user_id.as_str(), params.name.as_str()],
+            )
+            .await
+            .map_err(|e| WasmStorageError::Database(e.to_string()))?;
+
+        let row = rows
+            .next()
+            .await
+            .map_err(|e| WasmStorageError::Database(e.to_string()))?
+            .ok_or_else(|| {
+                WasmStorageError::Database("Insert succeeded but row not found".into())
+            })?;
+
+        let tool = libsql_row_to_tool(&row)?;
+
+        tx.commit()
+            .await
+            .map_err(|e| WasmStorageError::Database(e.to_string()))?;
+
+        Ok(tool)
+    }
+
+    async fn get(&self, user_id: &str, name: &str) -> Result {
+        let conn = self.connect()?;
+        let mut rows = conn
+            .query(
+                r#"
+                SELECT id, user_id, name, version, description, parameters_schema,
+                       source_url, trust_level, status, created_at, updated_at
+                FROM wasm_tools
+                WHERE user_id = ?1 AND name = ?2 AND status = 'active'
+                ORDER BY version DESC
+                LIMIT 1
+                "#,
+                libsql::params![user_id, name],
+            )
+            .await
+            .map_err(|e| WasmStorageError::Database(e.to_string()))?;
+
+        match rows
+            .next()
+            .await
+            .map_err(|e| WasmStorageError::Database(e.to_string()))?
+        {
+            Some(row) => {
+                let tool = libsql_row_to_tool(&row)?;
+                match tool.status {
+                    ToolStatus::Active => Ok(tool),
+                    ToolStatus::Disabled => Err(WasmStorageError::Disabled),
+                    ToolStatus::Quarantined => Err(WasmStorageError::Quarantined),
+                }
+            }
+            None => Err(WasmStorageError::NotFound(name.to_string())),
+        }
+    }
+
+    async fn get_with_binary(
+        &self,
+        user_id: &str,
+        name: &str,
+    ) -> Result {
+        let conn = self.connect()?;
+        let mut rows = conn
+            .query(
+                r#"
+                SELECT id, user_id, name, version, description, wasm_binary, binary_hash,
+                       parameters_schema, source_url, trust_level, status, created_at, updated_at
+                FROM wasm_tools
+                WHERE user_id = ?1 AND name = ?2 AND status = 'active'
+                ORDER BY version DESC
+                LIMIT 1
+                "#,
+                libsql::params![user_id, name],
+            )
+            .await
+            .map_err(|e| WasmStorageError::Database(e.to_string()))?;
+
+        match rows
+            .next()
+            .await
+            .map_err(|e| WasmStorageError::Database(e.to_string()))?
+        {
+            Some(row) => {
+                let wasm_binary: Vec = row
+                    .get(5)
+                    .map_err(|e| WasmStorageError::Database(e.to_string()))?;
+                let binary_hash: Vec = row
+                    .get(6)
+                    .map_err(|e| WasmStorageError::Database(e.to_string()))?;
+
+                if !verify_binary_integrity(&wasm_binary, &binary_hash) {
+                    tracing::error!(
+                        user_id = user_id,
+                        name = name,
+                        "WASM binary integrity check failed"
+                    );
+                    return Err(WasmStorageError::IntegrityCheckFailed);
+                }
+
+                // Parse metadata from the row (different column offsets due to binary/hash)
+                let tool = libsql_row_to_tool_with_offset(&row)?;
+
+                match tool.status {
+                    ToolStatus::Active => Ok(StoredWasmToolWithBinary {
+                        tool,
+                        wasm_binary,
+                        binary_hash,
+                    }),
+                    ToolStatus::Disabled => Err(WasmStorageError::Disabled),
+                    ToolStatus::Quarantined => Err(WasmStorageError::Quarantined),
+                }
+            }
+            None => Err(WasmStorageError::NotFound(name.to_string())),
+        }
+    }
+
+    async fn get_capabilities(
+        &self,
+        tool_id: Uuid,
+    ) -> Result, WasmStorageError> {
+        let conn = self.connect()?;
+        let mut rows = conn
+            .query(
+                r#"
+                SELECT id, wasm_tool_id, http_allowlist, allowed_secrets, tool_aliases,
+                       requests_per_minute, requests_per_hour, max_request_body_bytes,
+                       max_response_body_bytes, workspace_read_prefixes, http_timeout_secs
+                FROM tool_capabilities
+                WHERE wasm_tool_id = ?1
+                "#,
+                libsql::params![tool_id.to_string()],
+            )
+            .await
+            .map_err(|e| WasmStorageError::Database(e.to_string()))?;
+
+        match rows
+            .next()
+            .await
+            .map_err(|e| WasmStorageError::Database(e.to_string()))?
+        {
+            Some(row) => {
+                let id_str: String = row
+                    .get(0)
+                    .map_err(|e| WasmStorageError::Database(e.to_string()))?;
+                let tool_id_str: String = row
+                    .get(1)
+                    .map_err(|e| WasmStorageError::Database(e.to_string()))?;
+                let http_allowlist_str: String = row.get::(2).unwrap_or_default();
+                let allowed_secrets_str: String = row.get::(3).unwrap_or_default();
+                let tool_aliases_str: String = row.get::(4).unwrap_or_default();
+                let rpm: i64 = row.get::(5).unwrap_or(60);
+                let rph: i64 = row.get::(6).unwrap_or(1000);
+                let max_req: i64 = row.get::(7).unwrap_or(1048576);
+                let max_resp: i64 = row.get::(8).unwrap_or(10485760);
+                let ws_prefixes_str: String = row.get::(9).unwrap_or_default();
+                let timeout: i64 = row.get::(10).unwrap_or(30);
+
+                let http_allowlist: Vec =
+                    serde_json::from_str(&http_allowlist_str).unwrap_or_default();
+                let allowed_secrets: Vec =
+                    serde_json::from_str(&allowed_secrets_str).unwrap_or_default();
+                let tool_aliases: HashMap =
+                    serde_json::from_str(&tool_aliases_str).unwrap_or_default();
+                let workspace_read_prefixes: Vec =
+                    serde_json::from_str(&ws_prefixes_str).unwrap_or_default();
+
+                Ok(Some(StoredCapabilities {
+                    id: id_str
+                        .parse()
+                        .map_err(|e: uuid::Error| WasmStorageError::InvalidData(e.to_string()))?,
+                    wasm_tool_id: tool_id_str
+                        .parse()
+                        .map_err(|e: uuid::Error| WasmStorageError::InvalidData(e.to_string()))?,
+                    http_allowlist,
+                    allowed_secrets,
+                    tool_aliases,
+                    requests_per_minute: rpm as u32,
+                    requests_per_hour: rph as u32,
+                    max_request_body_bytes: max_req,
+                    max_response_body_bytes: max_resp,
+                    workspace_read_prefixes,
+                    http_timeout_secs: timeout as i32,
+                }))
+            }
+            None => Ok(None),
+        }
+    }
+
+    async fn list(&self, user_id: &str) -> Result, WasmStorageError> {
+        // SQLite doesn't have DISTINCT ON, so we use a subquery to get latest version per name
+        let conn = self.connect()?;
+        let mut rows = conn
+            .query(
+                r#"
+                SELECT id, user_id, name, version, description, parameters_schema,
+                       source_url, trust_level, status, created_at, updated_at
+                FROM wasm_tools
+                WHERE user_id = ?1
+                  AND rowid IN (
+                      SELECT MAX(rowid)
+                      FROM wasm_tools
+                      WHERE user_id = ?1
+                      GROUP BY name
+                  )
+                ORDER BY name
+                "#,
+                libsql::params![user_id],
+            )
+            .await
+            .map_err(|e| WasmStorageError::Database(e.to_string()))?;
+
+        let mut tools = Vec::new();
+        while let Some(row) = rows
+            .next()
+            .await
+            .map_err(|e| WasmStorageError::Database(e.to_string()))?
+        {
+            tools.push(libsql_row_to_tool(&row)?);
+        }
+        Ok(tools)
+    }
+
+    async fn update_status(
+        &self,
+        user_id: &str,
+        name: &str,
+        status: ToolStatus,
+    ) -> Result<(), WasmStorageError> {
+        let now = Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
+        let conn = self.connect()?;
+
+        let result = conn
+            .execute(
+                "UPDATE wasm_tools SET status = ?1, updated_at = ?2 WHERE user_id = ?3 AND name = ?4",
+                libsql::params![status.to_string(), now.as_str(), user_id, name],
+            )
+            .await
+            .map_err(|e| WasmStorageError::Database(e.to_string()))?;
+
+        if result == 0 {
+            return Err(WasmStorageError::NotFound(name.to_string()));
+        }
+
+        Ok(())
+    }
+
+    async fn delete(&self, user_id: &str, name: &str) -> Result {
+        let conn = self.connect()?;
+        let result = conn
+            .execute(
+                "DELETE FROM wasm_tools WHERE user_id = ?1 AND name = ?2",
+                libsql::params![user_id, name],
+            )
+            .await
+            .map_err(|e| WasmStorageError::Database(e.to_string()))?;
+
+        Ok(result > 0)
+    }
+}
+
+#[cfg(feature = "libsql")]
+fn libsql_wasm_opt_text(s: Option<&str>) -> libsql::Value {
+    match s {
+        Some(s) => libsql::Value::Text(s.to_string()),
+        None => libsql::Value::Null,
+    }
+}
+
+#[cfg(feature = "libsql")]
+fn libsql_wasm_parse_ts(s: &str) -> Result, WasmStorageError> {
+    if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(s) {
+        return Ok(dt.with_timezone(&Utc));
+    }
+    if let Ok(ndt) = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S%.f") {
+        return Ok(ndt.and_utc());
+    }
+    if let Ok(ndt) = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") {
+        return Ok(ndt.and_utc());
+    }
+    Err(WasmStorageError::InvalidData(format!(
+        "unparseable timestamp: {:?}",
+        s
+    )))
+}
+
+/// Parse a tool row with standard column order (no binary columns).
+/// Columns: id(0), user_id(1), name(2), version(3), description(4),
+///          parameters_schema(5), source_url(6), trust_level(7), status(8),
+///          created_at(9), updated_at(10)
+#[cfg(feature = "libsql")]
+fn libsql_row_to_tool(row: &libsql::Row) -> Result {
+    libsql_row_to_tool_at(row, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
+}
+
+/// Parse a tool row when binary columns are present (get_with_binary query).
+/// Columns: id(0), user_id(1), name(2), version(3), description(4),
+///          wasm_binary(5), binary_hash(6),
+///          parameters_schema(7), source_url(8), trust_level(9), status(10),
+///          created_at(11), updated_at(12)
+#[cfg(feature = "libsql")]
+fn libsql_row_to_tool_with_offset(row: &libsql::Row) -> Result {
+    libsql_row_to_tool_at(row, 0, 1, 2, 3, 4, 7, 8, 9, 10, 11, 12)
+}
+
+#[cfg(feature = "libsql")]
+#[allow(clippy::too_many_arguments)]
+fn libsql_row_to_tool_at(
+    row: &libsql::Row,
+    id_idx: i32,
+    user_id_idx: i32,
+    name_idx: i32,
+    version_idx: i32,
+    description_idx: i32,
+    schema_idx: i32,
+    source_url_idx: i32,
+    trust_level_idx: i32,
+    status_idx: i32,
+    created_at_idx: i32,
+    updated_at_idx: i32,
+) -> Result {
+    let id_str: String = row
+        .get(id_idx)
+        .map_err(|e| WasmStorageError::Database(e.to_string()))?;
+    let trust_level_str: String = row
+        .get(trust_level_idx)
+        .map_err(|e| WasmStorageError::Database(e.to_string()))?;
+    let status_str: String = row
+        .get(status_idx)
+        .map_err(|e| WasmStorageError::Database(e.to_string()))?;
+    let schema_str: String = row
+        .get(schema_idx)
+        .map_err(|e| WasmStorageError::Database(e.to_string()))?;
+    let created_at_str: String = row
+        .get(created_at_idx)
+        .map_err(|e| WasmStorageError::Database(e.to_string()))?;
+    let updated_at_str: String = row
+        .get(updated_at_idx)
+        .map_err(|e| WasmStorageError::Database(e.to_string()))?;
+
+    Ok(StoredWasmTool {
+        id: id_str
+            .parse()
+            .map_err(|e: uuid::Error| WasmStorageError::InvalidData(e.to_string()))?,
+        user_id: row
+            .get(user_id_idx)
+            .map_err(|e| WasmStorageError::Database(e.to_string()))?,
+        name: row
+            .get(name_idx)
+            .map_err(|e| WasmStorageError::Database(e.to_string()))?,
+        version: row
+            .get(version_idx)
+            .map_err(|e| WasmStorageError::Database(e.to_string()))?,
+        description: row
+            .get(description_idx)
+            .map_err(|e| WasmStorageError::Database(e.to_string()))?,
+        parameters_schema: serde_json::from_str(&schema_str).unwrap_or_default(),
+        source_url: row
+            .get::(source_url_idx)
+            .ok()
+            .filter(|s| !s.is_empty()),
+        trust_level: trust_level_str
+            .parse()
+            .map_err(WasmStorageError::InvalidData)?,
+        status: status_str.parse().map_err(WasmStorageError::InvalidData)?,
+        created_at: libsql_wasm_parse_ts(&created_at_str)?,
+        updated_at: libsql_wasm_parse_ts(&updated_at_str)?,
+    })
+}
+
 #[cfg(test)]
 mod tests {
     use crate::tools::wasm::storage::{
diff --git a/src/workspace/mod.rs b/src/workspace/mod.rs
index d0f05d9c..a6afb070 100644
--- a/src/workspace/mod.rs
+++ b/src/workspace/mod.rs
@@ -43,23 +43,206 @@
 mod chunker;
 mod document;
 mod embeddings;
+#[cfg(feature = "postgres")]
 mod repository;
 mod search;
 
 pub use chunker::{ChunkConfig, chunk_document};
 pub use document::{MemoryChunk, MemoryDocument, WorkspaceEntry, paths};
 pub use embeddings::{EmbeddingProvider, MockEmbeddings, NearAiEmbeddings, OpenAiEmbeddings};
+#[cfg(feature = "postgres")]
 pub use repository::Repository;
-pub use search::{SearchConfig, SearchResult};
+pub use search::{RankedResult, SearchConfig, SearchResult, reciprocal_rank_fusion};
 
 use std::sync::Arc;
 
 use chrono::{NaiveDate, Utc};
+#[cfg(feature = "postgres")]
 use deadpool_postgres::Pool;
 use uuid::Uuid;
 
 use crate::error::WorkspaceError;
 
+/// Internal storage abstraction for Workspace.
+///
+/// Allows Workspace to work with either a PostgreSQL `Repository` (the original
+/// path) or any `Database` trait implementation (e.g. libSQL backend).
+enum WorkspaceStorage {
+    /// PostgreSQL-backed repository (uses connection pool directly).
+    #[cfg(feature = "postgres")]
+    Repo(Repository),
+    /// Generic backend implementing the Database trait.
+    Db(Arc),
+}
+
+impl WorkspaceStorage {
+    async fn get_document_by_path(
+        &self,
+        user_id: &str,
+        agent_id: Option,
+        path: &str,
+    ) -> Result {
+        match self {
+            #[cfg(feature = "postgres")]
+            Self::Repo(repo) => repo.get_document_by_path(user_id, agent_id, path).await,
+            Self::Db(db) => db.get_document_by_path(user_id, agent_id, path).await,
+        }
+    }
+
+    async fn get_document_by_id(&self, id: Uuid) -> Result {
+        match self {
+            #[cfg(feature = "postgres")]
+            Self::Repo(repo) => repo.get_document_by_id(id).await,
+            Self::Db(db) => db.get_document_by_id(id).await,
+        }
+    }
+
+    async fn get_or_create_document_by_path(
+        &self,
+        user_id: &str,
+        agent_id: Option,
+        path: &str,
+    ) -> Result {
+        match self {
+            #[cfg(feature = "postgres")]
+            Self::Repo(repo) => {
+                repo.get_or_create_document_by_path(user_id, agent_id, path)
+                    .await
+            }
+            Self::Db(db) => {
+                db.get_or_create_document_by_path(user_id, agent_id, path)
+                    .await
+            }
+        }
+    }
+
+    async fn update_document(&self, id: Uuid, content: &str) -> Result<(), WorkspaceError> {
+        match self {
+            #[cfg(feature = "postgres")]
+            Self::Repo(repo) => repo.update_document(id, content).await,
+            Self::Db(db) => db.update_document(id, content).await,
+        }
+    }
+
+    async fn delete_document_by_path(
+        &self,
+        user_id: &str,
+        agent_id: Option,
+        path: &str,
+    ) -> Result<(), WorkspaceError> {
+        match self {
+            #[cfg(feature = "postgres")]
+            Self::Repo(repo) => repo.delete_document_by_path(user_id, agent_id, path).await,
+            Self::Db(db) => db.delete_document_by_path(user_id, agent_id, path).await,
+        }
+    }
+
+    async fn list_directory(
+        &self,
+        user_id: &str,
+        agent_id: Option,
+        directory: &str,
+    ) -> Result, WorkspaceError> {
+        match self {
+            #[cfg(feature = "postgres")]
+            Self::Repo(repo) => repo.list_directory(user_id, agent_id, directory).await,
+            Self::Db(db) => db.list_directory(user_id, agent_id, directory).await,
+        }
+    }
+
+    async fn list_all_paths(
+        &self,
+        user_id: &str,
+        agent_id: Option,
+    ) -> Result, WorkspaceError> {
+        match self {
+            #[cfg(feature = "postgres")]
+            Self::Repo(repo) => repo.list_all_paths(user_id, agent_id).await,
+            Self::Db(db) => db.list_all_paths(user_id, agent_id).await,
+        }
+    }
+
+    async fn delete_chunks(&self, document_id: Uuid) -> Result<(), WorkspaceError> {
+        match self {
+            #[cfg(feature = "postgres")]
+            Self::Repo(repo) => repo.delete_chunks(document_id).await,
+            Self::Db(db) => db.delete_chunks(document_id).await,
+        }
+    }
+
+    async fn insert_chunk(
+        &self,
+        document_id: Uuid,
+        chunk_index: i32,
+        content: &str,
+        embedding: Option<&[f32]>,
+    ) -> Result {
+        match self {
+            #[cfg(feature = "postgres")]
+            Self::Repo(repo) => {
+                repo.insert_chunk(document_id, chunk_index, content, embedding)
+                    .await
+            }
+            Self::Db(db) => {
+                db.insert_chunk(document_id, chunk_index, content, embedding)
+                    .await
+            }
+        }
+    }
+
+    async fn update_chunk_embedding(
+        &self,
+        chunk_id: Uuid,
+        embedding: &[f32],
+    ) -> Result<(), WorkspaceError> {
+        match self {
+            #[cfg(feature = "postgres")]
+            Self::Repo(repo) => repo.update_chunk_embedding(chunk_id, embedding).await,
+            Self::Db(db) => db.update_chunk_embedding(chunk_id, embedding).await,
+        }
+    }
+
+    async fn get_chunks_without_embeddings(
+        &self,
+        user_id: &str,
+        agent_id: Option,
+        limit: usize,
+    ) -> Result, WorkspaceError> {
+        match self {
+            #[cfg(feature = "postgres")]
+            Self::Repo(repo) => {
+                repo.get_chunks_without_embeddings(user_id, agent_id, limit)
+                    .await
+            }
+            Self::Db(db) => {
+                db.get_chunks_without_embeddings(user_id, agent_id, limit)
+                    .await
+            }
+        }
+    }
+
+    async fn hybrid_search(
+        &self,
+        user_id: &str,
+        agent_id: Option,
+        query: &str,
+        embedding: Option<&[f32]>,
+        config: &SearchConfig,
+    ) -> Result, WorkspaceError> {
+        match self {
+            #[cfg(feature = "postgres")]
+            Self::Repo(repo) => {
+                repo.hybrid_search(user_id, agent_id, query, embedding, config)
+                    .await
+            }
+            Self::Db(db) => {
+                db.hybrid_search(user_id, agent_id, query, embedding, config)
+                    .await
+            }
+        }
+    }
+}
+
 /// Default template seeded into HEARTBEAT.md on first access.
 ///
 /// Intentionally comment-only so the heartbeat runner treats it as
@@ -80,25 +263,39 @@ const HEARTBEAT_SEED: &str = "\
 /// Workspace provides database-backed memory storage for an agent.
 ///
 /// Each workspace is scoped to a user (and optionally an agent).
-/// Documents are persisted to PostgreSQL and indexed for search.
+/// Documents are persisted to the database and indexed for search.
+/// Supports both PostgreSQL (via Repository) and libSQL (via Database trait).
 pub struct Workspace {
     /// User identifier (from channel).
     user_id: String,
     /// Optional agent ID for multi-agent isolation.
     agent_id: Option,
-    /// Database repository.
-    repo: Repository,
+    /// Database storage backend.
+    storage: WorkspaceStorage,
     /// Embedding provider for semantic search.
     embeddings: Option>,
 }
 
 impl Workspace {
-    /// Create a new workspace for a user.
+    /// Create a new workspace backed by a PostgreSQL connection pool.
+    #[cfg(feature = "postgres")]
     pub fn new(user_id: impl Into, pool: Pool) -> Self {
         Self {
             user_id: user_id.into(),
             agent_id: None,
-            repo: Repository::new(pool),
+            storage: WorkspaceStorage::Repo(Repository::new(pool)),
+            embeddings: None,
+        }
+    }
+
+    /// Create a new workspace backed by any Database implementation.
+    ///
+    /// Use this for libSQL or any other backend that implements the Database trait.
+    pub fn new_with_db(user_id: impl Into, db: Arc) -> Self {
+        Self {
+            user_id: user_id.into(),
+            agent_id: None,
+            storage: WorkspaceStorage::Db(db),
             embeddings: None,
         }
     }
@@ -138,7 +335,7 @@ impl Workspace {
     /// ```
     pub async fn read(&self, path: &str) -> Result {
         let path = normalize_path(path);
-        self.repo
+        self.storage
             .get_document_by_path(&self.user_id, self.agent_id, &path)
             .await
     }
@@ -155,14 +352,14 @@ impl Workspace {
     pub async fn write(&self, path: &str, content: &str) -> Result {
         let path = normalize_path(path);
         let doc = self
-            .repo
+            .storage
             .get_or_create_document_by_path(&self.user_id, self.agent_id, &path)
             .await?;
-        self.repo.update_document(doc.id, content).await?;
+        self.storage.update_document(doc.id, content).await?;
         self.reindex_document(doc.id).await?;
 
         // Return updated doc
-        self.repo.get_document_by_id(doc.id).await
+        self.storage.get_document_by_id(doc.id).await
     }
 
     /// Append content to a file.
@@ -172,7 +369,7 @@ impl Workspace {
     pub async fn append(&self, path: &str, content: &str) -> Result<(), WorkspaceError> {
         let path = normalize_path(path);
         let doc = self
-            .repo
+            .storage
             .get_or_create_document_by_path(&self.user_id, self.agent_id, &path)
             .await?;
 
@@ -182,7 +379,7 @@ impl Workspace {
             format!("{}\n{}", doc.content, content)
         };
 
-        self.repo.update_document(doc.id, &new_content).await?;
+        self.storage.update_document(doc.id, &new_content).await?;
         self.reindex_document(doc.id).await?;
         Ok(())
     }
@@ -191,7 +388,7 @@ impl Workspace {
     pub async fn exists(&self, path: &str) -> Result {
         let path = normalize_path(path);
         match self
-            .repo
+            .storage
             .get_document_by_path(&self.user_id, self.agent_id, &path)
             .await
         {
@@ -206,7 +403,7 @@ impl Workspace {
     /// Also deletes associated chunks.
     pub async fn delete(&self, path: &str) -> Result<(), WorkspaceError> {
         let path = normalize_path(path);
-        self.repo
+        self.storage
             .delete_document_by_path(&self.user_id, self.agent_id, &path)
             .await
     }
@@ -229,14 +426,16 @@ impl Workspace {
     /// ```
     pub async fn list(&self, directory: &str) -> Result, WorkspaceError> {
         let directory = normalize_directory(directory);
-        self.repo
+        self.storage
             .list_directory(&self.user_id, self.agent_id, &directory)
             .await
     }
 
     /// List all files recursively (flat list of all paths).
     pub async fn list_all(&self) -> Result, WorkspaceError> {
-        self.repo.list_all_paths(&self.user_id, self.agent_id).await
+        self.storage
+            .list_all_paths(&self.user_id, self.agent_id)
+            .await
     }
 
     // ==================== Convenience Methods ====================
@@ -280,7 +479,7 @@ impl Workspace {
 
     /// Helper to read or create a file.
     async fn read_or_create(&self, path: &str) -> Result {
-        self.repo
+        self.storage
             .get_or_create_document_by_path(&self.user_id, self.agent_id, path)
             .await
     }
@@ -299,7 +498,7 @@ impl Workspace {
         } else {
             format!("{}\n\n{}", doc.content, entry)
         };
-        self.repo.update_document(doc.id, &new_content).await?;
+        self.storage.update_document(doc.id, &new_content).await?;
         self.reindex_document(doc.id).await?;
         Ok(())
     }
@@ -395,7 +594,7 @@ impl Workspace {
             None
         };
 
-        self.repo
+        self.storage
             .hybrid_search(
                 &self.user_id,
                 self.agent_id,
@@ -411,13 +610,13 @@ impl Workspace {
     /// Re-index a document (chunk and generate embeddings).
     async fn reindex_document(&self, document_id: Uuid) -> Result<(), WorkspaceError> {
         // Get the document
-        let doc = self.repo.get_document_by_id(document_id).await?;
+        let doc = self.storage.get_document_by_id(document_id).await?;
 
         // Chunk the content
         let chunks = chunk_document(&doc.content, ChunkConfig::default());
 
         // Delete old chunks
-        self.repo.delete_chunks(document_id).await?;
+        self.storage.delete_chunks(document_id).await?;
 
         // Insert new chunks
         for (index, content) in chunks.into_iter().enumerate() {
@@ -434,7 +633,7 @@ impl Workspace {
                 None
             };
 
-            self.repo
+            self.storage
                 .insert_chunk(document_id, index as i32, &content, embedding.as_deref())
                 .await?;
         }
@@ -542,7 +741,7 @@ impl Workspace {
         };
 
         let chunks = self
-            .repo
+            .storage
             .get_chunks_without_embeddings(&self.user_id, self.agent_id, 100)
             .await?;
 
@@ -550,7 +749,7 @@ impl Workspace {
         for chunk in chunks {
             match provider.embed(&chunk.content).await {
                 Ok(embedding) => {
-                    self.repo
+                    self.storage
                         .update_chunk_embedding(chunk.id, &embedding)
                         .await?;
                     count += 1;
diff --git a/tests/workspace_integration.rs b/tests/workspace_integration.rs
index 11a4de76..dddd95e9 100644
--- a/tests/workspace_integration.rs
+++ b/tests/workspace_integration.rs
@@ -1,3 +1,4 @@
+#![cfg(feature = "postgres")]
 //! Integration tests for the workspace module.
 //!
 //! Requires a running PostgreSQL with pgvector extension.

From d9ff86d7e0595c528b00b490872ae3c32a8b9494 Mon Sep 17 00:00:00 2001
From: Zaki Manian 
Date: Fri, 13 Feb 2026 20:25:53 -0800
Subject: [PATCH 27/33] docs: Add review discipline guidelines to CLAUDE.md
 (#68)

* docs: Add review discipline guidelines to CLAUDE.md

Codifies lessons learned from Illia's review fixes on the libSQL
backend PR -- patterns we missed that should be caught systematically
going forward.

- Ban .expect() alongside .unwrap() in production code
- Add mechanical grep checks before committing
- New "Review & Fix Discipline" section covering:
  - Fix all instances of a pattern, not just the one flagged
  - Propagate architectural changes to satellite types
  - Schema translation must include indexes and seed data
  - Feature flag testing with each feature in isolation

Co-Authored-By: Claude Opus 4.6 

* Apply suggestions from code review

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

---------

Co-authored-by: Claude Opus 4.6 
Co-authored-by: Illia Polosukhin 
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
---
 CLAUDE.md | 34 +++++++++++++++++++++++++++++++++-
 1 file changed, 33 insertions(+), 1 deletion(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index aeaf1dd2..5664469b 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -198,8 +198,9 @@ When designing new features or systems, always prefer generic/extensible archite
 
 ### Error Handling
 - Use `thiserror` for error types in `error.rs`
-- Never use `.unwrap()` in production code (tests are fine)
+- Never use `.unwrap()` or `.expect()` in production code (tests are fine)
 - Map errors with context: `.map_err(|e| SomeError::Variant { reason: e.to_string() })?`
+- Before committing, grep for `.unwrap()` and `.expect(` in changed files to catch violations mechanically
 
 ### Async
 - All I/O is async with tokio
@@ -637,6 +638,37 @@ RUST_LOG=ironclaw=debug,tower_http=debug cargo run
 - Keep functions focused, extract helpers when logic is reused
 - Comments for non-obvious logic only
 
+## Review & Fix Discipline
+
+Hard-won lessons from code review -- follow these when fixing bugs or addressing review feedback.
+
+### Fix the pattern, not just the instance
+When a reviewer flags a bug (e.g., TOCTOU race in INSERT + SELECT-back), search the entire codebase for all instances of that same pattern. A fix in `SecretsStore::create()` that doesn't also fix `WasmToolStore::store()` is half a fix.
+
+### Propagate architectural fixes to satellite types
+If a core type changes its concurrency model (e.g., `LibSqlBackend` switches to connection-per-operation), every type that was handed a resource from the old model (e.g., `LibSqlSecretsStore`, `LibSqlWasmToolStore` holding a single `Connection`) must also be updated. Grep for the old type across the codebase.
+
+### Schema translation is more than DDL
+When translating a database schema between backends (PostgreSQL to libSQL, etc.), check for:
+- **Indexes** -- diff `CREATE INDEX` statements between the two schemas
+- **Seed data** -- check for `INSERT INTO` in migrations (e.g., `leak_detection_patterns`)
+- **Semantic differences** -- document where SQL functions behave differently (e.g., `json_patch` vs `jsonb_set`)
+
+### Feature flag testing
+When adding feature-gated code, test compilation with each feature in isolation:
+```bash
+cargo check                                          # default features
+cargo check --no-default-features --features libsql  # libsql only
+cargo check --all-features                           # all features
+```
+Dead code behind the wrong `#[cfg]` gate will only show up when building with a single feature.
+
+### Mechanical verification before committing
+Run these checks on changed files before committing:
+- `grep -rnE '\.unwrap\(|\.expect\(' ` -- no panics in production
+- `grep -rn 'super::' ` -- use `crate::` imports
+- If you fixed a pattern bug, `grep` for other instances of that pattern across `src/`
+
 ## Workspace & Memory System
 
 Inspired by [OpenClaw](https://github.com/openclaw/openclaw), the workspace provides persistent memory for agents with a flexible filesystem-like structure.

From 408ae8a29a8aa62d6708a74eba074e3502b0775f Mon Sep 17 00:00:00 2001
From: alexthebuildr <116134064+ztsalexey@users.noreply.github.com>
Date: Sat, 14 Feb 2026 04:43:38 -0700
Subject: [PATCH 28/33] feat: add multi-provider LLM failover with retry
 backoff (#28)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* feat: add multi-provider LLM failover

Add FailoverProvider that wraps multiple LlmProvider instances and
tries each in sequence on transient failures. Non-retryable errors
(auth, context length, model not available) propagate immediately.

- New `FailoverProvider` with generic `try_providers` helper
- `is_retryable()` classifies transient errors (request failed,
  rate limited, invalid response, session renewal, HTTP, IO)
- Configurable via `NEARAI_FALLBACK_MODEL` env var
- Returns `Result` from constructor (no panics in production)
- Updates FEATURE_PARITY.md: failover chains ✅, cooldown ❌

Co-Authored-By: Claude Opus 4.6 

* fix: track last-used provider for accurate cost/model reporting

After failover, model_name() and cost_per_token() now reflect the
provider that actually handled the request, not always the primary.
Also corrects is_retryable() docs to list ModelNotAvailable as retryable.

Addresses PR #28 review comments.

Co-Authored-By: Claude Opus 4.6 

* feat: add retry with exponential backoff for LLM providers

Add retry logic with exponential backoff and jitter to both NearAiProvider
and NearAiChatProvider for transient errors (HTTP 429, 500, 502, 503, 504).

Extract shared retry helpers (is_retryable_status, retry_backoff_delay)
into src/llm/retry.rs so both providers reuse the same logic.

Configurable via NEARAI_MAX_RETRIES env var (default: 3).

* docs: clarify max_retries means N retries, not N total attempts

* warn when fallback model equals primary model

* fix: saturating_mul in backoff delay, dedupe to_lowercase allocation

---------

Co-authored-by: Claude Opus 4.6 
---
 FEATURE_PARITY.md      |   6 +-
 src/config.rs          |  11 +
 src/llm/failover.rs    | 483 +++++++++++++++++++++++++++++++++++++++++
 src/llm/mod.rs         |  34 ++-
 src/llm/nearai.rs      | 162 +++++++++-----
 src/llm/nearai_chat.rs | 129 +++++++----
 src/llm/retry.rs       |  96 ++++++++
 src/main.rs            |  26 ++-
 src/setup/wizard.rs    |   2 +
 9 files changed, 840 insertions(+), 109 deletions(-)
 create mode 100644 src/llm/failover.rs
 create mode 100644 src/llm/retry.rs

diff --git a/FEATURE_PARITY.md b/FEATURE_PARITY.md
index b6265291..cda8dfd1 100644
--- a/FEATURE_PARITY.md
+++ b/FEATURE_PARITY.md
@@ -133,7 +133,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
 |---------|----------|----------|-------|
 | Pi agent runtime | ✅ | ➖ | IronClaw uses custom runtime |
 | RPC-based execution | ✅ | ✅ | Orchestrator/worker pattern |
-| Multi-provider failover | ✅ | ❌ | Provider fallback chains |
+| Multi-provider failover | ✅ | ✅ | `FailoverProvider` tries providers sequentially on retryable errors |
 | Per-sender sessions | ✅ | ✅ | |
 | Global sessions | ✅ | ❌ | Optional shared context |
 | Session pruning | ✅ | ❌ | Auto cleanup old sessions |
@@ -173,7 +173,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
 | Feature | OpenClaw | IronClaw | Notes |
 |---------|----------|----------|-------|
 | Auto-discovery | ✅ | ❌ | |
-| Failover chains | ✅ | ❌ | Provider fallback |
+| Failover chains | ✅ | ✅ | `FailoverProvider` with configurable `fallback_model` |
 | Cooldown management | ✅ | ❌ | Skip failed providers |
 | Per-session model override | ✅ | ✅ | Model selector in TUI |
 | Model selection UI | ✅ | ✅ | TUI keyboard shortcut |
@@ -419,7 +419,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
 - ❌ Slack channel (real implementation)
 - ✅ Telegram channel (WASM, DM pairing, caption, /start)
 - ❌ WhatsApp channel
-- ❌ Multi-provider failover
+- ✅ Multi-provider failover (`FailoverProvider` with retryable error classification)
 - ❌ Hooks system (beforeInbound, beforeToolCall, etc.)
 
 ### P2 - Medium Priority
diff --git a/src/config.rs b/src/config.rs
index a6ddb158..35c89604 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -397,6 +397,15 @@ pub struct NearAiConfig {
     pub api_mode: NearAiApiMode,
     /// API key for cloud-api (required for chat_completions mode)
     pub api_key: Option,
+    /// Optional fallback model for failover (default: None).
+    /// When set, a secondary provider is created with this model and wrapped
+    /// in a `FailoverProvider` so transient errors on the primary model
+    /// automatically fall through to the fallback.
+    pub fallback_model: Option,
+    /// Maximum number of retries for transient errors (default: 3).
+    /// With the default of 3, the provider makes up to 4 total attempts
+    /// (1 initial + 3 retries) before giving up.
+    pub max_retries: u32,
 }
 
 impl LlmConfig {
@@ -441,6 +450,8 @@ impl LlmConfig {
                 .unwrap_or_else(default_session_path),
             api_mode,
             api_key: nearai_api_key,
+            fallback_model: optional_env("NEARAI_FALLBACK_MODEL")?,
+            max_retries: parse_optional_env("NEARAI_MAX_RETRIES", 3)?,
         };
 
         // Resolve provider-specific configs based on backend
diff --git a/src/llm/failover.rs b/src/llm/failover.rs
new file mode 100644
index 00000000..6bc5c239
--- /dev/null
+++ b/src/llm/failover.rs
@@ -0,0 +1,483 @@
+//! Multi-provider LLM failover.
+//!
+//! Wraps multiple LlmProvider instances and tries each in sequence
+//! until one succeeds. Transparent to callers --- same LlmProvider trait.
+
+use std::future::Future;
+use std::sync::Arc;
+use std::sync::atomic::{AtomicUsize, Ordering};
+
+use async_trait::async_trait;
+use rust_decimal::Decimal;
+
+use crate::error::LlmError;
+use crate::llm::provider::{
+    CompletionRequest, CompletionResponse, LlmProvider, ToolCompletionRequest,
+    ToolCompletionResponse,
+};
+
+/// Returns `true` if the error is transient and the request should be retried
+/// on the next provider in the failover chain.
+///
+/// Retryable: `RequestFailed`, `RateLimited`, `InvalidResponse`,
+/// `SessionRenewalFailed`, `ModelNotAvailable`, `Http`, `Io`.
+///
+/// `ModelNotAvailable` is retryable because the next provider in the chain may
+/// offer a different model, so it's worth trying.
+///
+/// Non-retryable errors (`AuthFailed`, `SessionExpired`, `ContextLengthExceeded`)
+/// propagate immediately because a different provider won't fix them.
+fn is_retryable(err: &LlmError) -> bool {
+    matches!(
+        err,
+        LlmError::RequestFailed { .. }
+            | LlmError::RateLimited { .. }
+            | LlmError::InvalidResponse { .. }
+            | LlmError::SessionRenewalFailed { .. }
+            // ModelNotAvailable is retryable: the next provider may offer a different model.
+            | LlmError::ModelNotAvailable { .. }
+            | LlmError::Http(_)
+            | LlmError::Io(_)
+    )
+}
+
+/// An LLM provider that wraps multiple providers and tries each in sequence
+/// on transient failures.
+///
+/// The first provider in the list is the primary. If it fails with a retryable
+/// error, the next provider is tried, and so on. Non-retryable errors
+/// (e.g. `AuthFailed`, `ContextLengthExceeded`) propagate immediately.
+pub struct FailoverProvider {
+    providers: Vec>,
+    /// Index of the provider that last handled a request successfully.
+    /// Used by `model_name()` and `cost_per_token()` so downstream cost
+    /// tracking reflects the provider that actually served the request.
+    last_used: AtomicUsize,
+}
+
+impl FailoverProvider {
+    /// Create a new failover provider.
+    ///
+    /// Returns an error if `providers` is empty.
+    pub fn new(providers: Vec>) -> Result {
+        if providers.is_empty() {
+            return Err(LlmError::RequestFailed {
+                provider: "failover".to_string(),
+                reason: "FailoverProvider requires at least one provider".to_string(),
+            });
+        }
+        Ok(Self {
+            providers,
+            last_used: AtomicUsize::new(0),
+        })
+    }
+
+    /// Try each provider in sequence until one succeeds or all fail.
+    async fn try_providers(&self, mut call: F) -> Result
+    where
+        F: FnMut(Arc) -> Fut,
+        Fut: Future>,
+    {
+        let mut last_error: Option = None;
+
+        for (i, provider) in self.providers.iter().enumerate() {
+            let result = call(Arc::clone(provider)).await;
+            match result {
+                Ok(response) => {
+                    self.last_used.store(i, Ordering::Relaxed);
+                    return Ok(response);
+                }
+                Err(err) => {
+                    if !is_retryable(&err) {
+                        return Err(err);
+                    }
+                    if i + 1 < self.providers.len() {
+                        tracing::warn!(
+                            provider = %provider.model_name(),
+                            error = %err,
+                            next_provider = %self.providers[i + 1].model_name(),
+                            "Provider failed with retryable error, trying next provider"
+                        );
+                    }
+                    last_error = Some(err);
+                }
+            }
+        }
+
+        // SAFETY: providers is non-empty (checked in `new`), so at least one
+        // iteration ran and `last_error` is `Some`.
+        Err(last_error.expect("providers list is non-empty"))
+    }
+}
+
+#[async_trait]
+impl LlmProvider for FailoverProvider {
+    fn model_name(&self) -> &str {
+        self.providers[self.last_used.load(Ordering::Relaxed)].model_name()
+    }
+
+    fn cost_per_token(&self) -> (Decimal, Decimal) {
+        self.providers[self.last_used.load(Ordering::Relaxed)].cost_per_token()
+    }
+
+    async fn complete(&self, request: CompletionRequest) -> Result {
+        self.try_providers(|provider| {
+            let req = request.clone();
+            async move { provider.complete(req).await }
+        })
+        .await
+    }
+
+    async fn complete_with_tools(
+        &self,
+        request: ToolCompletionRequest,
+    ) -> Result {
+        self.try_providers(|provider| {
+            let req = request.clone();
+            async move { provider.complete_with_tools(req).await }
+        })
+        .await
+    }
+
+    async fn list_models(&self) -> Result, LlmError> {
+        let mut all_models = Vec::new();
+
+        for provider in &self.providers {
+            match provider.list_models().await {
+                Ok(models) => all_models.extend(models),
+                Err(err) => {
+                    tracing::warn!(
+                        provider = %provider.model_name(),
+                        error = %err,
+                        "Failed to list models from provider, skipping"
+                    );
+                }
+            }
+        }
+
+        all_models.sort();
+        all_models.dedup();
+        Ok(all_models)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    use std::sync::Mutex;
+    use std::time::Duration;
+
+    use crate::llm::provider::{CompletionResponse, FinishReason, ToolCompletionResponse};
+
+    /// A mock LLM provider that returns a predetermined result.
+    struct MockProvider {
+        name: String,
+        input_cost: Decimal,
+        output_cost: Decimal,
+        complete_result: Mutex>>,
+        tool_complete_result: Mutex>>,
+    }
+
+    impl MockProvider {
+        fn succeeding(name: &str, content: &str) -> Self {
+            Self {
+                name: name.to_string(),
+                input_cost: Decimal::ZERO,
+                output_cost: Decimal::ZERO,
+                complete_result: Mutex::new(Some(Ok(CompletionResponse {
+                    content: content.to_string(),
+                    input_tokens: 10,
+                    output_tokens: 5,
+                    finish_reason: FinishReason::Stop,
+                    response_id: None,
+                }))),
+                tool_complete_result: Mutex::new(Some(Ok(ToolCompletionResponse {
+                    content: Some(content.to_string()),
+                    tool_calls: vec![],
+                    input_tokens: 10,
+                    output_tokens: 5,
+                    finish_reason: FinishReason::Stop,
+                    response_id: None,
+                }))),
+            }
+        }
+
+        fn succeeding_with_cost(
+            name: &str,
+            content: &str,
+            input_cost: Decimal,
+            output_cost: Decimal,
+        ) -> Self {
+            Self {
+                input_cost,
+                output_cost,
+                ..Self::succeeding(name, content)
+            }
+        }
+
+        fn failing_retryable(name: &str) -> Self {
+            Self {
+                name: name.to_string(),
+                input_cost: Decimal::ZERO,
+                output_cost: Decimal::ZERO,
+                complete_result: Mutex::new(Some(Err(LlmError::RequestFailed {
+                    provider: name.to_string(),
+                    reason: "server error".to_string(),
+                }))),
+                tool_complete_result: Mutex::new(Some(Err(LlmError::RequestFailed {
+                    provider: name.to_string(),
+                    reason: "server error".to_string(),
+                }))),
+            }
+        }
+
+        fn failing_non_retryable(name: &str) -> Self {
+            Self {
+                name: name.to_string(),
+                input_cost: Decimal::ZERO,
+                output_cost: Decimal::ZERO,
+                complete_result: Mutex::new(Some(Err(LlmError::AuthFailed {
+                    provider: name.to_string(),
+                }))),
+                tool_complete_result: Mutex::new(Some(Err(LlmError::AuthFailed {
+                    provider: name.to_string(),
+                }))),
+            }
+        }
+
+        fn failing_rate_limited(name: &str) -> Self {
+            Self {
+                name: name.to_string(),
+                input_cost: Decimal::ZERO,
+                output_cost: Decimal::ZERO,
+                complete_result: Mutex::new(Some(Err(LlmError::RateLimited {
+                    provider: name.to_string(),
+                    retry_after: Some(Duration::from_secs(30)),
+                }))),
+                tool_complete_result: Mutex::new(Some(Err(LlmError::RateLimited {
+                    provider: name.to_string(),
+                    retry_after: Some(Duration::from_secs(30)),
+                }))),
+            }
+        }
+    }
+
+    #[async_trait]
+    impl LlmProvider for MockProvider {
+        fn model_name(&self) -> &str {
+            &self.name
+        }
+
+        fn cost_per_token(&self) -> (Decimal, Decimal) {
+            (self.input_cost, self.output_cost)
+        }
+
+        async fn complete(
+            &self,
+            _request: CompletionRequest,
+        ) -> Result {
+            self.complete_result
+                .lock()
+                .unwrap()
+                .take()
+                .expect("MockProvider::complete called more than once")
+        }
+
+        async fn complete_with_tools(
+            &self,
+            _request: ToolCompletionRequest,
+        ) -> Result {
+            self.tool_complete_result
+                .lock()
+                .unwrap()
+                .take()
+                .expect("MockProvider::complete_with_tools called more than once")
+        }
+
+        async fn list_models(&self) -> Result, LlmError> {
+            Ok(vec![self.name.clone()])
+        }
+    }
+
+    fn make_request() -> CompletionRequest {
+        CompletionRequest::new(vec![crate::llm::ChatMessage::user("hello")])
+    }
+
+    fn make_tool_request() -> ToolCompletionRequest {
+        ToolCompletionRequest::new(vec![crate::llm::ChatMessage::user("hello")], vec![])
+    }
+
+    // Test 1: Primary succeeds, no failover occurs.
+    #[tokio::test]
+    async fn primary_succeeds_no_failover() {
+        let primary = Arc::new(MockProvider::succeeding("primary", "primary response"));
+        let fallback = Arc::new(MockProvider::succeeding("fallback", "fallback response"));
+
+        let failover = FailoverProvider::new(vec![primary, fallback]).unwrap();
+
+        let response = failover.complete(make_request()).await.unwrap();
+        assert_eq!(response.content, "primary response");
+    }
+
+    // Test 2: Primary fails with retryable error, fallback succeeds.
+    #[tokio::test]
+    async fn primary_fails_retryable_fallback_succeeds() {
+        let primary = Arc::new(MockProvider::failing_retryable("primary"));
+        let fallback = Arc::new(MockProvider::succeeding("fallback", "fallback response"));
+
+        let failover = FailoverProvider::new(vec![primary, fallback]).unwrap();
+
+        let response = failover.complete(make_request()).await.unwrap();
+        assert_eq!(response.content, "fallback response");
+    }
+
+    // Test 3: All providers fail, returns last error.
+    #[tokio::test]
+    async fn all_providers_fail_returns_last_error() {
+        let primary = Arc::new(MockProvider::failing_retryable("primary"));
+        let fallback = Arc::new(MockProvider::failing_retryable("fallback"));
+
+        let failover = FailoverProvider::new(vec![primary, fallback]).unwrap();
+
+        let err = failover.complete(make_request()).await.unwrap_err();
+        match err {
+            LlmError::RequestFailed { provider, .. } => {
+                assert_eq!(provider, "fallback");
+            }
+            other => panic!("expected RequestFailed, got: {other:?}"),
+        }
+    }
+
+    // Test 4: Non-retryable error fails immediately, no failover.
+    #[tokio::test]
+    async fn non_retryable_error_fails_immediately() {
+        let primary = Arc::new(MockProvider::failing_non_retryable("primary"));
+        let fallback = Arc::new(MockProvider::succeeding("fallback", "fallback response"));
+
+        let failover = FailoverProvider::new(vec![primary, fallback]).unwrap();
+
+        let err = failover.complete(make_request()).await.unwrap_err();
+        match err {
+            LlmError::AuthFailed { provider } => {
+                assert_eq!(provider, "primary");
+            }
+            other => panic!("expected AuthFailed, got: {other:?}"),
+        }
+    }
+
+    // Test 5: Three providers, first two fail (retryable), third succeeds.
+    #[tokio::test]
+    async fn three_providers_first_two_fail_third_succeeds() {
+        let p1 = Arc::new(MockProvider::failing_retryable("provider-1"));
+        let p2 = Arc::new(MockProvider::failing_rate_limited("provider-2"));
+        let p3 = Arc::new(MockProvider::succeeding("provider-3", "third time lucky"));
+
+        let failover = FailoverProvider::new(vec![p1, p2, p3]).unwrap();
+
+        let response = failover.complete(make_request()).await.unwrap();
+        assert_eq!(response.content, "third time lucky");
+    }
+
+    // Test: complete_with_tools follows same failover logic.
+    #[tokio::test]
+    async fn complete_with_tools_failover() {
+        let primary = Arc::new(MockProvider::failing_retryable("primary"));
+        let fallback = Arc::new(MockProvider::succeeding("fallback", "tools fallback"));
+
+        let failover = FailoverProvider::new(vec![primary, fallback]).unwrap();
+
+        let response = failover
+            .complete_with_tools(make_tool_request())
+            .await
+            .unwrap();
+        assert_eq!(response.content.as_deref(), Some("tools fallback"));
+    }
+
+    // Test: model_name and cost_per_token reflect the last-used provider.
+    #[tokio::test]
+    async fn model_name_and_cost_track_last_used_provider() {
+        let fallback_cost = Decimal::new(15, 6); // 0.000015
+
+        let primary = Arc::new(MockProvider::failing_retryable("primary-model"));
+        let fallback = Arc::new(MockProvider::succeeding_with_cost(
+            "fallback-model",
+            "ok",
+            fallback_cost,
+            fallback_cost,
+        ));
+
+        let failover = FailoverProvider::new(vec![primary, fallback]).unwrap();
+
+        // Before any call, defaults to primary (index 0).
+        assert_eq!(failover.model_name(), "primary-model");
+        assert_eq!(failover.cost_per_token(), (Decimal::ZERO, Decimal::ZERO));
+
+        // After failover, should reflect the fallback provider.
+        let _ = failover.complete(make_request()).await.unwrap();
+        assert_eq!(failover.model_name(), "fallback-model");
+        assert_eq!(failover.cost_per_token(), (fallback_cost, fallback_cost));
+    }
+
+    // Test: list_models aggregates from all providers.
+    #[tokio::test]
+    async fn list_models_aggregates_all() {
+        let p1 = Arc::new(MockProvider::succeeding("model-a", "ok"));
+        let p2 = Arc::new(MockProvider::succeeding("model-b", "ok"));
+
+        let failover = FailoverProvider::new(vec![p1, p2]).unwrap();
+
+        let models = failover.list_models().await.unwrap();
+        assert!(models.contains(&"model-a".to_string()));
+        assert!(models.contains(&"model-b".to_string()));
+    }
+
+    // Test: is_retryable correctly classifies errors.
+    #[test]
+    fn retryable_classification() {
+        // Retryable
+        assert!(is_retryable(&LlmError::RequestFailed {
+            provider: "p".into(),
+            reason: "err".into(),
+        }));
+        assert!(is_retryable(&LlmError::RateLimited {
+            provider: "p".into(),
+            retry_after: None,
+        }));
+        assert!(is_retryable(&LlmError::InvalidResponse {
+            provider: "p".into(),
+            reason: "bad json".into(),
+        }));
+        assert!(is_retryable(&LlmError::SessionRenewalFailed {
+            provider: "p".into(),
+            reason: "timeout".into(),
+        }));
+        assert!(is_retryable(&LlmError::Io(std::io::Error::new(
+            std::io::ErrorKind::ConnectionReset,
+            "reset"
+        ))));
+        assert!(is_retryable(&LlmError::ModelNotAvailable {
+            provider: "p".into(),
+            model: "m".into(),
+        }));
+
+        // Non-retryable
+        assert!(!is_retryable(&LlmError::AuthFailed {
+            provider: "p".into(),
+        }));
+        assert!(!is_retryable(&LlmError::SessionExpired {
+            provider: "p".into(),
+        }));
+        assert!(!is_retryable(&LlmError::ContextLengthExceeded {
+            used: 100_000,
+            limit: 50_000,
+        }));
+    }
+
+    // Test: empty providers list returns error (not panic).
+    #[test]
+    fn empty_providers_returns_error() {
+        let result = FailoverProvider::new(vec![]);
+        assert!(result.is_err());
+    }
+}
diff --git a/src/llm/mod.rs b/src/llm/mod.rs
index ee801df7..95b9981a 100644
--- a/src/llm/mod.rs
+++ b/src/llm/mod.rs
@@ -8,13 +8,16 @@
 //! - **OpenAI-compatible**: Any endpoint that speaks the OpenAI API
 
 mod costs;
+pub mod failover;
 mod nearai;
 mod nearai_chat;
 mod provider;
 mod reasoning;
+mod retry;
 mod rig_adapter;
 pub mod session;
 
+pub use failover::FailoverProvider;
 pub use nearai::{ModelInfo, NearAiProvider};
 pub use nearai_chat::NearAiChatProvider;
 pub use provider::{
@@ -33,7 +36,7 @@ use std::sync::Arc;
 use rig::client::CompletionClient;
 use secrecy::ExposeSecret;
 
-use crate::config::{LlmBackend, LlmConfig, NearAiApiMode};
+use crate::config::{LlmBackend, LlmConfig, NearAiApiMode, NearAiConfig};
 use crate::error::LlmError;
 
 /// Create an LLM provider based on configuration.
@@ -46,7 +49,7 @@ pub fn create_llm_provider(
     session: Arc,
 ) -> Result, LlmError> {
     match config.backend {
-        LlmBackend::NearAi => create_nearai_provider(config, session),
+        LlmBackend::NearAi => create_llm_provider_with_config(&config.nearai, session),
         LlmBackend::OpenAi => create_openai_provider(config),
         LlmBackend::Anthropic => create_anthropic_provider(config),
         LlmBackend::Ollama => create_ollama_provider(config),
@@ -54,21 +57,28 @@ pub fn create_llm_provider(
     }
 }
 
-fn create_nearai_provider(
-    config: &LlmConfig,
+/// Create an LLM provider from a `NearAiConfig` directly.
+///
+/// This is useful when constructing additional providers for failover,
+/// where only the model name differs from the primary config.
+pub fn create_llm_provider_with_config(
+    config: &NearAiConfig,
     session: Arc,
 ) -> Result, LlmError> {
-    match config.nearai.api_mode {
+    match config.api_mode {
         NearAiApiMode::Responses => {
-            tracing::info!("Using NEAR AI Responses API (chat-api) with session auth");
-            Ok(Arc::new(NearAiProvider::new(
-                config.nearai.clone(),
-                session,
-            )))
+            tracing::info!(
+                model = %config.model,
+                "Using Responses API (chat-api) with session auth"
+            );
+            Ok(Arc::new(NearAiProvider::new(config.clone(), session)))
         }
         NearAiApiMode::ChatCompletions => {
-            tracing::info!("Using NEAR AI Chat Completions API (cloud-api) with API key auth");
-            Ok(Arc::new(NearAiChatProvider::new(config.nearai.clone())?))
+            tracing::info!(
+                model = %config.model,
+                "Using Chat Completions API (cloud-api) with API key auth"
+            );
+            Ok(Arc::new(NearAiChatProvider::new(config.clone())?))
         }
     }
 }
diff --git a/src/llm/nearai.rs b/src/llm/nearai.rs
index ed32d2fc..70c966e0 100644
--- a/src/llm/nearai.rs
+++ b/src/llm/nearai.rs
@@ -19,6 +19,7 @@ use crate::llm::provider::{
     ChatMessage, CompletionRequest, CompletionResponse, FinishReason, LlmProvider, Role, ToolCall,
     ToolCompletionRequest, ToolCompletionResponse,
 };
+use crate::llm::retry::{is_retryable_status, retry_backoff_delay};
 use crate::llm::session::SessionManager;
 
 /// Information about an available model from NEAR AI API.
@@ -270,88 +271,139 @@ impl NearAiProvider {
         }
     }
 
-    /// Inner request implementation without retry logic.
+    /// Inner request implementation with retry logic for transient errors.
+    ///
+    /// Retries on HTTP 429, 500, 502, 503, 504 with exponential backoff.
+    /// Does not retry on client errors (400, 401, 403, 404) or parse errors.
     async fn send_request_inner Deserialize<'de>>(
         &self,
         path: &str,
         body: &T,
     ) -> Result {
         let url = self.api_url(path);
-        let token = self.session.get_token().await?;
+        let max_retries = self.config.max_retries;
 
-        tracing::debug!("Sending request to NEAR AI: {}", url);
-        tracing::debug!("Request body: {:?}", body);
+        for attempt in 0..=max_retries {
+            let token = self.session.get_token().await?;
 
-        let response = self
-            .client
-            .post(&url)
-            .header("Authorization", format!("Bearer {}", token.expose_secret()))
-            .header("Content-Type", "application/json")
-            .json(body)
-            .send()
-            .await
-            .map_err(|e| {
-                tracing::error!("NEAR AI request failed: {}", e);
-                e
-            })?;
+            tracing::debug!(
+                "Sending request to NEAR AI: {} (attempt {})",
+                url,
+                attempt + 1
+            );
+            tracing::debug!("Request body: {:?}", body);
 
-        let status = response.status();
-        let response_text = response.text().await.unwrap_or_default();
+            let response = self
+                .client
+                .post(&url)
+                .header("Authorization", format!("Bearer {}", token.expose_secret()))
+                .header("Content-Type", "application/json")
+                .json(body)
+                .send()
+                .await;
 
-        tracing::debug!("NEAR AI response status: {}", status);
-        tracing::debug!("NEAR AI response body: {}", response_text);
+            let response = match response {
+                Ok(r) => r,
+                Err(e) => {
+                    tracing::error!("NEAR AI request failed: {}", e);
+                    // Network errors (timeout, connection refused) are transient
+                    if attempt < max_retries {
+                        let delay = retry_backoff_delay(attempt);
+                        tracing::warn!(
+                            "NEAR AI request error (attempt {}/{}), retrying in {:?}: {}",
+                            attempt + 1,
+                            max_retries + 1,
+                            delay,
+                            e,
+                        );
+                        tokio::time::sleep(delay).await;
+                        continue;
+                    }
+                    return Err(e.into());
+                }
+            };
 
-        if !status.is_success() {
-            // Check for session expiration (401 with specific message patterns)
-            if status.as_u16() == 401 {
-                let is_session_expired = response_text.to_lowercase().contains("session")
-                    && (response_text.to_lowercase().contains("expired")
-                        || response_text.to_lowercase().contains("invalid"));
+            let status = response.status();
+            let response_text = response.text().await.unwrap_or_default();
 
-                if is_session_expired {
-                    return Err(LlmError::SessionExpired {
+            tracing::debug!("NEAR AI response status: {}", status);
+            tracing::debug!("NEAR AI response body: {}", response_text);
+
+            if !status.is_success() {
+                let status_code = status.as_u16();
+
+                // Check for session expiration (401 with specific message patterns)
+                if status_code == 401 {
+                    let lower = response_text.to_lowercase();
+                    let is_session_expired = lower.contains("session")
+                        && (lower.contains("expired") || lower.contains("invalid"));
+
+                    if is_session_expired {
+                        return Err(LlmError::SessionExpired {
+                            provider: "nearai".to_string(),
+                        });
+                    }
+
+                    // Generic 401 -- not retryable
+                    return Err(LlmError::AuthFailed {
                         provider: "nearai".to_string(),
                     });
                 }
 
-                // Generic 401 without session expiration indication
-                return Err(LlmError::AuthFailed {
-                    provider: "nearai".to_string(),
-                });
-            }
+                // Check if this is a transient error worth retrying
+                if is_retryable_status(status_code) && attempt < max_retries {
+                    let delay = retry_backoff_delay(attempt);
+                    tracing::warn!(
+                        "NEAR AI returned HTTP {} (attempt {}/{}), retrying in {:?}",
+                        status_code,
+                        attempt + 1,
+                        max_retries + 1,
+                        delay,
+                    );
+                    tokio::time::sleep(delay).await;
+                    continue;
+                }
 
-            // Try to parse as JSON error
-            if let Ok(error) = serde_json::from_str::(&response_text) {
-                if status.as_u16() == 429 {
-                    return Err(LlmError::RateLimited {
+                // Non-retryable error or exhausted retries
+                if let Ok(error) = serde_json::from_str::(&response_text) {
+                    if status_code == 429 {
+                        return Err(LlmError::RateLimited {
+                            provider: "nearai".to_string(),
+                            retry_after: None,
+                        });
+                    }
+                    return Err(LlmError::RequestFailed {
                         provider: "nearai".to_string(),
-                        retry_after: None,
+                        reason: error.error,
                     });
                 }
+
                 return Err(LlmError::RequestFailed {
                     provider: "nearai".to_string(),
-                    reason: error.error,
+                    reason: format!("HTTP {}: {}", status, response_text),
                 });
             }
 
-            return Err(LlmError::RequestFailed {
-                provider: "nearai".to_string(),
-                reason: format!("HTTP {}: {}", status, response_text),
-            });
+            // Success -- parse the response
+            return match serde_json::from_str::(&response_text) {
+                Ok(parsed) => Ok(parsed),
+                Err(e) => {
+                    tracing::debug!("Response is not expected JSON format: {}", e);
+                    tracing::debug!("Will try alternative parsing in caller");
+                    Err(LlmError::InvalidResponse {
+                        provider: "nearai".to_string(),
+                        reason: format!("Parse error: {}. Raw: {}", e, response_text),
+                    })
+                }
+            };
         }
 
-        // Try to parse as our expected type
-        match serde_json::from_str::(&response_text) {
-            Ok(parsed) => Ok(parsed),
-            Err(e) => {
-                tracing::debug!("Response is not expected JSON format: {}", e);
-                tracing::debug!("Will try alternative parsing in caller");
-                Err(LlmError::InvalidResponse {
-                    provider: "nearai".to_string(),
-                    reason: format!("Parse error: {}. Raw: {}", e, response_text),
-                })
-            }
-        }
+        // This is unreachable because the loop always returns, but the compiler
+        // cannot prove that. Return a generic error as a safety net.
+        Err(LlmError::RequestFailed {
+            provider: "nearai".to_string(),
+            reason: "retry loop exited unexpectedly".to_string(),
+        })
     }
 }
 
diff --git a/src/llm/nearai_chat.rs b/src/llm/nearai_chat.rs
index 13a36a91..dbe51bc7 100644
--- a/src/llm/nearai_chat.rs
+++ b/src/llm/nearai_chat.rs
@@ -16,6 +16,7 @@ use crate::llm::provider::{
     ChatMessage, CompletionRequest, CompletionResponse, FinishReason, LlmProvider, ModelMetadata,
     Role, ToolCall, ToolCompletionRequest, ToolCompletionResponse,
 };
+use crate::llm::retry::{is_retryable_status, retry_backoff_delay};
 
 /// NEAR AI Chat Completions API provider.
 pub struct NearAiChatProvider {
@@ -62,64 +63,116 @@ impl NearAiChatProvider {
             .unwrap_or_default()
     }
 
-    /// Send a request to the chat completions API.
+    /// Send a request to the chat completions API with retry on transient errors.
+    ///
+    /// Retries on HTTP 429, 500, 502, 503, 504 with exponential backoff.
+    /// Does not retry on client errors (400, 401, 403, 404) or parse errors.
     async fn send_request Deserialize<'de>>(
         &self,
         body: &T,
     ) -> Result {
         let url = self.api_url("chat/completions");
+        let max_retries = self.config.max_retries;
 
-        tracing::debug!("Sending request to NEAR AI Chat: {}", url);
+        for attempt in 0..=max_retries {
+            tracing::debug!(
+                "Sending request to NEAR AI Chat: {} (attempt {})",
+                url,
+                attempt + 1,
+            );
 
-        if tracing::enabled!(tracing::Level::DEBUG)
-            && let Ok(json) = serde_json::to_string(body)
-        {
-            tracing::debug!("NEAR AI Chat request body: {}", json);
-        }
+            if tracing::enabled!(tracing::Level::DEBUG)
+                && let Ok(json) = serde_json::to_string(body)
+            {
+                tracing::debug!("NEAR AI Chat request body: {}", json);
+            }
 
-        let response = self
-            .client
-            .post(&url)
-            .header("Authorization", format!("Bearer {}", self.api_key()))
-            .header("Content-Type", "application/json")
-            .json(body)
-            .send()
-            .await
-            .map_err(|e| {
-                tracing::error!("NEAR AI Chat request failed: {}", e);
-                LlmError::RequestFailed {
-                    provider: "nearai_chat".to_string(),
-                    reason: e.to_string(),
+            let response = self
+                .client
+                .post(&url)
+                .header("Authorization", format!("Bearer {}", self.api_key()))
+                .header("Content-Type", "application/json")
+                .json(body)
+                .send()
+                .await;
+
+            let response = match response {
+                Ok(r) => r,
+                Err(e) => {
+                    tracing::error!("NEAR AI Chat request failed: {}", e);
+                    if attempt < max_retries {
+                        let delay = retry_backoff_delay(attempt);
+                        tracing::warn!(
+                            "NEAR AI Chat request error (attempt {}/{}), retrying in {:?}: {}",
+                            attempt + 1,
+                            max_retries + 1,
+                            delay,
+                            e,
+                        );
+                        tokio::time::sleep(delay).await;
+                        continue;
+                    }
+                    return Err(LlmError::RequestFailed {
+                        provider: "nearai_chat".to_string(),
+                        reason: e.to_string(),
+                    });
                 }
-            })?;
+            };
 
-        let status = response.status();
-        let response_text = response.text().await.unwrap_or_default();
+            let status = response.status();
+            let response_text = response.text().await.unwrap_or_default();
 
-        tracing::debug!("NEAR AI Chat response status: {}", status);
-        tracing::debug!("NEAR AI Chat response body: {}", response_text);
+            tracing::debug!("NEAR AI Chat response status: {}", status);
+            tracing::debug!("NEAR AI Chat response body: {}", response_text);
 
-        if !status.is_success() {
-            if status.as_u16() == 401 {
-                return Err(LlmError::AuthFailed {
+            if !status.is_success() {
+                let status_code = status.as_u16();
+
+                // Auth errors are not retryable
+                if status_code == 401 {
+                    return Err(LlmError::AuthFailed {
+                        provider: "nearai_chat".to_string(),
+                    });
+                }
+
+                // Transient errors: retry with backoff
+                if is_retryable_status(status_code) && attempt < max_retries {
+                    let delay = retry_backoff_delay(attempt);
+                    tracing::warn!(
+                        "NEAR AI Chat returned HTTP {} (attempt {}/{}), retrying in {:?}",
+                        status_code,
+                        attempt + 1,
+                        max_retries + 1,
+                        delay,
+                    );
+                    tokio::time::sleep(delay).await;
+                    continue;
+                }
+
+                // Non-retryable or exhausted retries
+                if status_code == 429 {
+                    return Err(LlmError::RateLimited {
+                        provider: "nearai_chat".to_string(),
+                        retry_after: None,
+                    });
+                }
+                return Err(LlmError::RequestFailed {
                     provider: "nearai_chat".to_string(),
+                    reason: format!("HTTP {}: {}", status, response_text),
                 });
             }
-            if status.as_u16() == 429 {
-                return Err(LlmError::RateLimited {
-                    provider: "nearai_chat".to_string(),
-                    retry_after: None,
-                });
-            }
-            return Err(LlmError::RequestFailed {
+
+            // Success — parse the response
+            return serde_json::from_str(&response_text).map_err(|e| LlmError::InvalidResponse {
                 provider: "nearai_chat".to_string(),
-                reason: format!("HTTP {}: {}", status, response_text),
+                reason: format!("JSON parse error: {}. Raw: {}", e, response_text),
             });
         }
 
-        serde_json::from_str(&response_text).map_err(|e| LlmError::InvalidResponse {
+        // Safety net: unreachable because the loop always returns
+        Err(LlmError::RequestFailed {
             provider: "nearai_chat".to_string(),
-            reason: format!("JSON parse error: {}. Raw: {}", e, response_text),
+            reason: "retry loop exited unexpectedly".to_string(),
         })
     }
 
diff --git a/src/llm/retry.rs b/src/llm/retry.rs
new file mode 100644
index 00000000..2dced0b0
--- /dev/null
+++ b/src/llm/retry.rs
@@ -0,0 +1,96 @@
+//! Shared retry helpers for LLM providers.
+//!
+//! Provides exponential backoff with jitter and retryable status classification
+//! used by both `NearAiProvider` and `NearAiChatProvider`.
+
+use std::time::Duration;
+
+use rand::Rng;
+
+/// Returns `true` if the HTTP status code is transient and worth retrying.
+pub(crate) fn is_retryable_status(status: u16) -> bool {
+    matches!(status, 429 | 500 | 502 | 503 | 504)
+}
+
+/// Calculate exponential backoff delay with random jitter.
+///
+/// Base delay is 1 second, doubled each attempt, with +/-25% jitter.
+/// - attempt 0: ~1s (0.75s - 1.25s)
+/// - attempt 1: ~2s (1.5s - 2.5s)
+/// - attempt 2: ~4s (3.0s - 5.0s)
+pub(crate) fn retry_backoff_delay(attempt: u32) -> Duration {
+    let base_ms: u64 = 1000u64.saturating_mul(2u64.saturating_pow(attempt));
+    let jitter_range = base_ms / 4; // 25%
+    let jitter = if jitter_range > 0 {
+        let offset = rand::thread_rng().gen_range(0..=jitter_range * 2);
+        offset as i64 - jitter_range as i64
+    } else {
+        0
+    };
+    let delay_ms = (base_ms as i64 + jitter).max(100) as u64;
+    Duration::from_millis(delay_ms)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_is_retryable_status() {
+        // Transient errors should be retryable
+        assert!(is_retryable_status(429));
+        assert!(is_retryable_status(500));
+        assert!(is_retryable_status(502));
+        assert!(is_retryable_status(503));
+        assert!(is_retryable_status(504));
+
+        // Client errors should not be retryable
+        assert!(!is_retryable_status(400));
+        assert!(!is_retryable_status(401));
+        assert!(!is_retryable_status(403));
+        assert!(!is_retryable_status(404));
+        assert!(!is_retryable_status(422));
+
+        // Success codes should not be retryable
+        assert!(!is_retryable_status(200));
+        assert!(!is_retryable_status(201));
+    }
+
+    #[test]
+    fn test_retry_backoff_delay_exponential_growth() {
+        // Run multiple samples to verify the range, accounting for jitter
+        for _ in 0..20 {
+            let d0 = retry_backoff_delay(0);
+            let d1 = retry_backoff_delay(1);
+            let d2 = retry_backoff_delay(2);
+
+            // Attempt 0: base 1000ms, jitter +/-250ms -> [750, 1250]
+            assert!(d0.as_millis() >= 750, "attempt 0 too low: {:?}", d0);
+            assert!(d0.as_millis() <= 1250, "attempt 0 too high: {:?}", d0);
+
+            // Attempt 1: base 2000ms, jitter +/-500ms -> [1500, 2500]
+            assert!(d1.as_millis() >= 1500, "attempt 1 too low: {:?}", d1);
+            assert!(d1.as_millis() <= 2500, "attempt 1 too high: {:?}", d1);
+
+            // Attempt 2: base 4000ms, jitter +/-1000ms -> [3000, 5000]
+            assert!(d2.as_millis() >= 3000, "attempt 2 too low: {:?}", d2);
+            assert!(d2.as_millis() <= 5000, "attempt 2 too high: {:?}", d2);
+        }
+    }
+
+    #[test]
+    fn test_retry_backoff_delay_minimum() {
+        // Even at attempt 0, delay should be at least 100ms (the minimum floor)
+        for _ in 0..20 {
+            let delay = retry_backoff_delay(0);
+            assert!(delay.as_millis() >= 100);
+        }
+    }
+
+    #[test]
+    fn test_retry_backoff_delay_no_overflow() {
+        // Very high attempt numbers should not panic from overflow
+        let delay = retry_backoff_delay(30);
+        assert!(delay.as_millis() >= 100);
+    }
+}
diff --git a/src/main.rs b/src/main.rs
index 97355aaa..ca9754ad 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -22,7 +22,10 @@ use ironclaw::{
     config::Config,
     context::ContextManager,
     extensions::ExtensionManager,
-    llm::{SessionConfig, create_llm_provider, create_session_manager},
+    llm::{
+        FailoverProvider, LlmProvider, SessionConfig, create_llm_provider,
+        create_llm_provider_with_config, create_session_manager,
+    },
     orchestrator::{
         ContainerJobConfig, ContainerJobManager, OrchestratorApi, TokenStore,
         api::OrchestratorState,
@@ -447,6 +450,27 @@ async fn main() -> anyhow::Result<()> {
     let llm = create_llm_provider(&config.llm, session.clone())?;
     tracing::info!("LLM provider initialized: {}", llm.model_name());
 
+    // Wrap in failover if a fallback model is configured
+    let llm: Arc =
+        if let Some(fallback_model) = config.llm.nearai.fallback_model.as_ref() {
+            if fallback_model == &config.llm.nearai.model {
+                tracing::warn!(
+                    "fallback_model is the same as primary model, failover may not be effective"
+                );
+            }
+            let mut fallback_config = config.llm.nearai.clone();
+            fallback_config.model = fallback_model.clone();
+            let fallback = create_llm_provider_with_config(&fallback_config, session.clone())?;
+            tracing::info!(
+                primary = %llm.model_name(),
+                fallback = %fallback.model_name(),
+                "LLM failover enabled"
+            );
+            Arc::new(FailoverProvider::new(vec![llm, fallback])?)
+        } else {
+            llm
+        };
+
     // Initialize safety layer
     let safety = Arc::new(SafetyLayer::new(&config.safety));
     tracing::info!("Safety layer initialized");
diff --git a/src/setup/wizard.rs b/src/setup/wizard.rs
index 696a7a08..16be4d30 100644
--- a/src/setup/wizard.rs
+++ b/src/setup/wizard.rs
@@ -653,6 +653,8 @@ impl SetupWizard {
                 session_path: crate::llm::session::default_session_path(),
                 api_mode: crate::config::NearAiApiMode::Responses,
                 api_key: None,
+                fallback_model: None,
+                max_retries: 3,
             },
             openai: None,
             anthropic: None,

From a53b2c10b569de297ef3178e029b86df256c3763 Mon Sep 17 00:00:00 2001
From: Illia Polosukhin 
Date: Sat, 14 Feb 2026 13:21:22 -0800
Subject: [PATCH 29/33] fix: Fix wasm tool schemas and runtime (#42)

* feat: Move debug log truncation from agent loop to REPL channel

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

Co-Authored-By: Claude Opus 4.6 

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

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

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

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

Co-Authored-By: Claude Opus 4.6 

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

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

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

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

Co-Authored-By: Claude Opus 4.6 

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

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

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

Co-Authored-By: Claude Opus 4.6 

* fix: Consistent OAuth callback port and polished landing page

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

Co-Authored-By: Claude Opus 4.6 

* refactor: Unify OAuth callback server across all auth flows

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

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

Removes ~120 lines of duplicated callback/HTML code.

Co-Authored-By: Claude Opus 4.6 

* Support for oauth token refresh

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

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

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

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

Co-Authored-By: Claude Opus 4.6 

* fix: Address PR review findings

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

Co-Authored-By: Claude Opus 4.6 

* style: Fix cargo fmt formatting

Co-Authored-By: Claude Opus 4.6 

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

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

Co-Authored-By: Claude Opus 4.6 

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

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

Co-Authored-By: Claude Opus 4.6 

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

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

Addresses review feedback from zmanian on PR #42.

Co-Authored-By: Claude Opus 4.6 

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

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

Co-Authored-By: Claude Opus 4.6 

* refactor: Replace BootstrapConfig with .env-based bootstrap

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

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

Co-Authored-By: Claude Opus 4.6 

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

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

Co-Authored-By: Claude Opus 4.6 

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

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

Co-Authored-By: Claude Opus 4.6 

---------

Co-authored-by: Claude Opus 4.6 
---
 Cargo.lock                            |  14 +-
 Cargo.toml                            |   5 +-
 examples/test_heartbeat.rs            |   1 -
 src/agent/agent_loop.rs               |   4 +-
 src/bootstrap.rs                      | 393 +++++++-----
 src/channels/repl.rs                  |  14 +-
 src/channels/wasm/wrapper.rs          |  94 ++-
 src/cli/config.rs                     |  80 +--
 src/cli/mod.rs                        |   1 +
 src/cli/oauth_defaults.rs             | 343 ++++++++++
 src/cli/status.rs                     |  32 +-
 src/cli/tool.rs                       | 207 +++---
 src/config.rs                         |  80 +--
 src/history/store.rs                  |   5 +
 src/llm/nearai.rs                     |  12 +-
 src/llm/session.rs                    | 177 +----
 src/main.rs                           |  52 +-
 src/secrets/store.rs                  |  41 +-
 src/settings.rs                       |  90 +--
 src/setup/channels.rs                 |  44 +-
 src/setup/wizard.rs                   |  86 ++-
 src/tools/mcp/auth.rs                 |  87 +--
 src/tools/mcp/client.rs               |  13 +-
 src/tools/mcp/config.rs               |  83 ++-
 src/tools/registry.rs                 |  17 +-
 src/tools/wasm/credential_injector.rs |   4 +-
 src/tools/wasm/loader.rs              | 186 +++++-
 src/tools/wasm/mod.rs                 |   2 +-
 src/tools/wasm/wrapper.rs             | 891 +++++++++++++++++++++++++-
 tools-src/gmail/src/lib.rs            | 151 ++---
 tools-src/google-calendar/src/lib.rs  | 220 ++-----
 tools-src/google-docs/src/lib.rs      | 337 +++-------
 tools-src/google-drive/src/lib.rs     | 274 +++-----
 tools-src/google-sheets/src/lib.rs    | 302 +++------
 tools-src/google-slides/src/lib.rs    | 410 +++---------
 tools-src/okta/src/lib.rs             |  61 +-
 tools-src/slack/src/lib.rs            | 100 +--
 tools-src/telegram/src/lib.rs         | 182 ++----
 38 files changed, 2794 insertions(+), 2301 deletions(-)
 create mode 100644 src/cli/oauth_defaults.rs

diff --git a/Cargo.lock b/Cargo.lock
index 6d64e505..a7b7f585 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2210,11 +2210,11 @@ dependencies = [
  "hyper 1.8.1",
  "hyper-util",
  "rustls",
+ "rustls-native-certs",
  "rustls-pki-types",
  "tokio",
  "tokio-rustls",
  "tower-service",
- "webpki-roots",
 ]
 
 [[package]]
@@ -2548,6 +2548,7 @@ dependencies = [
  "tower-http 0.6.8",
  "tracing",
  "tracing-subscriber",
+ "url",
  "urlencoding",
  "uuid",
  "wasmparser 0.220.1",
@@ -4033,6 +4034,7 @@ dependencies = [
  "pin-project-lite",
  "quinn",
  "rustls",
+ "rustls-native-certs",
  "rustls-pki-types",
  "serde",
  "serde_json",
@@ -4050,7 +4052,6 @@ dependencies = [
  "wasm-bindgen-futures",
  "wasm-streams",
  "web-sys",
- "webpki-roots",
 ]
 
 [[package]]
@@ -6237,15 +6238,6 @@ dependencies = [
  "wasm-bindgen",
 ]
 
-[[package]]
-name = "webpki-roots"
-version = "1.0.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "12bed680863276c63889429bfd6cab3b99943659923822de1c8a39c49e4d722c"
-dependencies = [
- "rustls-pki-types",
-]
-
 [[package]]
 name = "which"
 version = "4.4.2"
diff --git a/Cargo.toml b/Cargo.toml
index 6a11219a..e00dd628 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -22,7 +22,7 @@ tokio-stream = { version = "0.1", features = ["sync"] }
 futures = "0.3"
 
 # HTTP client
-reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] }
+reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-native-roots", "stream"] }
 
 # Serialization
 serde = { version = "1", features = ["derive"] }
@@ -84,7 +84,8 @@ fs4 = "0.6"
 # Secrecy for sensitive values
 secrecy = { version = "0.10", features = ["serde"] }
 
-# URL encoding for OAuth flow
+# URL parsing and encoding
+url = "2"
 urlencoding = "2"
 
 # Open URLs in browser
diff --git a/examples/test_heartbeat.rs b/examples/test_heartbeat.rs
index 188009bb..fcb9333d 100644
--- a/examples/test_heartbeat.rs
+++ b/examples/test_heartbeat.rs
@@ -81,7 +81,6 @@ async fn main() -> anyhow::Result<()> {
     let session = create_session_manager(SessionConfig {
         auth_base_url: config.llm.nearai.auth_base_url.clone(),
         session_path: config.llm.nearai.session_path.clone(),
-        ..Default::default()
     })
     .await;
     let llm = create_llm_provider(&config.llm, session)?;
diff --git a/src/agent/agent_loop.rs b/src/agent/agent_loop.rs
index fa9cfb9a..02912a15 100644
--- a/src/agent/agent_loop.rs
+++ b/src/agent/agent_loop.rs
@@ -1236,7 +1236,7 @@ impl Agent {
                                     &message.channel,
                                     StatusUpdate::ToolResult {
                                         name: tc.name.clone(),
-                                        preview: truncate_for_preview(output, 200),
+                                        preview: output.clone(),
                                     },
                                     &message.metadata,
                                 )
@@ -1710,7 +1710,7 @@ impl Agent {
                         &message.channel,
                         StatusUpdate::ToolResult {
                             name: pending.tool_name.clone(),
-                            preview: truncate_for_preview(output, 200),
+                            preview: output.clone(),
                         },
                         &message.metadata,
                     )
diff --git a/src/bootstrap.rs b/src/bootstrap.rs
index e24b996e..6c14efdf 100644
--- a/src/bootstrap.rs
+++ b/src/bootstrap.rs
@@ -1,147 +1,128 @@
-//! Bootstrap configuration for IronClaw.
+//! Bootstrap helpers for IronClaw.
 //!
-//! These are the only settings that MUST live on disk because they're needed
-//! before the database connection is established. Everything else lives in the
-//! `settings` table in PostgreSQL.
+//! The only setting that truly needs disk persistence before the database is
+//! available is `DATABASE_URL` (chicken-and-egg: can't connect to DB without
+//! it). Everything else is auto-detected or read from env vars.
 //!
-//! File: `~/.ironclaw/bootstrap.json`
+//! File: `~/.ironclaw/.env` (standard dotenvy format)
 
 use std::path::PathBuf;
 
-use serde::{Deserialize, Serialize};
-
-use crate::settings::KeySource;
-
-/// Minimal config needed to connect to the database and decrypt secrets.
-///
-/// This is the only JSON file IronClaw reads from disk at startup.
-/// All other configuration lives in the `settings` table in PostgreSQL.
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct BootstrapConfig {
-    /// Database connection URL (postgres://...).
-    #[serde(default)]
-    pub database_url: Option,
-
-    /// Database connection pool size.
-    #[serde(default)]
-    pub database_pool_size: Option,
-
-    /// Source for the secrets master key.
-    #[serde(default)]
-    pub secrets_master_key_source: KeySource,
-
-    /// Whether onboarding wizard has been completed.
-    #[serde(default)]
-    pub onboard_completed: bool,
+/// Path to the IronClaw-specific `.env` file: `~/.ironclaw/.env`.
+pub fn ironclaw_env_path() -> PathBuf {
+    dirs::home_dir()
+        .unwrap_or_else(|| PathBuf::from("."))
+        .join(".ironclaw")
+        .join(".env")
 }
 
-impl Default for BootstrapConfig {
-    fn default() -> Self {
-        Self {
-            database_url: None,
-            database_pool_size: None,
-            secrets_master_key_source: KeySource::None,
-            onboard_completed: false,
-        }
+/// Load env vars from `~/.ironclaw/.env` (in addition to the standard `.env`).
+///
+/// Call this **after** `dotenvy::dotenv()` so that the standard `./.env`
+/// takes priority over `~/.ironclaw/.env`. dotenvy never overwrites
+/// existing env vars, so the effective priority is:
+///
+///   explicit env vars > `./.env` > `~/.ironclaw/.env`
+///
+/// If `~/.ironclaw/.env` doesn't exist but the legacy `bootstrap.json` does,
+/// extracts `DATABASE_URL` from it and writes the `.env` file (one-time
+/// upgrade from the old config format).
+pub fn load_ironclaw_env() {
+    let path = ironclaw_env_path();
+
+    if !path.exists() {
+        // One-time upgrade: extract DATABASE_URL from legacy bootstrap.json
+        migrate_bootstrap_json_to_env(&path);
+    }
+
+    if path.exists() {
+        let _ = dotenvy::from_path(&path);
     }
 }
 
-impl BootstrapConfig {
-    /// Default bootstrap file path: `~/.ironclaw/bootstrap.json`.
-    pub fn default_path() -> PathBuf {
-        dirs::home_dir()
-            .unwrap_or_else(|| PathBuf::from("."))
-            .join(".ironclaw")
-            .join("bootstrap.json")
+/// If `bootstrap.json` exists, pull `database_url` out of it and write `.env`.
+fn migrate_bootstrap_json_to_env(env_path: &std::path::Path) {
+    let ironclaw_dir = env_path
+        .parent()
+        .unwrap_or_else(|| std::path::Path::new("."));
+    let bootstrap_path = ironclaw_dir.join("bootstrap.json");
+
+    if !bootstrap_path.exists() {
+        return;
     }
 
-    /// Legacy settings.json path (for migration detection).
-    pub fn legacy_settings_path() -> PathBuf {
-        dirs::home_dir()
-            .unwrap_or_else(|| PathBuf::from("."))
-            .join(".ironclaw")
-            .join("settings.json")
-    }
+    let content = match std::fs::read_to_string(&bootstrap_path) {
+        Ok(c) => c,
+        Err(_) => return,
+    };
 
-    /// Load from the default path, falling back to legacy settings.json,
-    /// then to defaults if neither exists.
-    pub fn load() -> Self {
-        let bootstrap_path = Self::default_path();
-        if bootstrap_path.exists() {
-            return Self::load_from(&bootstrap_path);
+    // Minimal parse: just grab database_url from the JSON
+    let parsed: serde_json::Value = match serde_json::from_str(&content) {
+        Ok(v) => v,
+        Err(_) => return,
+    };
+
+    if let Some(url) = parsed.get("database_url").and_then(|v| v.as_str()) {
+        if let Some(parent) = env_path.parent()
+            && let Err(e) = std::fs::create_dir_all(parent)
+        {
+            eprintln!("Warning: failed to create {}: {}", parent.display(), e);
+            return;
         }
-
-        // Fall back to legacy settings.json (extract just the 4 bootstrap fields)
-        let legacy_path = Self::legacy_settings_path();
-        if legacy_path.exists() {
-            return Self::load_from_legacy(&legacy_path);
+        if let Err(e) = std::fs::write(env_path, format!("DATABASE_URL=\"{}\"\n", url)) {
+            eprintln!("Warning: failed to migrate bootstrap.json to .env: {}", e);
+            return;
         }
-
-        Self::default()
-    }
-
-    /// Load from a specific path.
-    pub fn load_from(path: &PathBuf) -> Self {
-        match std::fs::read_to_string(path) {
-            Ok(data) => serde_json::from_str(&data).unwrap_or_default(),
-            Err(_) => Self::default(),
-        }
-    }
-
-    /// Extract bootstrap fields from a legacy settings.json.
-    fn load_from_legacy(path: &PathBuf) -> Self {
-        match std::fs::read_to_string(path) {
-            Ok(data) => {
-                // The legacy Settings struct is a superset; serde will ignore extra fields.
-                serde_json::from_str(&data).unwrap_or_default()
-            }
-            Err(_) => Self::default(),
-        }
-    }
-
-    /// Save to the default path.
-    pub fn save(&self) -> std::io::Result<()> {
-        self.save_to(&Self::default_path())
-    }
-
-    /// Save to a specific path.
-    pub fn save_to(&self, path: &PathBuf) -> std::io::Result<()> {
-        if let Some(parent) = path.parent() {
-            std::fs::create_dir_all(parent)?;
-        }
-        let json = serde_json::to_string_pretty(self)
-            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
-        std::fs::write(path, json)
+        rename_to_migrated(&bootstrap_path);
+        eprintln!(
+            "Migrated DATABASE_URL from bootstrap.json to {}",
+            env_path.display()
+        );
     }
 }
 
-/// One-time migration from disk config files to the database settings table.
+/// Write `DATABASE_URL` to `~/.ironclaw/.env`.
 ///
-/// On first boot after upgrade, checks if:
-/// 1. `~/.ironclaw/settings.json` exists
-/// 2. The DB settings table is empty for this user
+/// Creates the parent directory if it doesn't exist.
+/// The value is double-quoted so that `#` (common in URL-encoded passwords)
+/// and other shell-special characters are preserved by dotenvy.
+pub fn save_database_url(url: &str) -> std::io::Result<()> {
+    let path = ironclaw_env_path();
+    if let Some(parent) = path.parent() {
+        std::fs::create_dir_all(parent)?;
+    }
+    std::fs::write(&path, format!("DATABASE_URL=\"{}\"\n", url))
+}
+
+/// One-time migration of legacy `~/.ironclaw/settings.json` into the database.
 ///
-/// If both conditions hold, migrates settings, MCP servers, and session data
-/// to the database, writes `bootstrap.json`, and renames old files to `.migrated`.
+/// Only runs when a `settings.json` exists on disk AND the DB has no settings
+/// yet. After the wizard writes directly to the DB, this path is only hit by
+/// users upgrading from the old disk-only configuration.
+///
+/// After syncing, renames `settings.json` to `.migrated` so it won't trigger again.
 pub async fn migrate_disk_to_db(
     store: &dyn crate::db::Database,
     user_id: &str,
 ) -> Result<(), MigrationError> {
-    let legacy_settings_path = BootstrapConfig::legacy_settings_path();
+    let ironclaw_dir = dirs::home_dir()
+        .unwrap_or_else(|| PathBuf::from("."))
+        .join(".ironclaw");
+    let legacy_settings_path = ironclaw_dir.join("settings.json");
+
     if !legacy_settings_path.exists() {
         tracing::debug!("No legacy settings.json found, skipping disk-to-DB migration");
         return Ok(());
     }
 
-    // Only migrate if DB is empty for this user
+    // If DB already has settings, this is not a first boot, the wizard already
+    // wrote directly to the DB. Just clean up the stale file.
     let has_settings = store.has_settings(user_id).await.map_err(|e| {
         MigrationError::Database(format!("Failed to check existing settings: {}", e))
     })?;
     if has_settings {
-        tracing::debug!(
-            "DB already has settings for user '{}', skipping migration",
-            user_id
-        );
+        tracing::info!("DB already has settings, renaming stale settings.json");
+        rename_to_migrated(&legacy_settings_path);
         return Ok(());
     }
 
@@ -160,22 +141,14 @@ pub async fn migrate_disk_to_db(
         tracing::info!("Migrated {} settings to database", db_map.len());
     }
 
-    // 2. Write bootstrap.json with the 4 essential fields
-    let bootstrap = BootstrapConfig {
-        database_url: settings.database_url.clone(),
-        database_pool_size: settings.database_pool_size,
-        secrets_master_key_source: settings.secrets_master_key_source,
-        onboard_completed: settings.onboard_completed,
-    };
-    bootstrap
-        .save()
-        .map_err(|e| MigrationError::Io(format!("Failed to write bootstrap.json: {}", e)))?;
-    tracing::info!("Wrote bootstrap.json");
+    // 2. Write DATABASE_URL to ~/.ironclaw/.env
+    if let Some(ref url) = settings.database_url {
+        save_database_url(url)
+            .map_err(|e| MigrationError::Io(format!("Failed to write .env: {}", e)))?;
+        tracing::info!("Wrote DATABASE_URL to {}", ironclaw_env_path().display());
+    }
 
     // 3. Migrate mcp-servers.json if it exists
-    let ironclaw_dir = dirs::home_dir()
-        .unwrap_or_else(|| PathBuf::from("."))
-        .join(".ironclaw");
     let mcp_path = ironclaw_dir.join("mcp-servers.json");
     if mcp_path.exists() {
         match std::fs::read_to_string(&mcp_path) {
@@ -236,12 +209,19 @@ pub async fn migrate_disk_to_db(
     // 5. Rename settings.json to .migrated (don't delete, safety net)
     rename_to_migrated(&legacy_settings_path);
 
+    // 6. Clean up old bootstrap.json if it exists (superseded by .env)
+    let old_bootstrap = ironclaw_dir.join("bootstrap.json");
+    if old_bootstrap.exists() {
+        rename_to_migrated(&old_bootstrap);
+        tracing::info!("Renamed old bootstrap.json to .migrated");
+    }
+
     tracing::info!("Disk-to-DB migration complete");
     Ok(())
 }
 
 /// Rename a file to `.migrated` as a safety net.
-fn rename_to_migrated(path: &PathBuf) {
+fn rename_to_migrated(path: &std::path::Path) {
     let mut migrated = path.as_os_str().to_owned();
     migrated.push(".migrated");
     if let Err(e) = std::fs::rename(path, &migrated) {
@@ -264,62 +244,145 @@ mod tests {
     use tempfile::tempdir;
 
     #[test]
-    fn test_bootstrap_save_load() {
+    fn test_save_and_load_database_url() {
         let dir = tempdir().unwrap();
-        let path = dir.path().join("bootstrap.json");
+        let env_path = dir.path().join(".env");
 
-        let config = BootstrapConfig {
-            database_url: Some("postgres://localhost/test".to_string()),
-            database_pool_size: Some(5),
-            secrets_master_key_source: KeySource::Keychain,
-            onboard_completed: true,
-        };
+        // Write in the quoted format that save_database_url uses
+        let url = "postgres://localhost:5432/ironclaw_test";
+        std::fs::write(&env_path, format!("DATABASE_URL=\"{}\"\n", url)).unwrap();
 
-        config.save_to(&path).unwrap();
-
-        let loaded = BootstrapConfig::load_from(&path);
+        // Verify the content is a valid dotenv line (quoted)
+        let content = std::fs::read_to_string(&env_path).unwrap();
         assert_eq!(
-            loaded.database_url,
-            Some("postgres://localhost/test".to_string())
+            content,
+            "DATABASE_URL=\"postgres://localhost:5432/ironclaw_test\"\n"
         );
-        assert_eq!(loaded.database_pool_size, Some(5));
-        assert_eq!(loaded.secrets_master_key_source, KeySource::Keychain);
-        assert!(loaded.onboard_completed);
+
+        // Verify dotenvy can parse it (strips quotes automatically)
+        let parsed: Vec<(String, String)> = dotenvy::from_path_iter(&env_path)
+            .unwrap()
+            .filter_map(|r| r.ok())
+            .collect();
+        assert_eq!(parsed.len(), 1);
+        assert_eq!(parsed[0].0, "DATABASE_URL");
+        assert_eq!(parsed[0].1, url);
     }
 
     #[test]
-    fn test_bootstrap_from_legacy_settings() {
+    fn test_save_database_url_with_hash_in_password() {
         let dir = tempdir().unwrap();
-        let path = dir.path().join("settings.json");
+        let env_path = dir.path().join(".env");
 
-        // Write a legacy settings.json with many extra fields
-        let legacy = serde_json::json!({
-            "database_url": "postgres://localhost/ironclaw",
-            "database_pool_size": 10,
+        // URLs with # in the password are common (URL-encoded special chars).
+        // Without quoting, dotenvy treats # as a comment delimiter.
+        let url = "postgres://user:p%23ss@localhost:5432/ironclaw";
+        std::fs::write(&env_path, format!("DATABASE_URL=\"{}\"\n", url)).unwrap();
+
+        let parsed: Vec<(String, String)> = dotenvy::from_path_iter(&env_path)
+            .unwrap()
+            .filter_map(|r| r.ok())
+            .collect();
+        assert_eq!(parsed.len(), 1);
+        assert_eq!(parsed[0].0, "DATABASE_URL");
+        assert_eq!(parsed[0].1, url);
+    }
+
+    #[test]
+    fn test_save_database_url_creates_parent_dirs() {
+        let dir = tempdir().unwrap();
+        let nested = dir.path().join("deep").join("nested");
+        let env_path = nested.join(".env");
+
+        // Parent doesn't exist yet
+        assert!(!nested.exists());
+
+        // The global function uses a fixed path, so we test the logic directly
+        std::fs::create_dir_all(&nested).unwrap();
+        std::fs::write(&env_path, "DATABASE_URL=postgres://test\n").unwrap();
+
+        assert!(env_path.exists());
+        let content = std::fs::read_to_string(&env_path).unwrap();
+        assert!(content.contains("DATABASE_URL=postgres://test"));
+    }
+
+    #[test]
+    fn test_ironclaw_env_path() {
+        let path = ironclaw_env_path();
+        assert!(path.ends_with(".ironclaw/.env"));
+    }
+
+    #[test]
+    fn test_migrate_bootstrap_json_to_env() {
+        let dir = tempdir().unwrap();
+        let env_path = dir.path().join(".env");
+        let bootstrap_path = dir.path().join("bootstrap.json");
+
+        // Write a legacy bootstrap.json
+        let bootstrap_json = serde_json::json!({
+            "database_url": "postgres://localhost/ironclaw_upgrade",
+            "database_pool_size": 5,
             "secrets_master_key_source": "keychain",
-            "onboard_completed": true,
-            "selected_model": "claude-3-5-sonnet",
-            "agent": { "name": "testbot", "max_parallel_jobs": 3 },
-            "heartbeat": { "enabled": true }
+            "onboard_completed": true
         });
-        std::fs::write(&path, serde_json::to_string_pretty(&legacy).unwrap()).unwrap();
+        std::fs::write(
+            &bootstrap_path,
+            serde_json::to_string_pretty(&bootstrap_json).unwrap(),
+        )
+        .unwrap();
 
-        let config = BootstrapConfig::load_from_legacy(&path);
+        assert!(!env_path.exists());
+        assert!(bootstrap_path.exists());
+
+        // Run the migration
+        migrate_bootstrap_json_to_env(&env_path);
+
+        // .env should now exist with DATABASE_URL
+        assert!(env_path.exists());
+        let content = std::fs::read_to_string(&env_path).unwrap();
         assert_eq!(
-            config.database_url,
-            Some("postgres://localhost/ironclaw".to_string())
+            content,
+            "DATABASE_URL=\"postgres://localhost/ironclaw_upgrade\"\n"
         );
-        assert_eq!(config.database_pool_size, Some(10));
-        assert_eq!(config.secrets_master_key_source, KeySource::Keychain);
-        assert!(config.onboard_completed);
+
+        // bootstrap.json should be renamed to .migrated
+        assert!(!bootstrap_path.exists());
+        assert!(dir.path().join("bootstrap.json.migrated").exists());
     }
 
     #[test]
-    fn test_bootstrap_defaults() {
-        let config = BootstrapConfig::default();
-        assert!(config.database_url.is_none());
-        assert!(config.database_pool_size.is_none());
-        assert_eq!(config.secrets_master_key_source, KeySource::None);
-        assert!(!config.onboard_completed);
+    fn test_migrate_bootstrap_json_no_database_url() {
+        let dir = tempdir().unwrap();
+        let env_path = dir.path().join(".env");
+        let bootstrap_path = dir.path().join("bootstrap.json");
+
+        // bootstrap.json with no database_url
+        let bootstrap_json = serde_json::json!({
+            "onboard_completed": false
+        });
+        std::fs::write(
+            &bootstrap_path,
+            serde_json::to_string_pretty(&bootstrap_json).unwrap(),
+        )
+        .unwrap();
+
+        migrate_bootstrap_json_to_env(&env_path);
+
+        // .env should NOT be created
+        assert!(!env_path.exists());
+        // bootstrap.json should remain (no migration happened)
+        assert!(bootstrap_path.exists());
+    }
+
+    #[test]
+    fn test_migrate_bootstrap_json_missing() {
+        let dir = tempdir().unwrap();
+        let env_path = dir.path().join(".env");
+
+        // No bootstrap.json at all
+        migrate_bootstrap_json_to_env(&env_path);
+
+        // Nothing should happen
+        assert!(!env_path.exists());
     }
 }
diff --git a/src/channels/repl.rs b/src/channels/repl.rs
index ea91082b..1dde2f47 100644
--- a/src/channels/repl.rs
+++ b/src/channels/repl.rs
@@ -37,6 +37,9 @@ use crate::agent::truncate_for_preview;
 use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate};
 use crate::error::ChannelError;
 
+/// Max characters for tool result previews in the terminal.
+const CLI_TOOL_RESULT_MAX: usize = 200;
+
 /// Max characters for thinking/status messages in the terminal.
 const CLI_STATUS_MAX: usize = 200;
 
@@ -265,7 +268,7 @@ impl Channel for ReplChannel {
         std::thread::spawn(move || {
             // Single message mode: send it and return
             if let Some(msg) = single_message {
-                let incoming = IncomingMessage::new("repl", "user", &msg);
+                let incoming = IncomingMessage::new("repl", "default", &msg);
                 let _ = tx.blocking_send(incoming);
                 return;
             }
@@ -333,21 +336,21 @@ impl Channel for ReplChannel {
                             _ => {}
                         }
 
-                        let msg = IncomingMessage::new("repl", "user", line);
+                        let msg = IncomingMessage::new("repl", "default", line);
                         if tx.blocking_send(msg).is_err() {
                             break;
                         }
                     }
                     Err(ReadlineError::Interrupted) => {
                         // Ctrl+C: send /interrupt
-                        let msg = IncomingMessage::new("repl", "user", "/interrupt");
+                        let msg = IncomingMessage::new("repl", "default", "/interrupt");
                         if tx.blocking_send(msg).is_err() {
                             break;
                         }
                     }
                     Err(ReadlineError::Eof) => {
                         // Ctrl+D: send /quit so the agent loop runs graceful shutdown
-                        let msg = IncomingMessage::new("repl", "user", "/quit");
+                        let msg = IncomingMessage::new("repl", "default", "/quit");
                         let _ = tx.blocking_send(msg);
                         break;
                     }
@@ -418,7 +421,8 @@ impl Channel for ReplChannel {
                 }
             }
             StatusUpdate::ToolResult { name: _, preview } => {
-                eprintln!("    \x1b[90m{preview}\x1b[0m");
+                let display = truncate_for_preview(&preview, CLI_TOOL_RESULT_MAX);
+                eprintln!("    \x1b[90m{display}\x1b[0m");
             }
             StatusUpdate::StreamChunk(chunk) => {
                 // Print separator on the false-to-true transition
diff --git a/src/channels/wasm/wrapper.rs b/src/channels/wasm/wrapper.rs
index 5d34e40c..212334a6 100644
--- a/src/channels/wasm/wrapper.rs
+++ b/src/channels/wasm/wrapper.rs
@@ -76,6 +76,9 @@ struct ChannelStoreData {
     credentials: HashMap,
     /// Pairing store for DM pairing (guest access control).
     pairing_store: Arc,
+    /// Dedicated tokio runtime for HTTP requests, lazily initialized.
+    /// Reused across multiple `http_request` calls within one execution.
+    http_runtime: Option,
 }
 
 impl ChannelStoreData {
@@ -96,6 +99,7 @@ impl ChannelStoreData {
             table: ResourceTable::new(),
             credentials,
             pairing_store,
+            http_runtime: None,
         }
     }
 
@@ -283,10 +287,25 @@ impl near::agent::channel_host::Host for ChannelStoreData {
             .map(|h| h.max_response_bytes)
             .unwrap_or(10 * 1024 * 1024);
 
-        // Make the HTTP request using blocking I/O
-        // We're already in a spawn_blocking context, so we can use block_on
-        let result = tokio::runtime::Handle::current().block_on(async {
-            let client = reqwest::Client::new();
+        // Make the HTTP request using a dedicated single-threaded runtime.
+        // We're inside spawn_blocking, so we can't rely on the main runtime's
+        // I/O driver (it may be busy with WASM compilation or other startup work).
+        // A dedicated runtime gives us our own I/O driver and avoids contention.
+        // The runtime is lazily created and reused across calls within one execution.
+        if self.http_runtime.is_none() {
+            self.http_runtime = Some(
+                tokio::runtime::Builder::new_current_thread()
+                    .enable_all()
+                    .build()
+                    .map_err(|e| format!("Failed to create HTTP runtime: {e}"))?,
+            );
+        }
+        let rt = self.http_runtime.as_ref().expect("just initialized");
+        let result = rt.block_on(async {
+            let client = reqwest::Client::builder()
+                .connect_timeout(std::time::Duration::from_secs(10))
+                .build()
+                .map_err(|e| format!("Failed to build HTTP client: {e}"))?;
 
             let mut request = match method.to_uppercase().as_str() {
                 "GET" => client.get(&url),
@@ -308,9 +327,9 @@ impl near::agent::channel_host::Host for ChannelStoreData {
                 request = request.body(body_bytes);
             }
 
-            // Send request with caller-specified timeout (default 30s).
-            // Cap at callback_timeout to prevent outliving the host wrapper.
-            let timeout = std::time::Duration::from_millis(timeout_ms.unwrap_or(30_000) as u64);
+            // Send request with caller-specified timeout (default 30s, max 5min).
+            let timeout_ms = timeout_ms.unwrap_or(30_000).min(300_000) as u64;
+            let timeout = std::time::Duration::from_millis(timeout_ms);
             let response = request.timeout(timeout).send().await.map_err(|e| {
                 // Walk the full error chain so we get the actual root cause
                 // (DNS, TLS, connection refused, etc.) instead of just
@@ -795,7 +814,21 @@ impl WasmChannel {
         .await;
 
         match result {
-            Ok(Ok((config, _host_state))) => {
+            Ok(Ok((config, mut host_state))) => {
+                // Surface WASM guest logs (errors/warnings from webhook setup, etc.)
+                for entry in host_state.take_logs() {
+                    match entry.level {
+                        crate::tools::wasm::LogLevel::Error => {
+                            tracing::error!(channel = %self.name, "{}", entry.message);
+                        }
+                        crate::tools::wasm::LogLevel::Warn => {
+                            tracing::warn!(channel = %self.name, "{}", entry.message);
+                        }
+                        _ => {
+                            tracing::debug!(channel = %self.name, "{}", entry.message);
+                        }
+                    }
+                }
                 tracing::info!(
                     channel = %self.name,
                     display_name = %config.display_name,
@@ -2615,15 +2648,52 @@ mod tests {
         assert_eq!(store.redact_credentials(input), input);
     }
 
-    /// Verify that the block_on-inside-spawn_blocking pattern used by the WASM
-    /// channel HTTP host function doesn't deadlock or panic.
+    /// Verify that WASM HTTP host functions work using a dedicated
+    /// current-thread runtime inside spawn_blocking.
     #[tokio::test]
-    async fn test_block_on_inside_spawn_blocking_does_not_deadlock() {
+    async fn test_dedicated_runtime_inside_spawn_blocking() {
         let result = tokio::task::spawn_blocking(|| {
-            tokio::runtime::Handle::current().block_on(async { 42 })
+            let rt = tokio::runtime::Builder::new_current_thread()
+                .enable_all()
+                .build()
+                .expect("failed to build runtime");
+            rt.block_on(async { 42 })
         })
         .await
         .expect("spawn_blocking panicked");
         assert_eq!(result, 42);
     }
+
+    /// Verify a real HTTP request works using the dedicated-runtime pattern.
+    /// This catches DNS, TLS, and I/O driver issues that trivial tests miss.
+    #[tokio::test]
+    #[ignore] // requires network
+    async fn test_dedicated_runtime_real_http() {
+        let result = tokio::task::spawn_blocking(|| {
+            let rt = tokio::runtime::Builder::new_current_thread()
+                .enable_all()
+                .build()
+                .expect("failed to build runtime");
+            rt.block_on(async {
+                let client = reqwest::Client::builder()
+                    .connect_timeout(std::time::Duration::from_secs(10))
+                    .build()
+                    .expect("failed to build client");
+                let resp = client
+                    .get("https://api.telegram.org/bot000/getMe")
+                    .timeout(std::time::Duration::from_secs(10))
+                    .send()
+                    .await;
+                match resp {
+                    Ok(r) => r.status().as_u16(),
+                    Err(e) if e.is_timeout() => panic!("request timed out: {e}"),
+                    Err(e) => panic!("unexpected error: {e}"),
+                }
+            })
+        })
+        .await
+        .expect("spawn_blocking panicked");
+        // 404 because "000" is not a valid bot token
+        assert_eq!(result, 404);
+    }
 }
diff --git a/src/cli/config.rs b/src/cli/config.rs
index 8835b2c0..f91e9241 100644
--- a/src/cli/config.rs
+++ b/src/cli/config.rs
@@ -48,8 +48,6 @@ pub enum ConfigCommand {
 /// Connects to the database to read/write settings. Falls back to disk
 /// if the database is not available.
 pub async fn run_config_command(cmd: ConfigCommand) -> anyhow::Result<()> {
-    let _ = dotenvy::dotenv();
-
     // Try to connect to the DB for settings access
     let db: Option> = match connect_db().await {
         Ok(d) => Some(d),
@@ -92,7 +90,7 @@ async fn load_settings(store: Option<&dyn crate::db::Database>) -> Settings {
             _ => {}
         }
     }
-    Settings::load()
+    Settings::default()
 }
 
 /// List all settings.
@@ -155,19 +153,17 @@ async fn set_setting(
         .set(path, value)
         .map_err(|e| anyhow::anyhow!("{}", e))?;
 
-    // Save to DB if available, otherwise disk
-    if let Some(store) = store {
-        let json_value = match serde_json::from_str::(value) {
-            Ok(v) => v,
-            Err(_) => serde_json::Value::String(value.to_string()),
-        };
-        store
-            .set_setting(DEFAULT_USER_ID, path, &json_value)
-            .await
-            .map_err(|e| anyhow::anyhow!("Failed to save to database: {}", e))?;
-    } else {
-        settings.save()?;
-    }
+    let store = store.ok_or_else(|| {
+        anyhow::anyhow!("Database connection required to save settings. Check DATABASE_URL.")
+    })?;
+    let json_value = match serde_json::from_str::(value) {
+        Ok(v) => v,
+        Err(_) => serde_json::Value::String(value.to_string()),
+    };
+    store
+        .set_setting(DEFAULT_USER_ID, path, &json_value)
+        .await
+        .map_err(|e| anyhow::anyhow!("Failed to save to database: {}", e))?;
 
     println!("Set {} = {}", path, value);
     Ok(())
@@ -180,17 +176,13 @@ async fn reset_setting(store: Option<&dyn crate::db::Database>, path: &str) -> a
         .get(path)
         .ok_or_else(|| anyhow::anyhow!("Unknown setting: {}", path))?;
 
-    // Delete from DB (falling back to default) or reset on disk
-    if let Some(store) = store {
-        store
-            .delete_setting(DEFAULT_USER_ID, path)
-            .await
-            .map_err(|e| anyhow::anyhow!("Failed to delete setting from database: {}", e))?;
-    } else {
-        let mut settings = Settings::load();
-        settings.reset(path).map_err(|e| anyhow::anyhow!("{}", e))?;
-        settings.save()?;
-    }
+    let store = store.ok_or_else(|| {
+        anyhow::anyhow!("Database connection required to reset settings. Check DATABASE_URL.")
+    })?;
+    store
+        .delete_setting(DEFAULT_USER_ID, path)
+        .await
+        .map_err(|e| anyhow::anyhow!("Failed to delete setting from database: {}", e))?;
 
     println!("Reset {} to default: {}", path, default_value);
     Ok(())
@@ -200,37 +192,13 @@ async fn reset_setting(store: Option<&dyn crate::db::Database>, path: &str) -> a
 fn show_path(has_db: bool) -> anyhow::Result<()> {
     if has_db {
         println!("Settings stored in: database (settings table)");
-        println!(
-            "Bootstrap config:   {}",
-            crate::bootstrap::BootstrapConfig::default_path().display()
-        );
     } else {
-        let path = Settings::default_path();
-        println!("Settings stored in: {} (disk fallback)", path.display());
-
-        if path.exists() {
-            let metadata = std::fs::metadata(&path)?;
-            println!("  Size: {} bytes", metadata.len());
-            if let Ok(modified) = metadata.modified() {
-                use std::time::SystemTime;
-                let duration = SystemTime::now()
-                    .duration_since(modified)
-                    .unwrap_or_default();
-                let secs = duration.as_secs();
-                if secs < 60 {
-                    println!("  Modified: {} seconds ago", secs);
-                } else if secs < 3600 {
-                    println!("  Modified: {} minutes ago", secs / 60);
-                } else if secs < 86400 {
-                    println!("  Modified: {} hours ago", secs / 3600);
-                } else {
-                    println!("  Modified: {} days ago", secs / 86400);
-                }
-            }
-        } else {
-            println!("  (does not exist, using defaults)");
-        }
+        println!("Settings stored in: PostgreSQL (not connected, using defaults)");
     }
+    println!(
+        "Env config:         {}",
+        crate::bootstrap::ironclaw_env_path().display()
+    );
 
     Ok(())
 }
diff --git a/src/cli/mod.rs b/src/cli/mod.rs
index 77ed1f3d..06715d8c 100644
--- a/src/cli/mod.rs
+++ b/src/cli/mod.rs
@@ -12,6 +12,7 @@
 mod config;
 mod mcp;
 pub mod memory;
+pub mod oauth_defaults;
 mod pairing;
 pub mod status;
 mod tool;
diff --git a/src/cli/oauth_defaults.rs b/src/cli/oauth_defaults.rs
new file mode 100644
index 00000000..eea91a71
--- /dev/null
+++ b/src/cli/oauth_defaults.rs
@@ -0,0 +1,343 @@
+//! Shared OAuth infrastructure: built-in credentials, callback server, landing pages.
+//!
+//! Every OAuth flow in the codebase (WASM tool auth, MCP server auth, NEAR AI login)
+//! uses the same callback port, landing page, and listener logic from this module.
+//!
+//! # Built-in Credentials
+//!
+//! Many CLI tools (gcloud, rclone, gdrive) ship with default OAuth credentials
+//! so users don't need to register their own OAuth app. Google explicitly
+//! documents that client_secret for "Desktop App" / "Installed App" types
+//! is NOT actually secret.
+//!
+//! Default credentials are hardcoded below. They can be overridden at:
+//!
+//! - **Compile time**: Set IRONCLAW_GOOGLE_CLIENT_ID / IRONCLAW_GOOGLE_CLIENT_SECRET
+//!   env vars before building to replace the hardcoded defaults.
+//! - **Runtime**: Users can set GOOGLE_OAUTH_CLIENT_ID / GOOGLE_OAUTH_CLIENT_SECRET
+//!   env vars, which take priority over built-in defaults.
+
+use std::time::Duration;
+
+use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
+use tokio::net::TcpListener;
+
+// ── Built-in credentials ────────────────────────────────────────────────
+
+pub struct OAuthCredentials {
+    pub client_id: &'static str,
+    pub client_secret: &'static str,
+}
+
+/// Google OAuth "Desktop App" credentials, shared across all Google tools.
+/// Compile-time env vars override the hardcoded defaults below.
+const GOOGLE_CLIENT_ID: &str = match option_env!("IRONCLAW_GOOGLE_CLIENT_ID") {
+    Some(v) => v,
+    None => "564604149681-efo25d43rs85v0tibdepsmdv5dsrhhr0.apps.googleusercontent.com",
+};
+const GOOGLE_CLIENT_SECRET: &str = match option_env!("IRONCLAW_GOOGLE_CLIENT_SECRET") {
+    Some(v) => v,
+    None => "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",
+};
+
+/// Returns built-in OAuth credentials for a provider, keyed by secret_name.
+///
+/// The secret_name comes from the tool's capabilities.json `auth.secret_name` field.
+/// Returns `None` if no built-in credentials are configured for that provider.
+pub fn builtin_credentials(secret_name: &str) -> Option {
+    match secret_name {
+        "google_oauth_token" => Some(OAuthCredentials {
+            client_id: GOOGLE_CLIENT_ID,
+            client_secret: GOOGLE_CLIENT_SECRET,
+        }),
+        _ => None,
+    }
+}
+
+// ── Shared callback server ──────────────────────────────────────────────
+
+/// Fixed port for all OAuth callbacks.
+///
+/// Every redirect URI registered with providers must use this port:
+/// `http://localhost:9876/callback` (or `/auth/callback` for NEAR AI).
+pub const OAUTH_CALLBACK_PORT: u16 = 9876;
+
+/// Error from the OAuth callback listener.
+#[derive(Debug, thiserror::Error)]
+pub enum OAuthCallbackError {
+    #[error("Port {0} is in use (another auth flow running?): {1}")]
+    PortInUse(u16, String),
+
+    #[error("Authorization denied by user")]
+    Denied,
+
+    #[error("Timed out waiting for authorization")]
+    Timeout,
+
+    #[error("IO error: {0}")]
+    Io(String),
+}
+
+/// Bind the OAuth callback listener on the fixed port.
+///
+/// Tries IPv6 loopback (`[::1]`) first so that `http://localhost:…` redirects
+/// work on systems where `localhost` resolves to `::1`. Falls back to IPv4
+/// (`127.0.0.1`) only if IPv6 fails for a reason other than `AddrInUse`
+/// (e.g., IPv6 not supported on the host). If the port is already occupied
+/// on IPv6, the port is occupied period, so we fail immediately.
+pub async fn bind_callback_listener() -> Result {
+    let ipv6_addr = format!("[::1]:{}", OAUTH_CALLBACK_PORT);
+    match TcpListener::bind(&ipv6_addr).await {
+        Ok(listener) => return Ok(listener),
+        Err(e) if e.kind() == std::io::ErrorKind::AddrInUse => {
+            return Err(OAuthCallbackError::PortInUse(
+                OAUTH_CALLBACK_PORT,
+                e.to_string(),
+            ));
+        }
+        Err(_) => {
+            // IPv6 not available on this host, fall back to IPv4
+        }
+    }
+    TcpListener::bind(format!("127.0.0.1:{}", OAUTH_CALLBACK_PORT))
+        .await
+        .map_err(|e| {
+            if e.kind() == std::io::ErrorKind::AddrInUse {
+                OAuthCallbackError::PortInUse(OAUTH_CALLBACK_PORT, e.to_string())
+            } else {
+                OAuthCallbackError::Io(e.to_string())
+            }
+        })
+}
+
+/// Wait for an OAuth callback and extract a query parameter value.
+///
+/// Listens for a GET request matching `path_prefix` (e.g., "/callback" or "/auth/callback"),
+/// extracts the value of `param_name` (e.g., "code" or "token"), and shows a branded
+/// landing page using `display_name` (e.g., "Google", "Notion", "NEAR AI").
+///
+/// Times out after 5 minutes.
+pub async fn wait_for_callback(
+    listener: TcpListener,
+    path_prefix: &str,
+    param_name: &str,
+    display_name: &str,
+) -> Result {
+    let path_prefix = path_prefix.to_string();
+    let param_name = param_name.to_string();
+    let display_name = display_name.to_string();
+
+    tokio::time::timeout(Duration::from_secs(300), async move {
+        loop {
+            let (mut socket, _) = listener
+                .accept()
+                .await
+                .map_err(|e| OAuthCallbackError::Io(e.to_string()))?;
+
+            let mut reader = BufReader::new(&mut socket);
+            let mut request_line = String::new();
+            reader
+                .read_line(&mut request_line)
+                .await
+                .map_err(|e| OAuthCallbackError::Io(e.to_string()))?;
+
+            if let Some(path) = request_line.split_whitespace().nth(1)
+                && path.starts_with(&path_prefix)
+                && let Some(query) = path.split('?').nth(1)
+            {
+                // Check for error first
+                if query.contains("error=") {
+                    let html = landing_html(&display_name, false);
+                    let response = format!(
+                        "HTTP/1.1 400 Bad Request\r\n\
+                         Content-Type: text/html; charset=utf-8\r\n\
+                         Connection: close\r\n\
+                         \r\n\
+                         {}",
+                        html
+                    );
+                    let _ = socket.write_all(response.as_bytes()).await;
+                    return Err(OAuthCallbackError::Denied);
+                }
+
+                // Look for the target parameter
+                for param in query.split('&') {
+                    let parts: Vec<&str> = param.splitn(2, '=').collect();
+                    if parts.len() == 2 && parts[0] == param_name {
+                        let value = urlencoding::decode(parts[1])
+                            .unwrap_or_else(|_| parts[1].into())
+                            .into_owned();
+
+                        let html = landing_html(&display_name, true);
+                        let response = format!(
+                            "HTTP/1.1 200 OK\r\n\
+                             Content-Type: text/html; charset=utf-8\r\n\
+                             Connection: close\r\n\
+                             \r\n\
+                             {}",
+                            html
+                        );
+                        let _ = socket.write_all(response.as_bytes()).await;
+                        let _ = socket.shutdown().await;
+
+                        return Ok(value);
+                    }
+                }
+            }
+
+            // Not the callback we're looking for
+            let response = "HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n";
+            let _ = socket.write_all(response.as_bytes()).await;
+        }
+    })
+    .await
+    .map_err(|_| OAuthCallbackError::Timeout)?
+}
+
+/// Escape a string for safe interpolation into HTML content.
+fn html_escape(s: &str) -> String {
+    let mut out = String::with_capacity(s.len());
+    for c in s.chars() {
+        match c {
+            '&' => out.push_str("&"),
+            '<' => out.push_str("<"),
+            '>' => out.push_str(">"),
+            '"' => out.push_str("""),
+            '\'' => out.push_str("'"),
+            _ => out.push(c),
+        }
+    }
+    out
+}
+
+/// HTML landing page shown in the browser after an OAuth redirect.
+pub fn landing_html(provider_name: &str, success: bool) -> String {
+    let safe_name = html_escape(provider_name);
+    let (icon, heading, subtitle, accent) = if success {
+        (
+            r##"
+ +
"##, + format!("{} Connected", safe_name), + "You can close this window and return to your terminal.", + "#22c55e", + ) + } else { + ( + r##"
+ +
"##, + "Authorization Failed".to_string(), + "The request was denied. You can close this window and try again.", + "#ef4444", + ) + }; + + format!( + r#" + + + + +IronClaw - {heading} + + + +
+ {icon} +

{heading}

+

{subtitle}

+
IronClaw
+
+ +"#, + heading = heading, + icon = icon, + subtitle = subtitle, + accent = accent, + ) +} + +#[cfg(test)] +mod tests { + use crate::cli::oauth_defaults::{builtin_credentials, landing_html}; + + #[test] + fn test_unknown_provider_returns_none() { + assert!(builtin_credentials("unknown_token").is_none()); + } + + #[test] + fn test_google_returns_based_on_compile_env() { + let creds = builtin_credentials("google_oauth_token"); + assert!(creds.is_some()); + let creds = creds.unwrap(); + assert!(!creds.client_id.is_empty()); + assert!(!creds.client_secret.is_empty()); + } + + #[test] + fn test_landing_html_success_contains_key_elements() { + let html = landing_html("Google", true); + assert!(html.contains("Google Connected")); + assert!(html.contains("charset")); + assert!(html.contains("IronClaw")); + assert!(html.contains("#22c55e")); // green accent + assert!(!html.contains("Failed")); + } + + #[test] + fn test_landing_html_escapes_provider_name() { + let html = landing_html("", true); + assert!(!html.contains("

nqHT`UW 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 XYN^NQ=1fo|=D&|TR3`SXw~+oHWe{s7 diff --git a/src/channels/web/types.rs b/src/channels/web/types.rs index 85420211..e476a0f6 100644 --- a/src/channels/web/types.rs +++ b/src/channels/web/types.rs @@ -314,6 +314,7 @@ impl WsServerMessage { SseEvent::Thinking { .. } => "thinking", SseEvent::ToolStarted { .. } => "tool_started", SseEvent::ToolCompleted { .. } => "tool_completed", + SseEvent::ToolResult { .. } => "tool_result", SseEvent::StreamChunk { .. } => "stream_chunk", SseEvent::Status { .. } => "status", SseEvent::ApprovalNeeded { .. } => "approval_needed", From 810ba58fd214a5e6c3705f18e283f52e1528ccca Mon Sep 17 00:00:00 2001 From: Illia Polosukhin Date: Mon, 9 Feb 2026 23:31:29 -0800 Subject: [PATCH 02/33] feat: Add Okta SSO WASM tool for profile management and app catalog Sandboxed WASM tool that integrates with Okta's Management API and MyAccount API. Supports user profile CRUD, listing all SSO app chiclets, searching apps by name, retrieving SSO launch links, and fetching org info. Uses OAuth2 with PKCE against the Org Authorization Server, with the domain stored in workspace at okta/domain. Co-Authored-By: Claude Opus 4.6 --- tools-src/okta/Cargo.toml | 21 ++ tools-src/okta/okta-tool.capabilities.json | 93 +++++++ tools-src/okta/src/api.rs | 281 +++++++++++++++++++++ tools-src/okta/src/lib.rs | 148 +++++++++++ tools-src/okta/src/types.rs | 119 +++++++++ 5 files changed, 662 insertions(+) create mode 100644 tools-src/okta/Cargo.toml create mode 100644 tools-src/okta/okta-tool.capabilities.json create mode 100644 tools-src/okta/src/api.rs create mode 100644 tools-src/okta/src/lib.rs create mode 100644 tools-src/okta/src/types.rs diff --git a/tools-src/okta/Cargo.toml b/tools-src/okta/Cargo.toml new file mode 100644 index 00000000..e399d494 --- /dev/null +++ b/tools-src/okta/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "okta-tool" +version = "0.1.0" +edition = "2021" +description = "Okta SSO tool for IronClaw (WASM component) — user profile, app catalog, and SSO launch links" +license = "MIT OR Apache-2.0" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +wit-bindgen = "=0.36" +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[profile.release] +opt-level = "s" +lto = true +strip = true +codegen-units = 1 diff --git a/tools-src/okta/okta-tool.capabilities.json b/tools-src/okta/okta-tool.capabilities.json new file mode 100644 index 00000000..14a0879d --- /dev/null +++ b/tools-src/okta/okta-tool.capabilities.json @@ -0,0 +1,93 @@ +{ + "http": { + "allowlist": [ + { + "host": "*.okta.com", + "path_prefix": "/api/v1/", + "methods": ["GET", "POST", "PUT"] + }, + { + "host": "*.okta.com", + "path_prefix": "/idp/myaccount/", + "methods": ["GET", "PUT"] + }, + { + "host": "*.okta.com", + "path_prefix": "/oauth2/v1/", + "methods": ["POST"] + }, + { + "host": "*.oktapreview.com", + "path_prefix": "/api/v1/", + "methods": ["GET", "POST", "PUT"] + }, + { + "host": "*.oktapreview.com", + "path_prefix": "/idp/myaccount/", + "methods": ["GET", "PUT"] + }, + { + "host": "*.oktapreview.com", + "path_prefix": "/oauth2/v1/", + "methods": ["POST"] + }, + { + "host": "*.okta-emea.com", + "path_prefix": "/api/v1/", + "methods": ["GET", "POST", "PUT"] + }, + { + "host": "*.okta-emea.com", + "path_prefix": "/idp/myaccount/", + "methods": ["GET", "PUT"] + }, + { + "host": "*.okta-emea.com", + "path_prefix": "/oauth2/v1/", + "methods": ["POST"] + } + ], + "credentials": { + "okta_oauth_token": { + "secret_name": "okta_oauth_token", + "location": { "type": "bearer" }, + "host_patterns": ["*.okta.com", "*.oktapreview.com", "*.okta-emea.com"] + } + }, + "rate_limit": { + "requests_per_minute": 30, + "requests_per_hour": 500 + }, + "timeout_secs": 30 + }, + "workspace": { + "allowed_prefixes": ["okta/"] + }, + "secrets": { + "allowed_names": ["okta_oauth_token"] + }, + "auth": { + "secret_name": "okta_oauth_token", + "display_name": "Okta", + "oauth": { + "authorization_url": "https://{okta_domain}/oauth2/v1/authorize", + "token_url": "https://{okta_domain}/oauth2/v1/token", + "client_id_env": "OKTA_OAUTH_CLIENT_ID", + "client_secret_env": "OKTA_OAUTH_CLIENT_SECRET", + "scopes": [ + "openid", + "profile", + "email", + "offline_access", + "okta.users.read.self", + "okta.users.manage.self", + "okta.apps.read" + ], + "use_pkce": true + }, + "instructions": "1. In your Okta Admin Console, go to Applications > Create App Integration\n2. Select 'OIDC - OpenID Connect', then 'Web Application'\n3. Set Sign-in redirect URI to http://localhost:9876/callback (through :9886)\n4. Under Okta API Scopes, grant: okta.users.read.self, okta.users.manage.self, okta.apps.read\n5. Copy the Client ID and Client Secret\n6. IMPORTANT: You must use the Org Authorization Server (not a custom one)\n7. Store your Okta domain in workspace at 'okta/domain' (e.g., 'mycompany.okta.com')\n8. For custom domains, add them to okta-tool.capabilities.json allowlist", + "setup_url": "https://developer.okta.com/docs/guides/implement-oauth-for-okta/main/", + "token_hint": "OAuth2 access token (JWT)", + "env_var": "OKTA_OAUTH_TOKEN" + } +} diff --git a/tools-src/okta/src/api.rs b/tools-src/okta/src/api.rs new file mode 100644 index 00000000..684947fd --- /dev/null +++ b/tools-src/okta/src/api.rs @@ -0,0 +1,281 @@ +use crate::near::agent::host; +use crate::types::*; + +const WORKSPACE_DOMAIN_PATH: &str = "okta/domain"; + +/// Read the configured Okta domain from workspace, or return a helpful error. +fn get_domain() -> Result { + host::workspace_read(WORKSPACE_DOMAIN_PATH).ok_or_else(|| { + "Okta domain not configured. Write your Okta domain to workspace path 'okta/domain' \ + using the memory_write tool (e.g., memory_write with path='okta/domain' and \ + content='mycompany.okta.com')." + .to_string() + }) +} + +/// Build the base URL for the Okta Management API. +fn management_base(domain: &str) -> String { + format!("https://{}/api/v1", domain) +} + +/// Make an Okta API call. +fn okta_api_call(method: &str, url: &str, body: Option<&str>) -> Result { + let headers = if body.is_some() { + r#"{"Content-Type": "application/json", "Accept": "application/json"}"# + } else { + r#"{"Accept": "application/json"}"# + }; + + let body_bytes = body.map(|b| b.as_bytes().to_vec()); + + host::log( + host::LogLevel::Debug, + &format!("Okta API: {} {}", method, url), + ); + + let response = host::http_request(method, url, headers, body_bytes.as_deref())?; + + if response.status < 200 || response.status >= 300 { + let body_text = String::from_utf8_lossy(&response.body); + // Try to extract Okta's error summary for a better message. + if let Ok(parsed) = serde_json::from_str::(&body_text) { + if let Some(summary) = parsed["errorSummary"].as_str() { + return Err(format!("Okta API error ({}): {}", response.status, summary)); + } + } + return Err(format!( + "Okta API returned status {}: {}", + response.status, body_text + )); + } + + String::from_utf8(response.body).map_err(|e| format!("Invalid UTF-8: {}", e)) +} + +// --------------------------------------------------------------------------- +// Action implementations +// --------------------------------------------------------------------------- + +/// GET /api/v1/users/me +pub fn get_profile() -> Result { + let domain = get_domain()?; + let url = format!("{}/users/me", management_base(&domain)); + let response = okta_api_call("GET", &url, None)?; + + let parsed: serde_json::Value = + serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?; + + let profile = parse_user_profile(&parsed)?; + serde_json::to_string(&profile).map_err(|e| e.to_string()) +} + +/// POST /api/v1/users/me (partial update via Management API) +pub fn update_profile(fields: &serde_json::Value) -> Result { + let domain = get_domain()?; + let url = format!("{}/users/me", management_base(&domain)); + + // Wrap fields under "profile" key for Okta's expected format. + let payload = serde_json::json!({ "profile": fields }); + let body = serde_json::to_string(&payload).map_err(|e| e.to_string())?; + + let response = okta_api_call("POST", &url, Some(&body))?; + + let parsed: serde_json::Value = + serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?; + + let profile = parse_user_profile(&parsed)?; + let result = UpdateProfileResult { + success: true, + profile, + }; + serde_json::to_string(&result).map_err(|e| e.to_string()) +} + +/// GET /api/v1/users/me/appLinks +pub fn list_apps() -> Result { + let domain = get_domain()?; + let url = format!("{}/users/me/appLinks", management_base(&domain)); + let response = okta_api_call("GET", &url, None)?; + + let parsed: serde_json::Value = + serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?; + + let apps = parse_app_links(&parsed)?; + let count = apps.len(); + let result = ListAppsResult { apps, count }; + serde_json::to_string(&result).map_err(|e| e.to_string()) +} + +/// Search apps by label (case-insensitive substring match). +pub fn search_apps(query: &str) -> Result { + let domain = get_domain()?; + let url = format!("{}/users/me/appLinks", management_base(&domain)); + let response = okta_api_call("GET", &url, None)?; + + let parsed: serde_json::Value = + serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?; + + let all_apps = parse_app_links(&parsed)?; + let query_lower = query.to_lowercase(); + + let apps: Vec = all_apps + .into_iter() + .filter(|app| { + app.label.to_lowercase().contains(&query_lower) + || app.app_name.to_lowercase().contains(&query_lower) + }) + .collect(); + + let count = apps.len(); + let result = ListAppsResult { apps, count }; + serde_json::to_string(&result).map_err(|e| e.to_string()) +} + +/// Find an app by ID or label and return its SSO launch link. +pub fn get_app_sso_link(app: &str) -> Result { + let domain = get_domain()?; + let url = format!("{}/users/me/appLinks", management_base(&domain)); + let response = okta_api_call("GET", &url, None)?; + + let parsed: serde_json::Value = + serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?; + + let all_apps = parse_app_links(&parsed)?; + let app_lower = app.to_lowercase(); + + // Try exact ID match first, then case-insensitive label match. + let found = all_apps + .iter() + .find(|a| a.app_instance_id == app) + .or_else(|| { + all_apps + .iter() + .find(|a| a.label.to_lowercase() == app_lower) + }) + .or_else(|| { + all_apps + .iter() + .find(|a| a.label.to_lowercase().contains(&app_lower)) + }); + + match found { + Some(app_link) => { + let result = AppSsoLinkResult { + label: app_link.label.clone(), + link_url: app_link.link_url.clone(), + app_instance_id: app_link.app_instance_id.clone(), + app_name: app_link.app_name.clone(), + }; + serde_json::to_string(&result).map_err(|e| e.to_string()) + } + None => { + let available: Vec = all_apps.iter().map(|a| a.label.clone()).collect(); + Err(format!( + "App '{}' not found. Available apps: {}", + app, + available.join(", ") + )) + } + } +} + +/// GET /idp/myaccount/organization +pub fn get_org_info() -> Result { + let domain = get_domain()?; + let url = format!("https://{}/idp/myaccount/organization", domain); + + // MyAccount API requires the okta-version header. + let response = okta_api_call_with_headers( + "GET", + &url, + None, + r#"{"Accept": "application/json; okta-version=1.0.0"}"#, + )?; + + let parsed: serde_json::Value = + serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?; + + let result = OrgInfo { + id: parsed["id"].as_str().unwrap_or("").to_string(), + name: parsed["name"].as_str().unwrap_or("").to_string(), + subdomain: parsed["subdomain"].as_str().map(|s| s.to_string()), + website: parsed["website"].as_str().map(|s| s.to_string()), + support_phone: parsed["supportPhoneNumber"].as_str().map(|s| s.to_string()), + technical_contact: parsed["technicalContact"].as_str().map(|s| s.to_string()), + }; + serde_json::to_string(&result).map_err(|e| e.to_string()) +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Like `okta_api_call` but with custom headers (for MyAccount API versioning). +fn okta_api_call_with_headers( + method: &str, + url: &str, + body: Option<&str>, + headers: &str, +) -> Result { + let body_bytes = body.map(|b| b.as_bytes().to_vec()); + + host::log( + host::LogLevel::Debug, + &format!("Okta API: {} {}", method, url), + ); + + let response = host::http_request(method, url, headers, body_bytes.as_deref())?; + + if response.status < 200 || response.status >= 300 { + let body_text = String::from_utf8_lossy(&response.body); + if let Ok(parsed) = serde_json::from_str::(&body_text) { + if let Some(summary) = parsed["errorSummary"].as_str() { + return Err(format!("Okta API error ({}): {}", response.status, summary)); + } + } + return Err(format!( + "Okta API returned status {}: {}", + response.status, body_text + )); + } + + String::from_utf8(response.body).map_err(|e| format!("Invalid UTF-8: {}", e)) +} + +fn parse_user_profile(v: &serde_json::Value) -> Result { + let p = &v["profile"]; + Ok(UserProfile { + id: v["id"].as_str().unwrap_or("").to_string(), + status: v["status"].as_str().unwrap_or("").to_string(), + first_name: p["firstName"].as_str().unwrap_or("").to_string(), + last_name: p["lastName"].as_str().unwrap_or("").to_string(), + email: p["email"].as_str().unwrap_or("").to_string(), + login: p["login"].as_str().unwrap_or("").to_string(), + mobile_phone: p["mobilePhone"].as_str().map(|s| s.to_string()), + display_name: p["displayName"].as_str().map(|s| s.to_string()), + nick_name: p["nickName"].as_str().map(|s| s.to_string()), + title: p["title"].as_str().map(|s| s.to_string()), + department: p["department"].as_str().map(|s| s.to_string()), + organization: p["organization"].as_str().map(|s| s.to_string()), + timezone: p["timezone"].as_str().map(|s| s.to_string()), + locale: p["locale"].as_str().map(|s| s.to_string()), + }) +} + +fn parse_app_links(v: &serde_json::Value) -> Result, String> { + let arr = v + .as_array() + .ok_or_else(|| "Expected array of app links from Okta".to_string())?; + + Ok(arr + .iter() + .map(|a| AppLink { + app_instance_id: a["appInstanceId"].as_str().unwrap_or("").to_string(), + label: a["label"].as_str().unwrap_or("").to_string(), + link_url: a["linkUrl"].as_str().unwrap_or("").to_string(), + logo_url: a["logoUrl"].as_str().map(|s| s.to_string()), + app_name: a["appName"].as_str().unwrap_or("").to_string(), + hidden: a["hidden"].as_bool().unwrap_or(false), + }) + .collect()) +} diff --git a/tools-src/okta/src/lib.rs b/tools-src/okta/src/lib.rs new file mode 100644 index 00000000..fed25f35 --- /dev/null +++ b/tools-src/okta/src/lib.rs @@ -0,0 +1,148 @@ +//! Okta WASM Tool for IronClaw. +//! +//! Provides user profile management, SSO app catalog browsing, and +//! launch links for all applications under Okta single sign-on. +//! +//! # Setup +//! +//! 1. Configure OAuth2 with PKCE (see capabilities.json instructions) +//! 2. Write your Okta domain to workspace: `memory_write(path="okta/domain", content="mycompany.okta.com")` +//! 3. All actions read the domain from workspace automatically +//! +//! # Capabilities Required +//! +//! - HTTP: `*.okta.com/api/v1/*`, `*.okta.com/idp/myaccount/*` (GET, POST, PUT) +//! - Secrets: `okta_oauth_token` (injected as Bearer token) +//! - Workspace: `okta/` prefix (read-only, for domain config) +//! +//! # Supported Actions +//! +//! - `get_profile`: Fetch the current user's profile +//! - `update_profile`: Update profile fields +//! - `list_apps`: List all SSO apps assigned to the user +//! - `search_apps`: Search apps by name +//! - `get_app_sso_link`: Get the SSO launch URL for a specific app +//! - `get_org_info`: Get organization details + +mod api; +mod types; + +use types::OktaAction; + +wit_bindgen::generate!({ + world: "sandboxed-tool", + path: "../../wit/tool.wit", +}); + +struct OktaTool; + +impl exports::near::agent::tool::Guest for OktaTool { + fn execute(req: exports::near::agent::tool::Request) -> exports::near::agent::tool::Response { + match execute_inner(&req.params) { + Ok(result) => exports::near::agent::tool::Response { + output: Some(result), + error: None, + }, + Err(e) => exports::near::agent::tool::Response { + output: None, + error: Some(e), + }, + } + } + + fn schema() -> String { + r#"{ + "type": "object", + "required": ["action"], + "oneOf": [ + { + "properties": { + "action": { "const": "get_profile" } + }, + "required": ["action"] + }, + { + "properties": { + "action": { "const": "update_profile" }, + "fields": { + "type": "object", + "description": "Profile fields to update. Common: firstName, lastName, email, mobilePhone, displayName, nickName, title, department, organization" + } + }, + "required": ["action", "fields"] + }, + { + "properties": { + "action": { "const": "list_apps" } + }, + "required": ["action"] + }, + { + "properties": { + "action": { "const": "search_apps" }, + "query": { + "type": "string", + "description": "Case-insensitive search query to match against app labels and names" + } + }, + "required": ["action", "query"] + }, + { + "properties": { + "action": { "const": "get_app_sso_link" }, + "app": { + "type": "string", + "description": "App instance ID (e.g., '0oa1xxx') or app label (e.g., 'Google Workspace')" + } + }, + "required": ["action", "app"] + }, + { + "properties": { + "action": { "const": "get_org_info" } + }, + "required": ["action"] + } + ] + }"# + .to_string() + } + + fn description() -> String { + "Okta SSO tool for managing your profile and accessing all applications under \ + single sign-on. Supports viewing/updating your Okta profile, listing all assigned \ + SSO apps, searching apps by name, and getting direct SSO launch links. Requires \ + Okta domain in workspace at 'okta/domain' and an OAuth token with \ + okta.users.read.self, okta.users.manage.self, and okta.apps.read scopes." + .to_string() + } +} + +fn execute_inner(params: &str) -> Result { + if !crate::near::agent::host::secret_exists("okta_oauth_token") { + return Err( + "Okta OAuth token not configured. Please add the 'okta_oauth_token' secret \ + via OAuth2 flow or set the OKTA_OAUTH_TOKEN environment variable." + .to_string(), + ); + } + + let action: OktaAction = + serde_json::from_str(params).map_err(|e| format!("Invalid parameters: {}", e))?; + + crate::near::agent::host::log( + crate::near::agent::host::LogLevel::Info, + &format!("Executing Okta action: {:?}", action), + ); + + match action { + OktaAction::GetProfile => api::get_profile(), + OktaAction::UpdateProfile { fields } => api::update_profile(&fields), + OktaAction::ListApps => api::list_apps(), + OktaAction::SearchApps { query } => api::search_apps(&query), + OktaAction::GetAppSsoLink { app } => api::get_app_sso_link(&app), + OktaAction::GetOrgInfo => api::get_org_info(), + } +} + +export!(OktaTool); diff --git a/tools-src/okta/src/types.rs b/tools-src/okta/src/types.rs new file mode 100644 index 00000000..63e40ab9 --- /dev/null +++ b/tools-src/okta/src/types.rs @@ -0,0 +1,119 @@ +use serde::{Deserialize, Serialize}; + +/// Input parameters for the Okta tool. +/// +/// Actions map to Okta Management API (/api/v1/) and MyAccount API (/idp/myaccount/). +/// The tool reads the Okta domain from workspace at `okta/domain`. +#[derive(Debug, Deserialize)] +#[serde(tag = "action", rename_all = "snake_case")] +pub enum OktaAction { + /// Get the current user's Okta profile. + GetProfile, + + /// Update fields on the current user's profile (partial update). + UpdateProfile { + /// Key-value pairs of profile fields to update. + /// Common fields: firstName, lastName, email, mobilePhone, displayName, + /// nickName, title, department, organization. + fields: serde_json::Value, + }, + + /// List all SSO applications assigned to the current user. + ListApps, + + /// Search assigned apps by name (case-insensitive substring match). + SearchApps { + /// Search query to match against app labels. + query: String, + }, + + /// Get the SSO launch link for a specific app by its instance ID or label. + GetAppSsoLink { + /// App instance ID (e.g., "0oa1xxx") or app label to search for. + app: String, + }, + + /// Get information about the Okta organization. + GetOrgInfo, +} + +// --------------------------------------------------------------------------- +// Response types +// --------------------------------------------------------------------------- + +/// User profile from Okta. +#[derive(Debug, Serialize)] +pub struct UserProfile { + pub id: String, + pub status: String, + pub first_name: String, + pub last_name: String, + pub email: String, + pub login: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub mobile_phone: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub display_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub nick_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub department: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub organization: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub timezone: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub locale: Option, +} + +/// Result of a profile update. +#[derive(Debug, Serialize)] +pub struct UpdateProfileResult { + pub success: bool, + pub profile: UserProfile, +} + +/// An SSO app link (chiclet) assigned to the user. +#[derive(Debug, Serialize)] +pub struct AppLink { + pub app_instance_id: String, + pub label: String, + pub link_url: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub logo_url: Option, + pub app_name: String, + pub hidden: bool, +} + +/// Result of listing or searching apps. +#[derive(Debug, Serialize)] +pub struct ListAppsResult { + pub apps: Vec, + pub count: usize, +} + +/// SSO launch link for a specific app. +#[derive(Debug, Serialize)] +pub struct AppSsoLinkResult { + pub label: String, + pub link_url: String, + pub app_instance_id: String, + pub app_name: String, +} + +/// Okta organization info. +#[derive(Debug, Serialize)] +pub struct OrgInfo { + pub id: String, + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub subdomain: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub website: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub support_phone: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub technical_contact: Option, +} From ced83d5b4de5ec5da8d873ab04d00dd392b8c382 Mon Sep 17 00:00:00 2001 From: Illia Polosukhin Date: Wed, 11 Feb 2026 00:31:25 -0800 Subject: [PATCH 03/33] feat: Sandbox jobs (#4) * Orchestrating jobs and running them in sandboxes * Fix heartbeat: dynamic max_tokens, empty content guard, notification fallback - Query /v1/models API for context_length and set max_tokens to half (floor 4096) instead of hardcoded 1024; reasoning models like GLM-4.7 need much larger budgets - Guard against empty LLM content (reasoning models can burn all tokens on chain-of-thought and return content: null) - Simplify notification routing: try configured channel first, fall back to broadcast_all so heartbeat alerts always reach someone - Add ModelMetadata struct and model_metadata() to LlmProvider trait - Refactor NearAiChatProvider::list_models into shared fetch_models() - Add standalone test_heartbeat example for isolated debugging Co-Authored-By: Claude Opus 4.6 * Add job detail view with drill-down from jobs list Click a job row to see full details across four sub-tabs: Overview (metadata grid, description, state transitions timeline), Actions (expandable tool call cards with input/output JSON), Thinking (conversation messages styled by role), and Files (embedded workspace tree browser). Co-Authored-By: Claude Opus 4.6 * Strip model-internal XML tags from LLM responses, fix Telegram parse_mode 400 Some models (GLM-4.7, etc.) emit tool_list in the content field instead of using the OpenAI tool_calls array. This XML leaks through to channels as text, and Telegram's Markdown parser chokes on the underscores, returning 400 "can't parse entities". Two fixes: - Generalize clean_response() to strip , , , and pipe-delimited variants (<|tool_call|>) alongside the existing tag stripping - Add Telegram send_message helper with parse_mode fallback: try Markdown first, retry as plain text on "can't parse entities" 400 errors Co-Authored-By: Claude Opus 4.6 * Add SystemCommand submission type for thread-state-independent commands System commands (/help, /model, /version, /tools, /ping, /debug) now bypass thread-state checks and safety validation via a dedicated Submission::SystemCommand variant. Previously these flowed through process_user_input() which blocked them during Processing/AwaitingApproval /Completed states. - Add /model [name] for runtime model switching with provider validation - Add active_model_name()/set_model() to LlmProvider trait with RwLock hot-swap in both NEAR AI providers - Rewrite /help with aligned columns grouped by category - Expand REPL tab-completion from 10 to 23 slash commands - Remove REPL-local /help interception (now handled by agent) Co-Authored-By: Claude Opus 4.6 * Add per-tool execution timeouts, auto-create sandbox project dirs, serve built files The sandbox e2e pipeline (agent -> container -> built website -> browsable URL) was broken by three gaps: hardcoded 60s timeouts killed sandbox jobs that need minutes, no auto-created project directory meant container output vanished, and no HTTP route to browse the built files. - Add `execution_timeout()` to the `Tool` trait (default 60s), replace all four hardcoded `Duration::from_secs(60)` call sites (agent_loop, worker, scheduler, worker/runtime) with the per-tool value - Override to 660s in `RunInSandboxTool` (10 min polling + 60s buffer) - Auto-create `~/.ironclaw/projects/{uuid}/` when no `project_dir` is specified, so every sandbox job gets a persistent bind mount - Include `project_dir` and `browse_url` in sandbox tool output JSON - Add `/projects/{id}` and `/projects/{id}/{path}` static file serving routes to the web gateway with path traversal protection and MIME type detection - Add `mime_guess` dependency for content-type detection Co-Authored-By: Claude Opus 4.6 * Apply cargo fmt to wizard.rs after merge Co-Authored-By: Claude Opus 4.6 * Persist sandbox jobs in DB, fix web UI, unify job model Sandbox container jobs were invisible to the web UI because they lived only in ContainerJobManager's in-memory HashMap while the API queried ContextManager. This persists them to the agent_jobs table and fixes all six front-end bugs (empty job list, broken back button, empty actions/thinking tabs, wrong files tab, stuck status, no persistence). Key changes: - V4 migration adds project_dir and user_id columns to agent_jobs - Embedded migrations via refinery (no external CLI needed) - SandboxJobRecord CRUD in Store with fire-and-forget DB writes - Unified job_id: sandbox tool generates UUID, passes to ContainerJobManager - Web API queries DB for sandbox jobs, merges with ContextManager direct jobs - New endpoints: restart, project file list/read with path traversal protection - Front-end: rebuild DOM on back navigation, sandbox-aware tabs, job cards in chat stream, source badges, restart button for failed/interrupted jobs - Gateway defaults to enabled, prints Web UI URL on startup - Stale jobs marked "interrupted" on restart for visibility and restartability Co-Authored-By: Claude Opus 4.6 * Secure in-chat auth: tokens never touch the LLM or chat history Remove the token parameter from tool_auth so the LLM cannot pass raw API keys. Add dedicated REST (POST /api/chat/auth-token) and WebSocket (auth_token) endpoints that route tokens directly to ext_mgr.auth(), completely bypassing the message pipeline, turns, history, and compaction. Web UI shows an auth card (password input + OAuth button) when the agent enters auth mode, submitted via the dedicated endpoint. CLI auth mode interception is unchanged (already secure). New StatusUpdate::AuthRequired/AuthCompleted variants propagate through all channels (SSE, WebSocket, REPL, WASM). Co-Authored-By: Claude Opus 4.6 * feat: Add Claude Code mode for sandbox jobs Run Claude Code CLI inside Docker containers as an alternative to the standard worker mode. The bridge spawns `claude -p` with stream-json output, posts events to the orchestrator, and supports follow-up prompts via `--resume`. Key additions: - `claude-bridge` CLI subcommand and ClaudeBridgeRuntime - JobMode enum (Worker vs ClaudeCode) with per-mode container config - Orchestrator endpoints for Claude events and prompt polling - SSE event variants for real-time Claude Code streaming to frontend - Claude Code sub-tab in web UI with terminal-style output and input bar - Database migration for job_mode column and claude_code_events table - ClaudeCodeConfig with env var support (CLAUDE_CODE_ENABLED, etc.) - Mode parameter on run_in_sandbox tool schema Co-Authored-By: Claude Opus 4.6 * fix: Skip create_job tool when sandbox is enabled to prevent duplicate jobs When sandbox mode is on, the LLM would call create_job (creating a pending "direct" entry) then run_in_sandbox (creating a second "sandbox" entry), producing two jobs in the list for a single user request. Now register_job_tools() skips create_job when sandbox is enabled since run_in_sandbox already creates tracked jobs. Also improved the run_in_sandbox description to guide the LLM to use it directly and to mention wait=false for async execution. Co-Authored-By: Claude Opus 4.6 * feat: Web gateway UI quality-of-life improvements Phase 1: Send button disabled state to prevent double-sends, copy button on code blocks, confirm() guards on destructive actions, SSE-driven job list auto-refresh, log filters re-applied on tab switch, jobEvents memory leak fix (cap at 500, cleanup after 60s). Phase 2: Toast notification system replacing chat-based system messages, memory search highlighting with centered snippets, keyboard shortcuts (Ctrl+1-5 tabs, Ctrl+K focus, Ctrl+N new thread, Escape close/blur), activity tab toolbar with event type filter and auto-scroll toggle. Phase 3: Thread sidebar with load/switch/create, thread_id passed with messages, collapsible to hamburger. Memory inline editing with textarea, Save/Cancel, POST to /api/memory/write. Phase 4: Gateway status popover on hover (polls every 30s), extension install form (name/URL/kind), markdown rendering in memory viewer for .md files, mobile responsive layout at 768px breakpoint. Co-Authored-By: Claude Opus 4.6 * feat: Add routines system, remove non-sandbox job mode from web UI Routines: scheduled & reactive job system with cron and event triggers, lightweight (single LLM call) and full-job execution modes, guardrails (cooldown, max concurrent, dedup), and LLM-facing tools for CRUD. Web UI: remove ContextManager-backed "direct" job mode entirely. Jobs are now exclusively sandbox-backed (DB + container). Simplify job detail response, drop dead types (ActionInfo, MessageInfo, MessageToolCallInfo), fix Browse Files CSS loading (trailing-slash redirect), fix Activity tab event rendering. Co-Authored-By: Claude Opus 4.6 * fix: Re-enable chat input on agent completion, auto-auth on tool_activate, recover tool calls from content XML Three fixes: 1. Chat input stays disabled after agent finishes: the "Done" status SSE event now calls enableChatInput() as a safety net when the response event is empty or lost. Same for auth_completed and cancelAuth(). 2. tool_activate never triggers auth: when activation fails due to missing authentication, it now auto-initiates the auth flow (same pattern as the web API handler). detect_auth_awaiting() also matches tool_activate results now. 3. Models like GLM-4.7 emit tool calls as XML tags in content (tool_list) instead of using the structured tool_calls array. recover_tool_calls_from_content() extracts and validates these before falling back to plain text. Co-Authored-By: Claude Opus 4.6 * feat: Add routines web UI tab, update docs for sandbox-jobs branch Add full routines management to the web gateway (list, detail, trigger, toggle, delete) with 7 new API endpoints, response types, and frontend (HTML, JS, CSS). Update FEATURE_PARITY.md (~23 rows), CLAUDE.md (new subsystems, config, TODOs), and README.md (architecture diagram, features, components, fix onboard command). Co-Authored-By: Claude Opus 4.6 * fix: Bind Telegram bot to owner account during setup Without owner binding, anyone who discovers the bot can send it messages. The setup wizard now prompts the user to message their bot, captures their Telegram user ID via getUpdates, and persists it as telegram_owner_id in settings. On startup, the owner_id is injected into the WASM channel config so the existing owner restriction logic drops messages from non-owners. Co-Authored-By: Claude Opus 4.6 * feat: Move settings from disk to PostgreSQL database Settings previously lived in three JSON files on disk (settings.json, mcp-servers.json, session.json). This made them inaccessible from the web UI and caused redundant disk reads (Settings::load() called 8+ times during startup). Now all settings live in a `settings` table (user_id + key -> JSONB) with only 4 bootstrap fields remaining on disk (database_url, pool size, secrets key source, onboard_completed) since they're needed before the DB connection exists. - Add V8 migration for settings table - Add BootstrapConfig (thin disk file) and Settings DB round-trip - Add Store CRUD methods for settings (get/set/delete/list/bulk) - Refactor Config to load from DB (env > DB > default cascade) - Add SessionManager DB persistence for session tokens - Add DB-backed MCP server config load/save functions - Add 6 settings web API endpoints (list/get/set/delete/export/import) - Add one-time disk-to-DB migration on first boot - Make CLI config commands async with DB access (disk fallback) Co-Authored-By: Claude Opus 4.6 * feat: Seed workspace on boot, fix gateway duplicate logs and URL auto-auth - Add Workspace::seed_if_empty() to create core identity files (README, MEMORY, IDENTITY, SOUL, AGENTS, USER, HEARTBEAT) when missing, called on every boot without overwriting existing user edits - Remove duplicate gateway log lines from web/mod.rs (main.rs has the useful clickable ?token= URL) - Auto-authenticate from ?token= URL parameter in the web UI and strip the token from the address bar after successful auth Co-Authored-By: Claude Opus 4.6 * fix: Harden sandbox security (path traversal + orchestrator auth) Two vulnerabilities fixed: 1. project_dir path traversal: The create_job tool let the LLM specify arbitrary host paths for Docker bind mounts. Removed project_dir from the tool schema entirely, and added canonicalization + prefix validation at both resolve_project_dir() and the job_manager bind mount point. 2. Orchestrator API auth bypass: worker_auth_middleware was defined but never applied. Each handler manually called validate_token(), so any new endpoint that forgot would be publicly accessible. Applied the middleware as route_layer on all /worker/ routes, removed manual auth from all 7 handlers. Bind to 127.0.0.1 on macOS/Windows (Linux keeps 0.0.0.0 since containers reach host via docker bridge, not loopback). Co-Authored-By: Claude Opus 4.6 * feat: Rework gateway chat with pinned assistant, pagination, and NEAR AI response chaining Implements the 4-phase plan for overhauling the web gateway chat: - Phase 1: Pinned "Assistant" thread at top of sidebar, regular threads below - Phase 2: Cursor-based history pagination with infinite scroll - Phase 3: NEAR AI previous_response_id chaining (delta-only messages), with fallback to full history on chain errors, and DB persistence of chain state across restarts - Phase 4: SSE thread isolation (events filtered by thread_id) Co-Authored-By: Claude Opus 4.6 * fix: Add per-request HTTP timeout to WASM host, redact credentials in errors Three fixes for WASM channel reliability: 1. Per-request timeout: Add optional timeout-ms parameter to http-request in both channel and tool WIT interfaces. Telegram long-poll now specifies 35s (outliving the 30s server-side hold), while regular API calls use the 30s default. Fixes the triple-30s timeout race that caused polling failures. 2. Credential redaction: reqwest::Error includes the full URL (with injected bot tokens) in its Display output. Scrub credential values from error messages before logging or returning to WASM. 3. Webhook route registration: Remove tunnel URL gate so webhook routes are always available when webhook channels exist, not only when TUNNEL_URL is configured. Co-Authored-By: Claude Opus 4.6 * chore: Fix clippy warnings in WASM tools and channels - slack channel: allow dead_code on signing_secret_name (forward compat field) - gmail tool: use div_ceil() instead of manual (n+2)/3 - google-calendar tool: extract CreateEventParams/UpdateEventParams structs to fix too-many-arguments warnings Co-Authored-By: Claude Opus 4.6 * Fix approval flow * fix: Rebuild bundled telegram.wasm with updated WIT interface The bundled WASM binary must match the host's WIT definition. Previous binary was compiled against the old 4-arg http-request; this rebuild includes the new timeout-ms parameter. Co-Authored-By: Claude Opus 4.6 * refactor: Load WASM channels from disk instead of bundling in binary Remove include_bytes! embedding of telegram.wasm. Channels are now loaded from their build output directories (channels-src//target/) during onboarding, then from ~/.ironclaw/channels/ at runtime. - bundled.rs: locate_channel_artifacts() finds WASM + capabilities from build output; IRONCLAW_CHANNELS_SRC env var overrides the default path - available_channel_names(): only lists channels with build artifacts - bundled_channel_names(): lists all known channels (manifest) - Setup wizard uses available_channel_names() to offer installable channels - Add *.wasm to .gitignore, remove tracked telegram.wasm Co-Authored-By: Claude Opus 4.6 * fix: Persist gateway auth token, fix thread hydration race, polish auth screen Three web gateway UX fixes: 1. Token persistence: Store auth token in sessionStorage so refreshing the page doesn't force re-authentication. Hide the auth screen immediately when a saved token exists to prevent flash. 2. Thread hydration: Remove the !msgs.is_empty() bail-out in maybe_hydrate_thread so that even brand-new (empty) assistant threads get hydrated with their correct DB UUID. Previously resolve_thread would mint a fresh UUID, causing messages to land in the wrong conversation and duplicate threads to appear. 3. Auth screen: Redesign as a centered card with brand, tagline, labeled input, and hint text. Also adds 34 new tests covering session/thread lifecycle, thread resolution isolation (user, channel, external ID), hydration edge cases, serialization round-trips, approval flows, and stale mapping recovery. Co-Authored-By: Claude Opus 4.6 * fix: Use bindgen! for WASM tool wrapper, add dev tool loading Three changes: 1. Rewrite src/tools/wasm/wrapper.rs to use wasmtime::component::bindgen! instead of manual linker.root().func_wrap(). This fixes the "component imports instance 'near:agent/host', but a matching implementation was not found in the linker" error. All 6 host functions (log, now-millis, workspace-read, http-request, secret-exists, tool-invoke) are now properly registered under the near:agent/host namespace. Also adds WASI support, credential injection, and leak detection for HTTP requests made by WASM tools. 2. Add dev tool loading to src/tools/wasm/loader.rs. During startup, the loader now also scans tools-src/*/target/wasm32-wasip2/release/ for build artifacts that are newer than installed copies. This means during development you just rebuild the WASM and restart the host; no manual copy step needed. Set IRONCLAW_TOOLS_SRC to override the source dir. 3. Wire up load_dev_tools() in main.rs alongside the existing load_from_dir() call. Co-Authored-By: Claude Opus 4.6 * feat: Wire main startup and CLI to use DB-backed settings main.rs now reloads Config from the database after connecting, attaches the store to the session manager for dual-write tokens, and loads MCP servers from DB instead of disk. ExtensionManager and MCP CLI commands use DB when available with disk fallback. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- .dockerignore | 8 + .gitignore | 3 + CLAUDE.md | 89 +- Cargo.lock | 45 + Cargo.toml | 4 + Dockerfile.worker | 63 + FEATURE_PARITY.md | 65 +- README.md | 75 +- channels-src/slack/src/lib.rs | 46 +- channels-src/telegram/src/lib.rs | 186 ++- .../telegram/telegram.capabilities.json | 1 + channels-src/telegram/telegram.wasm | Bin 246930 -> 0 bytes channels-src/whatsapp/src/lib.rs | 1 + examples/test_heartbeat.rs | 120 ++ migrations/V4__sandbox_columns.sql | 10 + migrations/V5__claude_code.sql | 14 + migrations/V6__routines.sql | 73 + migrations/V7__rename_events.sql | 3 + migrations/V8__settings.sql | 16 + src/agent/agent_loop.rs | 756 +++++++-- src/agent/heartbeat.rs | 37 +- src/agent/mod.rs | 5 + src/agent/routine.rs | 509 ++++++ src/agent/routine_engine.rs | 606 +++++++ src/agent/scheduler.rs | 34 +- src/agent/session.rs | 404 +++++ src/agent/session_manager.rs | 375 +++++ src/agent/submission.rs | 131 ++ src/agent/worker.rs | 56 +- src/bootstrap.rs | 325 ++++ src/channels/channel.rs | 19 + src/channels/repl.rs | 54 +- src/channels/wasm/bundled.rs | 157 +- src/channels/wasm/mod.rs | 2 +- src/channels/wasm/wrapper.rs | 140 +- src/channels/web/mod.rs | 122 +- src/channels/web/server.rs | 1429 ++++++++++++++-- src/channels/web/sse.rs | 14 +- src/channels/web/static/app.js | 1397 +++++++++++++++- src/channels/web/static/index.html | 89 +- src/channels/web/static/style.css | 1469 ++++++++++++++++- src/channels/web/types.rs | 422 ++++- src/channels/web/ws.rs | 76 +- src/cli/config.rs | 173 +- src/cli/mcp.rs | 71 +- src/cli/mod.rs | 36 + src/config.rs | 340 ++-- src/error.rs | 53 + src/extensions/manager.rs | 72 +- src/history/mod.rs | 5 +- src/history/store.rs | 1123 ++++++++++++- src/lib.rs | 3 + src/llm/mod.rs | 4 +- src/llm/nearai.rs | 328 +++- src/llm/nearai_chat.rs | 87 +- src/llm/provider.rs | 75 +- src/llm/reasoning.rs | 382 ++++- src/llm/session.rs | 79 +- src/main.rs | 310 +++- src/orchestrator/api.rs | 511 ++++++ src/orchestrator/auth.rs | 162 ++ src/orchestrator/job_manager.rs | 474 ++++++ src/orchestrator/mod.rs | 37 + src/sandbox/container.rs | 31 +- src/settings.rs | 101 ++ src/setup/channels.rs | 161 +- src/setup/wizard.rs | 32 +- src/tools/builder/core.rs | 13 +- src/tools/builtin/extension_tools.rs | 73 +- src/tools/builtin/file.rs | 18 +- src/tools/builtin/job.rs | 580 ++++++- src/tools/builtin/mod.rs | 4 + src/tools/builtin/routine.rs | 654 ++++++++ src/tools/builtin/shell.rs | 6 +- src/tools/mcp/config.rs | 80 + src/tools/mod.rs | 2 +- src/tools/registry.rs | 83 +- src/tools/tool.rs | 35 + src/tools/wasm/loader.rs | 188 +++ src/tools/wasm/mod.rs | 5 +- src/tools/wasm/wrapper.rs | 460 ++++-- src/worker/api.rs | 400 +++++ src/worker/claude_bridge.rs | 644 ++++++++ src/worker/mod.rs | 35 + src/worker/proxy_llm.rs | 95 ++ src/worker/runtime.rs | 491 ++++++ src/workspace/mod.rs | 91 + tests/ws_gateway_integration.rs | 8 +- tools-src/gmail/src/api.rs | 4 +- tools-src/google-calendar/src/api.rs | 107 +- tools-src/google-calendar/src/lib.rs | 50 +- tools-src/google-docs/src/api.rs | 2 +- tools-src/google-drive/src/api.rs | 6 +- tools-src/google-sheets/src/api.rs | 2 +- tools-src/google-slides/src/api.rs | 2 +- tools-src/slack/src/api.rs | 2 +- tools-src/telegram/src/transport.rs | 2 +- wit/channel.wit | 8 +- wit/tool.wit | 7 +- 99 files changed, 17095 insertions(+), 1162 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile.worker delete mode 100644 channels-src/telegram/telegram.wasm create mode 100644 examples/test_heartbeat.rs create mode 100644 migrations/V4__sandbox_columns.sql create mode 100644 migrations/V5__claude_code.sql create mode 100644 migrations/V6__routines.sql create mode 100644 migrations/V7__rename_events.sql create mode 100644 migrations/V8__settings.sql create mode 100644 src/agent/routine.rs create mode 100644 src/agent/routine_engine.rs create mode 100644 src/bootstrap.rs create mode 100644 src/orchestrator/api.rs create mode 100644 src/orchestrator/auth.rs create mode 100644 src/orchestrator/job_manager.rs create mode 100644 src/orchestrator/mod.rs create mode 100644 src/tools/builtin/routine.rs create mode 100644 src/worker/api.rs create mode 100644 src/worker/claude_bridge.rs create mode 100644 src/worker/mod.rs create mode 100644 src/worker/proxy_llm.rs create mode 100644 src/worker/runtime.rs diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..32b9468c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +target/ +.git/ +.env +.env.* +*.md +!CLAUDE.md +node_modules/ +tools-src/ diff --git a/.gitignore b/.gitignore index e9846352..0f80f04c 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,6 @@ target/ +# WASM build artifacts (loaded from disk, not bundled) +*.wasm + diff --git a/CLAUDE.md b/CLAUDE.md index 2064847a..3d3bd4a3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,8 +11,13 @@ - **Always available** - Multi-channel access with proactive background execution ### Features -- **Multi-channel input**: TUI (Ratatui), HTTP webhooks, Telegram, WhatsApp, Slack (WASM channels) +- **Multi-channel input**: TUI (Ratatui), HTTP webhooks, WASM channels (Telegram, Slack), web gateway - **Parallel job execution** with state machine and self-repair for stuck jobs +- **Sandbox execution**: Docker container isolation with orchestrator/worker pattern +- **Claude Code mode**: Delegate jobs to Claude CLI inside containers +- **Routines**: Scheduled (cron) and reactive (event, webhook) task execution +- **Web gateway**: Browser UI with SSE/WebSocket real-time streaming +- **Extension management**: Install, auth, activate MCP/WASM extensions - **Extensible tools**: Built-in tools, WASM sandbox, MCP client, dynamic builder - **Persistent memory**: Workspace with hybrid search (FTS + vector via RRF) - **Prompt injection defense**: Sanitizer, validator, policy rules, leak detection @@ -59,7 +64,9 @@ src/ │ ├── context_monitor.rs # Memory pressure detection │ ├── undo.rs # Turn-based undo/redo with checkpoints │ ├── submission.rs # Submission parsing (undo, redo, compact, clear, etc.) -│ └── task.rs # Sub-task execution framework +│ ├── task.rs # Sub-task execution framework +│ ├── routine.rs # Routine types (Trigger, Action, Guardrails) +│ └── routine_engine.rs # Routine execution (cron ticker, event matcher) │ ├── channels/ # Multi-channel input │ ├── channel.rs # Channel trait, IncomingMessage, OutgoingResponse @@ -72,8 +79,33 @@ src/ │ │ ├── overlay.rs # Approval overlays │ │ └── composer.rs # Message composition │ ├── http.rs # HTTP webhook (axum) with secret validation -│ ├── slack.rs # Stub -│ └── telegram.rs # Stub +│ ├── repl.rs # Simple REPL (for testing) +│ ├── web/ # Web gateway (browser UI) +│ │ ├── mod.rs # Gateway builder, startup +│ │ ├── server.rs # Axum router, 40+ API endpoints +│ │ ├── sse.rs # SSE broadcast manager +│ │ ├── ws.rs # WebSocket gateway + connection tracking +│ │ ├── types.rs # Request/response types, SseEvent enum +│ │ ├── auth.rs # Bearer token auth middleware +│ │ ├── log_layer.rs # Tracing layer for log streaming +│ │ └── static/ # HTML, CSS, JS (single-page app) +│ └── wasm/ # WASM channel runtime +│ ├── mod.rs +│ ├── bundled.rs # Bundled channel discovery +│ └── wrapper.rs # Channel trait wrapper for WASM modules +│ +├── orchestrator/ # Internal HTTP API for sandbox containers +│ ├── mod.rs +│ ├── api.rs # Axum endpoints (LLM proxy, events, prompts) +│ ├── auth.rs # Per-job bearer token store +│ └── job_manager.rs # Container lifecycle (create, stop, cleanup) +│ +├── worker/ # Runs inside Docker containers +│ ├── mod.rs +│ ├── runtime.rs # Worker execution loop (tool calls, LLM) +│ ├── claude_bridge.rs # Claude Code bridge (spawns claude CLI) +│ ├── api.rs # HTTP client to orchestrator +│ └── proxy_llm.rs # LlmProvider that proxies through orchestrator │ ├── safety/ # Prompt injection defense │ ├── sanitizer.rs # Pattern detection, content escaping @@ -96,6 +128,9 @@ src/ │ │ ├── file.rs # ReadFile, WriteFile, ListDir, ApplyPatch │ │ ├── shell.rs # Shell command execution │ │ ├── memory.rs # Memory tools (search, write, read, tree) +│ │ ├── job.rs # CreateJob, ListJobs, JobStatus, CancelJob +│ │ ├── routine.rs # routine_create/list/update/delete/history +│ │ ├── extension_tools.rs # Extension install/auth/activate/remove │ │ └── marketplace.rs, ecommerce.rs, taskrabbit.rs, restaurant.rs (stubs) │ ├── builder/ # Dynamic tool building │ │ ├── core.rs # BuildRequirement, SoftwareType, Language @@ -236,6 +271,30 @@ HEARTBEAT_ENABLED=true HEARTBEAT_INTERVAL_SECS=1800 # 30 minutes HEARTBEAT_NOTIFY_CHANNEL=tui HEARTBEAT_NOTIFY_USER=default + +# Web gateway +GATEWAY_ENABLED=true +GATEWAY_HOST=127.0.0.1 +GATEWAY_PORT=3001 +GATEWAY_AUTH_TOKEN=changeme # Required for API access +GATEWAY_USER_ID=default + +# Docker sandbox +SANDBOX_ENABLED=true +SANDBOX_IMAGE=ironclaw-worker:latest +SANDBOX_MEMORY_LIMIT_MB=512 +SANDBOX_TIMEOUT_SECS=1800 + +# Claude Code mode (runs inside sandbox containers) +CLAUDE_CODE_ENABLED=false +CLAUDE_CODE_MODEL=claude-sonnet-4-20250514 +CLAUDE_CODE_MAX_TURNS=50 +CLAUDE_CODE_CONFIG_DIR=/home/worker/.claude + +# Routines (scheduled/reactive execution) +ROUTINES_ENABLED=true +ROUTINES_CRON_INTERVAL=60 # Tick interval in seconds +ROUTINES_MAX_CONCURRENT=3 ``` ### NEAR AI Provider @@ -297,13 +356,14 @@ Key test patterns: ## Current Limitations / TODOs -1. **Slack/Telegram channels** - Stubs only, need implementation -2. **Domain-specific tools** - `marketplace.rs`, `restaurant.rs`, `taskrabbit.rs`, `ecommerce.rs` return placeholder responses; need real API integrations -3. **Integration tests** - Need testcontainers setup for PostgreSQL -4. **MCP stdio transport** - Only HTTP transport implemented -5. **WIT bindgen integration** - Auto-extract tool description/schema from WASM modules (stubbed) -6. **Capability granting after tool build** - Built tools get empty capabilities; need UX for granting HTTP/secrets access -7. **Tool versioning workflow** - No version tracking or rollback for dynamically built tools +1. **Domain-specific tools** - `marketplace.rs`, `restaurant.rs`, `taskrabbit.rs`, `ecommerce.rs` return placeholder responses; need real API integrations +2. **Integration tests** - Need testcontainers setup for PostgreSQL +3. **MCP stdio transport** - Only HTTP transport implemented +4. **WIT bindgen integration** - Auto-extract tool description/schema from WASM modules (stubbed) +5. **Capability granting after tool build** - Built tools get empty capabilities; need UX for granting HTTP/secrets access +6. **Tool versioning workflow** - No version tracking or rollback for dynamically built tools +7. **Webhook trigger endpoint** - Routines webhook trigger not yet exposed in web gateway +8. **Full channel status view** - Gateway status widget exists, but no per-channel connection dashboard ### Completed @@ -320,6 +380,13 @@ Key test patterns: - ✅ **Tool approval enforcement** - Tools with `requires_approval()` (shell, http, file write/patch, build_software) now gate execution, track auto-approved tools per session - ✅ **Tool definition refresh** - Tool definitions refreshed each iteration so newly built tools become visible in same session - ✅ **Worker tool call handling** - Uses `respond_with_tools()` to properly execute tool calls when `select_tools()` returns empty +- ✅ **Gateway control plane** - Web gateway with 40+ API endpoints, SSE/WebSocket +- ✅ **Web Control UI** - Browser-based dashboard with chat, memory, jobs, logs, extensions, routines +- ✅ **Slack/Telegram channels** - Implemented as WASM tools +- ✅ **Docker sandbox** - Orchestrator/worker containers with per-job auth +- ✅ **Claude Code mode** - Delegate jobs to Claude CLI inside containers +- ✅ **Routines system** - Cron, event, webhook, and manual triggers with guardrails +- ✅ **Extension management** - Install, auth, activate MCP/WASM extensions via CLI and web UI ## Adding a New Tool diff --git a/Cargo.lock b/Cargo.lock index 85dfeab3..0fe4dc8b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -996,6 +996,17 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "cron" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eee8b2b4516038bc0f1d3c9934bcb4a13dd316e04abbc63c96757a6d75978532" +dependencies = [ + "chrono", + "nom", + "once_cell", +] + [[package]] name = "crossbeam" version = "0.8.4" @@ -2189,6 +2200,7 @@ dependencies = [ "bytes", "chrono", "clap", + "cron", "crossterm 0.28.1", "deadpool-postgres", "dirs 6.0.0", @@ -2198,6 +2210,7 @@ dependencies = [ "http-body-util", "hyper", "hyper-util", + "mime_guess", "open", "pgvector", "postgres-types", @@ -2495,6 +2508,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "minimad" version = "0.14.0" @@ -2504,6 +2527,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "mio" version = "1.1.1" @@ -2550,6 +2579,16 @@ dependencies = [ "libc", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -4673,6 +4712,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-bidi" version = "0.3.18" diff --git a/Cargo.toml b/Cargo.toml index 6fc39003..26a449fd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -58,6 +58,9 @@ axum = { version = "0.8", features = ["ws"] } tower = "0.5" tower-http = { version = "0.6", features = ["trace", "cors"] } +# Cron scheduling for routines +cron = "0.13" + # Safety/sanitization regex = "1" aho-corasick = "1" @@ -99,6 +102,7 @@ hyper-util = { version = "0.1", features = ["server", "tokio", "http1", "http2"] http-body-util = "0.1" bytes = "1" base64 = "0.22.1" +mime_guess = "2.0.5" # macOS keychain [target.'cfg(target_os = "macos")'.dependencies] diff --git a/Dockerfile.worker b/Dockerfile.worker new file mode 100644 index 00000000..3909abaa --- /dev/null +++ b/Dockerfile.worker @@ -0,0 +1,63 @@ +# Multi-stage Dockerfile for the IronClaw worker container. +# +# This image runs the ironclaw binary in worker mode inside Docker containers. +# The orchestrator creates instances of this image for sandboxed job execution. +# +# Build: +# docker build -f Dockerfile.worker -t ironclaw-worker . +# +# The image includes common development tools so workers can build software, +# run tests, and execute shell commands. + +FROM rust:1.85-bookworm AS builder + +WORKDIR /build +COPY . . + +# Build only the ironclaw binary (release mode) +RUN cargo build --release --bin ironclaw + +# --- + +FROM debian:bookworm-slim + +# Install common development tools +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + git \ + build-essential \ + pkg-config \ + libssl-dev \ + nodejs \ + npm \ + python3 \ + python3-pip \ + python3-venv \ + && rm -rf /var/lib/apt/lists/* + +# Install Rust toolchain for the sandbox user +ENV RUSTUP_HOME=/usr/local/rustup \ + CARGO_HOME=/usr/local/cargo \ + PATH=/usr/local/cargo/bin:$PATH +RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain 1.85.0 \ + && chmod -R a+r /usr/local/rustup /usr/local/cargo + +# Install Claude Code CLI (for claude-bridge mode) +RUN npm install -g @anthropic-ai/claude-code@latest + +# Copy the binary +COPY --from=builder /build/target/release/ironclaw /usr/local/bin/ironclaw + +# Create non-root user (UID 1000 matches the orchestrator's container config) +RUN useradd -m -u 1000 -s /bin/bash sandbox \ + && mkdir -p /workspace \ + && chown sandbox:sandbox /workspace \ + && mkdir -p /home/sandbox/.claude \ + && chown sandbox:sandbox /home/sandbox/.claude + +USER sandbox +WORKDIR /workspace + +# The orchestrator passes the full command via Docker cmd. +ENTRYPOINT ["ironclaw"] diff --git a/FEATURE_PARITY.md b/FEATURE_PARITY.md index 6f791052..ce126dca 100644 --- a/FEATURE_PARITY.md +++ b/FEATURE_PARITY.md @@ -16,8 +16,8 @@ This document tracks feature parity between IronClaw (Rust implementation) and O | Feature | OpenClaw | IronClaw | Notes | |---------|----------|----------|-------| -| Hub-and-spoke architecture | ✅ | 🚧 | IronClaw has channels but no central gateway | -| WebSocket control plane | ✅ | ❌ | Gateway with ws://127.0.0.1:18789 | +| Hub-and-spoke architecture | ✅ | ✅ | Web gateway as central hub | +| WebSocket control plane | ✅ | ✅ | Gateway with WebSocket + SSE | | Single-user system | ✅ | ✅ | | | Multi-agent routing | ✅ | ❌ | Workspace isolation per-agent | | Session-based messaging | ✅ | ✅ | Per-sender sessions | @@ -31,9 +31,9 @@ This document tracks feature parity between IronClaw (Rust implementation) and O | Feature | OpenClaw | IronClaw | Notes | |---------|----------|----------|-------| -| Gateway control plane | ✅ | ❌ | Central WebSocket server | -| HTTP endpoints for Control UI | ✅ | ❌ | Web dashboard | -| Channel connection lifecycle | ✅ | 🚧 | ChannelManager handles streams | +| Gateway control plane | ✅ | ✅ | Web gateway with 40+ API endpoints | +| HTTP endpoints for Control UI | ✅ | ✅ | Web dashboard with chat, memory, jobs, logs, extensions | +| Channel connection lifecycle | ✅ | ✅ | ChannelManager + WebSocket tracker | | Session management/routing | ✅ | ✅ | SessionManager exists | | Configuration hot-reload | ✅ | ❌ | | | Network modes (loopback/LAN/remote) | ✅ | 🚧 | HTTP only | @@ -43,7 +43,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O | launchd/systemd integration | ✅ | ❌ | | | Bonjour/mDNS discovery | ✅ | ❌ | | | Tailscale integration | ✅ | ❌ | | -| Health check endpoints | ✅ | ❌ | | +| Health check endpoints | ✅ | ✅ | /api/health + /api/gateway/status | | `doctor` diagnostics | ✅ | ❌ | | ### Owner: _Unassigned_ @@ -59,14 +59,14 @@ This document tracks feature parity between IronClaw (Rust implementation) and O | REPL (simple) | ✅ | ✅ | - | For testing | | WASM channels | ❌ | ✅ | - | IronClaw innovation | | WhatsApp | ✅ | ❌ | P1 | Baileys (Web) | -| Telegram | ✅ | ❌ | P1 | grammY (Bot API) | +| Telegram | ✅ | ✅ | - | WASM tool (MTProto) | | Discord | ✅ | ❌ | P2 | discord.js | | Signal | ✅ | ❌ | P2 | signal-cli | -| Slack | ✅ | 🚧 | P1 | Stub exists, needs implementation | +| Slack | ✅ | ✅ | - | WASM tool | | iMessage | ✅ | ❌ | P3 | BlueBubbles recommended | | Feishu/Lark | ✅ | ❌ | P3 | | | LINE | ✅ | ❌ | P3 | | -| WebChat | ✅ | ❌ | P2 | Browser-based chat | +| WebChat | ✅ | ✅ | - | Web gateway chat | | Matrix | ✅ | ❌ | P3 | E2EE support | | Mattermost | ✅ | ❌ | P3 | | | Google Chat | ✅ | ❌ | P3 | | @@ -99,15 +99,15 @@ This document tracks feature parity between IronClaw (Rust implementation) and O | `run` (agent) | ✅ | ✅ | - | Default command | | `tool install/list/remove` | ✅ | ✅ | - | WASM tools | | `gateway start/stop` | ✅ | ❌ | P2 | | -| `onboard` (wizard) | ✅ | ❌ | P2 | Interactive setup | +| `onboard` (wizard) | ✅ | ✅ | - | Interactive setup | | `tui` | ✅ | ✅ | - | Ratatui TUI | -| `config` | ✅ | ❌ | P2 | Read/write config | +| `config` | ✅ | ✅ | - | Read/write config | | `channels` | ✅ | ❌ | P2 | Channel management | | `models` | ✅ | 🚧 | - | Model selector in TUI | -| `status` | ✅ | ❌ | P2 | System status | +| `status` | ✅ | ✅ | - | System status | | `agents` | ✅ | ❌ | P3 | Multi-agent management | | `sessions` | ✅ | ❌ | P3 | Session listing | -| `memory` | ✅ | ❌ | P2 | Memory search CLI | +| `memory` | ✅ | ✅ | - | Memory search CLI | | `skills` | ✅ | ❌ | P3 | Agent skills | | `pairing` | ✅ | ❌ | P3 | Node pairing | | `nodes` | ✅ | ❌ | P3 | Device management | @@ -132,7 +132,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O | Feature | OpenClaw | IronClaw | Notes | |---------|----------|----------|-------| | Pi agent runtime | ✅ | ➖ | IronClaw uses custom runtime | -| RPC-based execution | ✅ | 🚧 | Worker isolation | +| RPC-based execution | ✅ | ✅ | Orchestrator/worker pattern | | Multi-provider failover | ✅ | ❌ | Provider fallback chains | | Per-sender sessions | ✅ | ✅ | | | Global sessions | ✅ | ❌ | Optional shared context | @@ -303,13 +303,13 @@ This document tracks feature parity between IronClaw (Rust implementation) and O | Feature | OpenClaw | IronClaw | Priority | Notes | |---------|----------|----------|----------|-------| -| Control UI Dashboard | ✅ | ❌ | P2 | Web status/config | -| Channel status view | ✅ | ❌ | P2 | | +| Control UI Dashboard | ✅ | ✅ | - | Web gateway with chat, memory, jobs, logs, extensions | +| Channel status view | ✅ | 🚧 | P2 | Gateway status widget, full channel view pending | | Agent management | ✅ | ❌ | P3 | | | Model selection | ✅ | ✅ | - | TUI only | | Config editing | ✅ | ❌ | P3 | | -| Debug/logs viewer | ✅ | ❌ | P3 | | -| WebChat interface | ✅ | ❌ | P2 | Browser chat | +| Debug/logs viewer | ✅ | ✅ | - | Real-time log streaming with level/target filters | +| WebChat interface | ✅ | ✅ | - | Web gateway chat with SSE/WebSocket | | Canvas system (A2UI) | ✅ | ❌ | P3 | Agent-driven UI | ### Owner: _Unassigned_ @@ -320,13 +320,13 @@ This document tracks feature parity between IronClaw (Rust implementation) and O | Feature | OpenClaw | IronClaw | Priority | Notes | |---------|----------|----------|----------|-------| -| Cron jobs | ✅ | ❌ | P2 | Schedule-based tasks | -| Timezone support | ✅ | ❌ | P2 | | -| One-shot/recurring jobs | ✅ | ❌ | P2 | | +| Cron jobs | ✅ | ✅ | - | Routines with cron trigger | +| Timezone support | ✅ | ✅ | - | Via cron expressions | +| One-shot/recurring jobs | ✅ | ✅ | - | Manual + cron triggers | | `beforeInbound` hook | ✅ | ❌ | P2 | | | `beforeOutbound` hook | ✅ | ❌ | P2 | | | `beforeToolCall` hook | ✅ | ❌ | P2 | | -| `onMessage` hook | ✅ | ❌ | P2 | | +| `onMessage` hook | ✅ | ✅ | - | Routines with event trigger | | `onSessionStart` hook | ✅ | ❌ | P2 | | | `onSessionEnd` hook | ✅ | ❌ | P2 | | | `transcribeAudio` hook | ✅ | ❌ | P3 | | @@ -346,7 +346,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O | Feature | OpenClaw | IronClaw | Notes | |---------|----------|----------|-------| -| Gateway token auth | ✅ | 🚧 | HTTP webhook secret | +| Gateway token auth | ✅ | ✅ | Bearer token auth on web gateway | | Device pairing | ✅ | ❌ | | | Tailscale identity | ✅ | ❌ | | | OAuth flows | ✅ | 🚧 | NEAR AI OAuth | @@ -357,7 +357,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O | TLS 1.3 minimum | ✅ | ✅ | reqwest rustls | | SSRF protection | ✅ | ✅ | WASM allowlist | | Loopback-first | ✅ | 🚧 | HTTP binds 0.0.0.0 | -| Docker sandbox | ✅ | ❌ | Uses WASM sandbox | +| Docker sandbox | ✅ | ✅ | Orchestrator/worker containers | | WASM sandbox | ❌ | ✅ | IronClaw innovation | | Tool policies | ✅ | ✅ | | | Elevated mode | ✅ | ❌ | | @@ -404,23 +404,26 @@ This document tracks feature parity between IronClaw (Rust implementation) and O - ✅ Session management - ✅ Context compaction - ✅ Model selection +- ✅ Gateway control plane + WebSocket +- ✅ Web Control UI (chat, memory, jobs, logs, extensions, routines) +- ✅ WebChat channel (web gateway) +- ✅ Slack channel (WASM tool) +- ✅ Telegram channel (WASM tool, MTProto) +- ✅ Docker sandbox (orchestrator/worker) +- ✅ Cron job scheduling (routines) +- ✅ CLI subcommands (onboard, config, status, memory) +- ✅ Gateway token auth ### P1 - High Priority -- ❌ Slack channel (real implementation) -- ❌ Telegram channel - ❌ WhatsApp channel - ❌ Multi-provider failover -- ❌ Gateway control plane + WebSocket - ❌ Hooks system (beforeInbound, beforeToolCall, etc.) ### P2 - Medium Priority -- ❌ Cron job scheduling -- ❌ Web Control UI -- ❌ WebChat channel - ❌ Media handling (images, PDFs) -- ❌ CLI subcommands (config, status, memory, doctor) - ❌ Ollama/local model support - ❌ Configuration hot-reload +- ❌ Webhook trigger endpoint in web gateway ### P3 - Lower Priority - ❌ Discord channel diff --git a/README.md b/README.md index 194b9e15..80c16302 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,10 @@ IronClaw is the AI assistant you can actually trust with your personal and profe ### Always Available -- **Multi-channel** - REPL, HTTP webhooks, and extensible WASM channels (Telegram, Slack, and more) +- **Multi-channel** - REPL, HTTP webhooks, WASM channels (Telegram, Slack), and web gateway +- **Docker Sandbox** - Isolated container execution with per-job tokens and orchestrator/worker pattern +- **Web Gateway** - Browser UI with real-time SSE/WebSocket streaming +- **Routines** - Cron schedules, event triggers, webhook handlers for background automation - **Heartbeat System** - Proactive background execution for monitoring and maintenance tasks - **Parallel Jobs** - Handle multiple requests concurrently with isolated contexts - **Self-repair** - Automatic detection and recovery of stuck operations @@ -143,37 +146,42 @@ External content passes through multiple security layers: ## Architecture ``` -┌─────────────────────────────────────────────────────────────────┐ -│ Channels │ -│ ┌──────┐ ┌──────┐ ┌──────────────┐ │ -│ │ REPL │ │ HTTP │ │ WASM Channels│ │ -│ └──┬───┘ └──┬───┘ └──────┬───────┘ │ -│ └─────────┴─────────────┘ │ -│ │ │ -│ ┌────▼────┐ │ -│ │ Router │ Intent classification │ -│ └────┬────┘ │ -│ │ │ -│ ┌──────────▼──────────┐ │ -│ │ Scheduler │ Parallel job management │ -│ └──────────┬──────────┘ │ -│ │ │ -│ ┌───────────────┼───────────────┐ │ -│ ▼ ▼ ▼ │ -│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ -│ │ Worker │ │ Worker │ │ Worker │ LLM reasoning │ -│ └────┬────┘ └────┬────┘ └────┬────┘ │ -│ └───────────────┼───────────────┘ │ -│ │ │ -│ ┌──────────▼──────────┐ │ -│ │ Tool Registry │ │ -│ │ ┌───────────────┐ │ │ -│ │ │ Built-in │ │ │ -│ │ │ MCP │ │ │ -│ │ │ WASM Sandbox │ │ │ -│ │ └───────────────┘ │ │ -│ └─────────────────────┘ │ -└─────────────────────────────────────────────────────────────────┘ +┌────────────────────────────────────────────────────────────────────┐ +│ Channels │ +│ ┌──────┐ ┌──────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ REPL │ │ HTTP │ │WASM Channels│ │ Web Gateway │ │ +│ └──┬───┘ └──┬───┘ └──────┬──────┘ │ (SSE + WS) │ │ +│ │ │ │ └──────┬──────┘ │ +│ └─────────┴──────────────┴────────────────┘ │ +│ │ │ +│ ┌─────────▼─────────┐ │ +│ │ Agent Loop │ Intent routing │ +│ └────┬─────────┬────┘ │ +│ │ │ │ +│ ┌──────────▼───┐ ┌──▼──────────────┐ │ +│ │ Scheduler │ │ Routines Engine │ │ +│ │(parallel jobs)│ │(cron, event, wh) │ │ +│ └──────┬───────┘ └────────┬─────────┘ │ +│ │ │ │ +│ ┌─────────────┼───────────────────┘ │ +│ │ │ │ +│ ┌───▼────┐ ┌────▼────────────────┐ │ +│ │ Local │ │ Orchestrator │ │ +│ │Workers │ │ ┌───────────────┐ │ │ +│ │(in-proc)│ │ │ Docker Sandbox│ │ │ +│ └───┬────┘ │ │ Containers │ │ │ +│ │ │ │ ┌───────────┐ │ │ │ +│ │ │ │ │Worker / CC│ │ │ │ +│ │ │ │ └───────────┘ │ │ │ +│ │ │ └───────────────┘ │ │ +│ │ └─────────┬───────────┘ │ +│ └──────────────────┤ │ +│ │ │ +│ ┌───────────▼──────────┐ │ +│ │ Tool Registry │ │ +│ │ Built-in, MCP, WASM │ │ +│ └──────────────────────┘ │ +└────────────────────────────────────────────────────────────────────┘ ``` ### Core Components @@ -184,6 +192,9 @@ External content passes through multiple security layers: | **Router** | Classifies user intent (command, query, task) | | **Scheduler** | Manages parallel job execution with priorities | | **Worker** | Executes jobs with LLM reasoning and tool calls | +| **Orchestrator** | Container lifecycle, LLM proxying, per-job auth | +| **Web Gateway** | Browser UI with chat, memory, jobs, logs, extensions, routines | +| **Routines Engine** | Scheduled (cron) and reactive (event, webhook) background tasks | | **Workspace** | Persistent memory with hybrid search | | **Safety Layer** | Prompt injection defense and content sanitization | diff --git a/channels-src/slack/src/lib.rs b/channels-src/slack/src/lib.rs index c54af12f..5cf7b10d 100644 --- a/channels-src/slack/src/lib.rs +++ b/channels-src/slack/src/lib.rs @@ -108,7 +108,10 @@ struct SlackPostMessageResponse { #[derive(Debug, Deserialize)] struct SlackConfig { /// Name of secret containing signing secret (for verification by host). + /// Parsed from config for forward compatibility; not yet used in WASM + /// (host handles signature verification). #[serde(default = "default_signing_secret_name")] + #[allow(dead_code)] signing_secret_name: String, } @@ -175,11 +178,7 @@ impl Guest for SlackChannel { // Actual event callback "event_callback" => { if let Some(event) = event_wrapper.event { - handle_slack_event( - event, - event_wrapper.team_id, - event_wrapper.event_id, - ); + handle_slack_event(event, event_wrapper.team_id, event_wrapper.event_id); } // Always respond 200 quickly to Slack (they have a 3s timeout) json_response(200, serde_json::json!({"ok": true})) @@ -230,6 +229,7 @@ impl Guest for SlackChannel { "https://slack.com/api/chat.postMessage", &headers.to_string(), Some(&payload_bytes), + None, ); match result { @@ -243,14 +243,15 @@ impl Guest for SlackChannel { // Parse Slack response let slack_response: SlackPostMessageResponse = - serde_json::from_slice(&http_response.body).map_err(|e| { - format!("Failed to parse Slack response: {}", e) - })?; + serde_json::from_slice(&http_response.body) + .map_err(|e| format!("Failed to parse Slack response: {}", e))?; if !slack_response.ok { return Err(format!( "Slack API error: {}", - slack_response.error.unwrap_or_else(|| "unknown".to_string()) + slack_response + .error + .unwrap_or_else(|| "unknown".to_string()) )); } @@ -277,17 +278,16 @@ impl Guest for SlackChannel { } /// Handle a Slack event and emit message if applicable. -fn handle_slack_event( - event: SlackEvent, - team_id: Option, - _event_id: Option, -) { +fn handle_slack_event(event: SlackEvent, team_id: Option, _event_id: Option) { match event.event_type.as_str() { // Direct mention of the bot "app_mention" => { - if let (Some(user), Some(channel), Some(text), Some(ts)) = - (event.user, event.channel.clone(), event.text, event.ts.clone()) - { + if let (Some(user), Some(channel), Some(text), Some(ts)) = ( + event.user, + event.channel.clone(), + event.text, + event.ts.clone(), + ) { emit_message(user, text, channel, event.thread_ts.or(Some(ts)), team_id); } } @@ -299,9 +299,12 @@ fn handle_slack_event( return; } - if let (Some(user), Some(channel), Some(text), Some(ts)) = - (event.user, event.channel.clone(), event.text, event.ts.clone()) - { + if let (Some(user), Some(channel), Some(text), Some(ts)) = ( + event.user, + event.channel.clone(), + event.text, + event.ts.clone(), + ) { // Only process DMs (channel IDs starting with D) if channel.starts_with('D') { emit_message(user, text, channel, event.thread_ts.or(Some(ts)), team_id); @@ -335,8 +338,7 @@ fn emit_message( team_id, }; - let metadata_json = - serde_json::to_string(&metadata).unwrap_or_else(|_| "{}".to_string()); + let metadata_json = serde_json::to_string(&metadata).unwrap_or_else(|_| "{}".to_string()); // Strip @ mentions of the bot from the text for cleaner messages let cleaned_text = strip_bot_mention(&text); diff --git a/channels-src/telegram/src/lib.rs b/channels-src/telegram/src/lib.rs index 984385bc..5a1591bc 100644 --- a/channels-src/telegram/src/lib.rs +++ b/channels-src/telegram/src/lib.rs @@ -388,7 +388,9 @@ impl Guest for TelegramChannel { let headers = serde_json::json!({}); - let result = channel_host::http_request("GET", &url, &headers.to_string(), None); + // 35s HTTP timeout outlives Telegram's 30s server-side long-poll + let result = + channel_host::http_request("GET", &url, &headers.to_string(), None, Some(35_000)); match result { Ok(response) => { @@ -461,72 +463,52 @@ impl Guest for TelegramChannel { } fn on_respond(response: AgentResponse) -> Result<(), String> { - // Parse metadata to get chat info let metadata: TelegramMessageMetadata = serde_json::from_str(&response.metadata_json) .map_err(|e| format!("Failed to parse metadata: {}", e))?; - // Build sendMessage payload - let mut payload = serde_json::json!({ - "chat_id": metadata.chat_id, - "text": response.content, - "parse_mode": "Markdown", - }); - - // Reply to the original message for context - payload["reply_to_message_id"] = serde_json::Value::Number(metadata.message_id.into()); - - let payload_bytes = serde_json::to_vec(&payload) - .map_err(|e| format!("Failed to serialize payload: {}", e))?; - - // Make HTTP request to Telegram API - // The bot token is injected into the URL by the host - let headers = serde_json::json!({ - "Content-Type": "application/json" - }); - - let result = channel_host::http_request( - "POST", - "https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage", - &headers.to_string(), - Some(&payload_bytes), + // Try sending with Markdown first; fall back to plain text if Telegram + // can't parse the entities (e.g. model leaked with underscores). + let result = send_message( + metadata.chat_id, + &response.content, + metadata.message_id, + Some("Markdown"), ); match result { - Ok(http_response) => { - if http_response.status != 200 { - let body_str = String::from_utf8_lossy(&http_response.body); - return Err(format!( - "Telegram API returned status {}: {}", - http_response.status, body_str - )); - } - - // Parse Telegram response - let api_response: TelegramApiResponse = - serde_json::from_slice(&http_response.body) - .map_err(|e| format!("Failed to parse Telegram response: {}", e))?; - - if !api_response.ok { - return Err(format!( - "Telegram API error: {}", - api_response - .description - .unwrap_or_else(|| "unknown".to_string()) - )); - } - + Ok(msg_id) => { channel_host::log( channel_host::LogLevel::Debug, &format!( "Sent message to chat {}: message_id={}", - metadata.chat_id, - api_response.result.map(|r| r.message_id).unwrap_or(0) + metadata.chat_id, msg_id ), ); - Ok(()) } - Err(e) => Err(format!("HTTP request failed: {}", e)), + Err(SendError::ParseEntities(detail)) => { + channel_host::log( + channel_host::LogLevel::Warn, + &format!("Markdown parse failed ({}), retrying as plain text", detail), + ); + let msg_id = send_message( + metadata.chat_id, + &response.content, + metadata.message_id, + None, + ) + .map_err(|e| format!("Plain-text retry also failed: {}", e))?; + + channel_host::log( + channel_host::LogLevel::Debug, + &format!( + "Sent plain-text message to chat {}: message_id={}", + metadata.chat_id, msg_id + ), + ); + Ok(()) + } + Err(e) => Err(e.to_string()), } } @@ -568,6 +550,7 @@ impl Guest for TelegramChannel { "https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendChatAction", &headers.to_string(), Some(&payload_bytes), + None, ); if let Err(e) = result { @@ -586,6 +569,101 @@ impl Guest for TelegramChannel { } } +// ============================================================================ +// Send Message Helper +// ============================================================================ + +/// Errors from send_message, split so callers can match on parse-entity failures. +enum SendError { + /// Telegram returned 400 with "can't parse entities" (Markdown issue). + ParseEntities(String), + /// Any other failure. + Other(String), +} + +impl std::fmt::Display for SendError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + SendError::ParseEntities(detail) => write!(f, "parse entities error: {}", detail), + SendError::Other(msg) => write!(f, "{}", msg), + } + } +} + +/// Send a message via the Telegram Bot API. +/// +/// Returns the sent message_id on success. When `parse_mode` is set and +/// Telegram returns a 400 "can't parse entities" error, returns +/// `SendError::ParseEntities` so the caller can retry without formatting. +fn send_message( + chat_id: i64, + text: &str, + reply_to_message_id: i64, + parse_mode: Option<&str>, +) -> Result { + let mut payload = serde_json::json!({ + "chat_id": chat_id, + "text": text, + "reply_to_message_id": reply_to_message_id, + }); + + if let Some(mode) = parse_mode { + payload["parse_mode"] = serde_json::Value::String(mode.to_string()); + } + + let payload_bytes = serde_json::to_vec(&payload) + .map_err(|e| SendError::Other(format!("Failed to serialize payload: {}", e)))?; + + let headers = serde_json::json!({ "Content-Type": "application/json" }); + + let result = channel_host::http_request( + "POST", + "https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage", + &headers.to_string(), + Some(&payload_bytes), + None, + ); + + match result { + Ok(http_response) => { + if http_response.status == 400 { + let body_str = String::from_utf8_lossy(&http_response.body); + if body_str.contains("can't parse entities") { + return Err(SendError::ParseEntities(body_str.to_string())); + } + return Err(SendError::Other(format!( + "Telegram API returned 400: {}", + body_str + ))); + } + + if http_response.status != 200 { + let body_str = String::from_utf8_lossy(&http_response.body); + return Err(SendError::Other(format!( + "Telegram API returned status {}: {}", + http_response.status, body_str + ))); + } + + let api_response: TelegramApiResponse = + serde_json::from_slice(&http_response.body) + .map_err(|e| SendError::Other(format!("Failed to parse response: {}", e)))?; + + if !api_response.ok { + return Err(SendError::Other(format!( + "Telegram API error: {}", + api_response + .description + .unwrap_or_else(|| "unknown".to_string()) + ))); + } + + Ok(api_response.result.map(|r| r.message_id).unwrap_or(0)) + } + Err(e) => Err(SendError::Other(format!("HTTP request failed: {}", e))), + } +} + // ============================================================================ // Webhook Management // ============================================================================ @@ -604,6 +682,7 @@ fn delete_webhook() -> Result<(), String> { "https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/deleteWebhook", &headers.to_string(), None, + None, ); match result { @@ -666,6 +745,7 @@ fn register_webhook(tunnel_url: &str, webhook_secret: Option<&str>) -> Result<() "https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/setWebhook", &headers.to_string(), Some(&body_bytes), + None, ); match result { diff --git a/channels-src/telegram/telegram.capabilities.json b/channels-src/telegram/telegram.capabilities.json index 70f56e01..f9335ad0 100644 --- a/channels-src/telegram/telegram.capabilities.json +++ b/channels-src/telegram/telegram.capabilities.json @@ -26,6 +26,7 @@ "allowed_paths": ["/webhook/telegram"], "allow_polling": true, "min_poll_interval_ms": 30000, + "callback_timeout_secs": 45, "workspace_prefix": "channels/telegram/", "emit_rate_limit": { "messages_per_minute": 100, diff --git a/channels-src/telegram/telegram.wasm b/channels-src/telegram/telegram.wasm deleted file mode 100644 index f739b982535cdeed237a67f965be5dbce43484ad..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 246930 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 DcQqMy diff --git a/channels-src/whatsapp/src/lib.rs b/channels-src/whatsapp/src/lib.rs index e28340b8..27d79e2c 100644 --- a/channels-src/whatsapp/src/lib.rs +++ b/channels-src/whatsapp/src/lib.rs @@ -361,6 +361,7 @@ impl Guest for WhatsAppChannel { &api_url, &headers.to_string(), Some(&payload_bytes), + None, ); match result { diff --git a/examples/test_heartbeat.rs b/examples/test_heartbeat.rs new file mode 100644 index 00000000..c62d28c3 --- /dev/null +++ b/examples/test_heartbeat.rs @@ -0,0 +1,120 @@ +//! Standalone heartbeat test. +//! +//! Exercises the heartbeat system in isolation: connects to the real +//! database, reads the real HEARTBEAT.md, calls the real LLM, and prints +//! every step so you can see exactly where it breaks. +//! +//! Usage: +//! cargo run --example test_heartbeat + +use std::sync::Arc; + +use ironclaw::{ + agent::HeartbeatRunner, + config::Config, + history::Store, + llm::{SessionConfig, create_llm_provider, create_session_manager}, + workspace::Workspace, +}; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + // Load .env and set up logging + let _ = dotenvy::dotenv(); + tracing_subscriber::fmt() + .with_env_filter("ironclaw=debug") + .init(); + + println!("=== Heartbeat Integration Test ===\n"); + + // 1. Load config + let config = Config::from_env().map_err(|e| anyhow::anyhow!("Config: {}", e))?; + println!("[1/6] Config loaded"); + println!(" heartbeat.enabled = {}", config.heartbeat.enabled); + println!( + " heartbeat.interval_secs = {}", + config.heartbeat.interval_secs + ); + println!( + " heartbeat.notify_channel = {:?}", + config.heartbeat.notify_channel + ); + println!( + " heartbeat.notify_user = {:?}", + config.heartbeat.notify_user + ); + + // 2. Connect to database + let store = Store::new(&config.database).await?; + store.run_migrations().await?; + println!("[2/6] Database connected"); + + // 3. Create workspace + let workspace = Arc::new(Workspace::new("default", store.pool())); + println!("[3/6] Workspace created"); + + // 4. Read HEARTBEAT.md + let checklist = workspace.heartbeat_checklist().await; + match &checklist { + Ok(Some(content)) => { + let preview: String = content.chars().take(200).collect(); + println!("[4/6] HEARTBEAT.md found ({} chars)", content.len()); + println!(" Preview: {}...", preview); + } + Ok(None) => { + println!("[4/6] HEARTBEAT.md is None (no file, no seed fallback)"); + println!(" Heartbeat will return Skipped."); + } + Err(e) => { + println!("[4/6] HEARTBEAT.md read error: {}", e); + } + } + + // Check if the checklist would be considered "effectively empty" + if let Ok(Some(_)) = checklist { + println!(" (Will verify via runner below)"); + } + + // 5. Create LLM provider + let session = create_session_manager(SessionConfig { + auth_base_url: config.llm.nearai.auth_base_url.clone(), + session_path: config.llm.nearai.session_path.clone(), + ..Default::default() + }) + .await; + let llm = create_llm_provider(&config.llm, session)?; + println!("[5/6] LLM provider created (model: {})", llm.model_name()); + + // 6. Run heartbeat check + println!("[6/6] Running check_heartbeat()...\n"); + + let hb_config = ironclaw::agent::HeartbeatConfig::default(); + let runner = HeartbeatRunner::new(hb_config, workspace, llm); + + let result = runner.check_heartbeat().await; + + println!("=== Result ===\n"); + match &result { + ironclaw::agent::HeartbeatResult::Ok => { + println!("HeartbeatResult::Ok"); + println!(" LLM responded HEARTBEAT_OK, nothing needs attention."); + } + ironclaw::agent::HeartbeatResult::NeedsAttention(msg) => { + println!("HeartbeatResult::NeedsAttention"); + println!(" Message:\n{}", msg); + } + ironclaw::agent::HeartbeatResult::Skipped => { + println!("HeartbeatResult::Skipped"); + println!(" No checklist found, or checklist was effectively empty."); + println!(" This means the HEARTBEAT.md either:"); + println!(" - Does not exist in the workspace database"); + println!(" - Contains only headers, comments, and empty checkboxes"); + } + ironclaw::agent::HeartbeatResult::Failed(err) => { + println!("HeartbeatResult::Failed"); + println!(" Error: {}", err); + } + } + + Ok(()) +} diff --git a/migrations/V4__sandbox_columns.sql b/migrations/V4__sandbox_columns.sql new file mode 100644 index 00000000..7510e847 --- /dev/null +++ b/migrations/V4__sandbox_columns.sql @@ -0,0 +1,10 @@ +-- Add project_dir and user_id columns for sandbox job tracking. +-- user_id was previously hardcoded to "default" in the Rust layer; +-- now it's persisted so we can filter per-user. + +ALTER TABLE agent_jobs ADD COLUMN IF NOT EXISTS project_dir TEXT; +ALTER TABLE agent_jobs ADD COLUMN IF NOT EXISTS user_id TEXT NOT NULL DEFAULT 'default'; + +CREATE INDEX IF NOT EXISTS idx_agent_jobs_source ON agent_jobs(source); +CREATE INDEX IF NOT EXISTS idx_agent_jobs_user ON agent_jobs(user_id); +CREATE INDEX IF NOT EXISTS idx_agent_jobs_created ON agent_jobs(created_at DESC); diff --git a/migrations/V5__claude_code.sql b/migrations/V5__claude_code.sql new file mode 100644 index 00000000..f0a20426 --- /dev/null +++ b/migrations/V5__claude_code.sql @@ -0,0 +1,14 @@ +-- Track which mode a sandbox job uses (worker vs claude_code). +ALTER TABLE agent_jobs ADD COLUMN IF NOT EXISTS job_mode TEXT NOT NULL DEFAULT 'worker'; + +-- Persist Claude Code streaming events so they survive restarts and can be +-- loaded when the frontend opens a job detail view after the fact. +CREATE TABLE IF NOT EXISTS claude_code_events ( + id BIGSERIAL PRIMARY KEY, + job_id UUID NOT NULL REFERENCES agent_jobs(id), + event_type TEXT NOT NULL, + data JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_cc_events_job ON claude_code_events(job_id, id); diff --git a/migrations/V6__routines.sql b/migrations/V6__routines.sql new file mode 100644 index 00000000..36f63cb2 --- /dev/null +++ b/migrations/V6__routines.sql @@ -0,0 +1,73 @@ +-- Routines: scheduled and reactive job system. +-- +-- A routine is a named, persistent, user-owned task with a trigger and an action. +-- Triggers fire independently (cron, event, webhook, manual) so only the +-- relevant routine's prompt hits the LLM, not the whole checklist. + +CREATE TABLE routines ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + user_id TEXT NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT true, + + -- Trigger definition + trigger_type TEXT NOT NULL, -- 'cron', 'event', 'webhook', 'manual' + trigger_config JSONB NOT NULL, -- type-specific config (schedule, pattern, etc.) + + -- Action definition + action_type TEXT NOT NULL, -- 'lightweight', 'full_job' + action_config JSONB NOT NULL, -- prompt, context_paths, max_tokens / title, max_iterations + + -- Guardrails + cooldown_secs INTEGER NOT NULL DEFAULT 300, + max_concurrent INTEGER NOT NULL DEFAULT 1, + dedup_window_secs INTEGER, -- NULL = no dedup + + -- Notification preferences + notify_channel TEXT, -- NULL = use default + notify_user TEXT NOT NULL DEFAULT 'default', + notify_on_success BOOLEAN NOT NULL DEFAULT false, + notify_on_failure BOOLEAN NOT NULL DEFAULT true, + notify_on_attention BOOLEAN NOT NULL DEFAULT true, + + -- Runtime state (updated by engine) + state JSONB NOT NULL DEFAULT '{}', + last_run_at TIMESTAMPTZ, + next_fire_at TIMESTAMPTZ, -- pre-computed for cron triggers + run_count BIGINT NOT NULL DEFAULT 0, + consecutive_failures INTEGER NOT NULL DEFAULT 0, + + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + UNIQUE (user_id, name) +); + +-- Fast lookup: "which cron routines need to fire right now?" +CREATE INDEX idx_routines_next_fire + ON routines (next_fire_at) + WHERE enabled AND next_fire_at IS NOT NULL; + +-- Fast lookup: event triggers for a user +CREATE INDEX idx_routines_event_triggers + ON routines (user_id) + WHERE enabled AND trigger_type = 'event'; + +-- Audit log of individual routine executions. +CREATE TABLE routine_runs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + routine_id UUID NOT NULL REFERENCES routines(id) ON DELETE CASCADE, + trigger_type TEXT NOT NULL, + trigger_detail TEXT, -- e.g. matched message preview, cron expression + started_at TIMESTAMPTZ NOT NULL DEFAULT now(), + completed_at TIMESTAMPTZ, + status TEXT NOT NULL DEFAULT 'running', -- running, ok, attention, failed + result_summary TEXT, + tokens_used INTEGER, + job_id UUID REFERENCES agent_jobs(id), -- non-NULL for full_job runs + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_routine_runs_routine ON routine_runs (routine_id); +CREATE INDEX idx_routine_runs_status ON routine_runs (status) WHERE status = 'running'; diff --git a/migrations/V7__rename_events.sql b/migrations/V7__rename_events.sql new file mode 100644 index 00000000..3676fbf7 --- /dev/null +++ b/migrations/V7__rename_events.sql @@ -0,0 +1,3 @@ +-- Rename claude_code_events to job_events (generic for all sandbox job types). +ALTER TABLE claude_code_events RENAME TO job_events; +ALTER INDEX idx_cc_events_job RENAME TO idx_job_events_job; diff --git a/migrations/V8__settings.sql b/migrations/V8__settings.sql new file mode 100644 index 00000000..515b0a74 --- /dev/null +++ b/migrations/V8__settings.sql @@ -0,0 +1,16 @@ +-- Settings table: key-value store for all user configuration. +-- +-- Replaces ~/.ironclaw/settings.json, session.json, and mcp-servers.json. +-- Keys use dotted paths matching the existing Settings.get()/set() convention +-- (e.g., "agent.name", "sandbox.enabled", "mcp_servers"). +-- One row per setting so individual values can be updated atomically. + +CREATE TABLE IF NOT EXISTS settings ( + user_id TEXT NOT NULL, + key TEXT NOT NULL, + value JSONB NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (user_id, key) +); + +CREATE INDEX IF NOT EXISTS idx_settings_user ON settings (user_id); diff --git a/src/agent/agent_loop.rs b/src/agent/agent_loop.rs index 76cefdf9..462535e9 100644 --- a/src/agent/agent_loop.rs +++ b/src/agent/agent_loop.rs @@ -9,13 +9,14 @@ use uuid::Uuid; use crate::agent::compaction::ContextCompactor; use crate::agent::context_monitor::ContextMonitor; use crate::agent::heartbeat::spawn_heartbeat; +use crate::agent::routine_engine::{RoutineEngine, spawn_cron_ticker}; use crate::agent::self_repair::{DefaultSelfRepair, RepairResult, SelfRepair}; use crate::agent::session::{PendingApproval, Session, ThreadState}; use crate::agent::session_manager::SessionManager; use crate::agent::submission::{Submission, SubmissionParser, SubmissionResult}; use crate::agent::{HeartbeatConfig as AgentHeartbeatConfig, MessageIntent, Router, Scheduler}; use crate::channels::{ChannelManager, IncomingMessage, OutgoingResponse, StatusUpdate}; -use crate::config::{AgentConfig, HeartbeatConfig}; +use crate::config::{AgentConfig, HeartbeatConfig, RoutineConfig}; use crate::context::ContextManager; use crate::context::JobContext; use crate::error::Error; @@ -77,6 +78,7 @@ pub struct Agent { session_manager: Arc, context_monitor: ContextMonitor, heartbeat_config: Option, + routine_config: Option, } impl Agent { @@ -89,6 +91,7 @@ impl Agent { deps: AgentDeps, channels: ChannelManager, heartbeat_config: Option, + routine_config: Option, context_manager: Option>, session_manager: Option>, ) -> Self { @@ -116,6 +119,7 @@ impl Agent { session_manager, context_monitor: ContextMonitor::new(), heartbeat_config, + routine_config, } } @@ -256,53 +260,28 @@ impl Agent { let channels = self.channels.clone(); tokio::spawn(async move { while let Some(response) = notify_rx.recv().await { - // Route notification to configured channel/user, or broadcast to all - match (¬ify_channel, ¬ify_user) { - (Some(channel), Some(user)) => { - // Send to specific channel and user - if let Err(e) = - channels.broadcast(channel, user, response.clone()).await - { + let user = notify_user.as_deref().unwrap_or("default"); + + // Try the configured channel first, fall back to + // broadcasting on all channels. + let targeted_ok = if let Some(ref channel) = notify_channel { + channels + .broadcast(channel, user, response.clone()) + .await + .is_ok() + } else { + false + }; + + if !targeted_ok { + let results = channels.broadcast_all(user, response).await; + for (ch, result) in results { + if let Err(e) = result { tracing::warn!( - "Failed to send heartbeat to {}/{}: {}", - channel, - user, + "Failed to broadcast heartbeat to {}: {}", + ch, e ); - } else { - tracing::debug!( - "Heartbeat notification sent to {}/{}", - channel, - user - ); - } - } - (None, Some(user)) => { - // Broadcast to all channels for this user - let results = channels.broadcast_all(user, response).await; - for (ch, result) in results { - if let Err(e) = result { - tracing::warn!( - "Failed to broadcast heartbeat to {}: {}", - ch, - e - ); - } - } - } - _ => { - // No explicit target, broadcast to all channels - // for the default user so notifications actually - // reach someone instead of vanishing into logs. - let results = channels.broadcast_all("default", response).await; - for (ch, result) in results { - if let Err(e) = result { - tracing::warn!( - "Failed to broadcast heartbeat to {}: {}", - ch, - e - ); - } } } } @@ -330,6 +309,85 @@ impl Agent { None }; + // Spawn routine engine if enabled + let routine_handle = if let Some(ref rt_config) = self.routine_config { + if rt_config.enabled { + if let (Some(store), Some(workspace)) = (self.store(), self.workspace()) { + // Set up notification channel (same pattern as heartbeat) + let (notify_tx, mut notify_rx) = + tokio::sync::mpsc::channel::(32); + + let engine = Arc::new(RoutineEngine::new( + rt_config.clone(), + Arc::clone(store), + self.llm().clone(), + Arc::clone(workspace), + notify_tx, + )); + + // Register routine tools + self.deps + .tools + .register_routine_tools(Arc::clone(store), Arc::clone(&engine)); + + // Load initial event cache + engine.refresh_event_cache().await; + + // Spawn notification forwarder + let channels = self.channels.clone(); + tokio::spawn(async move { + while let Some(response) = notify_rx.recv().await { + let user = response + .metadata + .get("notify_user") + .and_then(|v| v.as_str()) + .unwrap_or("default") + .to_string(); + let results = channels.broadcast_all(&user, response).await; + for (ch, result) in results { + if let Err(e) = result { + tracing::warn!( + "Failed to broadcast routine notification to {}: {}", + ch, + e + ); + } + } + } + }); + + // Spawn cron ticker + let cron_interval = + std::time::Duration::from_secs(rt_config.cron_check_interval_secs); + let cron_handle = spawn_cron_ticker(Arc::clone(&engine), cron_interval); + + // Store engine reference for event trigger checking + // Safety: we're in run() which takes self, no other reference exists + let engine_ref = Arc::clone(&engine); + // SAFETY: self is consumed by run(), we can smuggle the engine in + // via a local to use in the message loop below. + + tracing::info!( + "Routines enabled: cron ticker every {}s, max {} concurrent", + rt_config.cron_check_interval_secs, + rt_config.max_concurrent_routines + ); + + Some((cron_handle, engine_ref)) + } else { + tracing::warn!("Routines enabled but store/workspace not available"); + None + } + } else { + None + } + } else { + None + }; + + // Extract engine ref for use in message loop + let routine_engine_for_loop = routine_handle.as_ref().map(|(_, e)| Arc::clone(e)); + // Main message loop tracing::info!("Agent {} ready and listening", self.config.name); @@ -374,6 +432,14 @@ impl Agent { .await; } } + + // Check event triggers (cheap in-memory regex, fires async if matched) + if let Some(ref engine) = routine_engine_for_loop { + let fired = engine.check_event_triggers(&message).await; + if fired > 0 { + tracing::debug!("Fired {} event-triggered routines", fired); + } + } } // Cleanup @@ -383,6 +449,9 @@ impl Agent { if let Some(handle) = heartbeat_handle { handle.abort(); } + if let Some((cron_handle, _)) = routine_handle { + cron_handle.abort(); + } self.scheduler.stop_all().await; self.channels.shutdown_all().await?; @@ -393,6 +462,11 @@ impl Agent { // Parse submission type first let submission = SubmissionParser::parse(&message.content); + // Hydrate thread from DB if it's a historical thread not in memory + if let Some(ref external_thread_id) = message.thread_id { + self.maybe_hydrate_thread(message, external_thread_id).await; + } + // Resolve session and thread let (session, thread_id) = self .session_manager @@ -444,6 +518,9 @@ impl Agent { self.process_user_input(message, session, thread_id, &content) .await } + Submission::SystemCommand { command, args } => { + self.handle_system_command(&command, &args).await + } Submission::Undo => self.process_undo(session, thread_id).await, Submission::Redo => self.process_redo(session, thread_id).await, Submission::Interrupt => self.process_interrupt(session, thread_id).await, @@ -515,6 +592,107 @@ impl Agent { } } + /// Hydrate a historical thread from DB into memory if not already present. + /// + /// Called before `resolve_thread` so that the session manager finds the + /// thread on lookup instead of creating a new one. + /// + /// Creates an in-memory thread with the exact UUID the frontend sent, + /// even when the conversation has zero messages (e.g. a brand-new + /// assistant thread). Without this, `resolve_thread` would mint a + /// fresh UUID and all messages would land in the wrong conversation. + async fn maybe_hydrate_thread(&self, message: &IncomingMessage, external_thread_id: &str) { + // Only hydrate UUID-shaped thread IDs (web gateway uses UUIDs) + let thread_uuid = match Uuid::parse_str(external_thread_id) { + Ok(id) => id, + Err(_) => return, + }; + + // Check if already in memory + let session = self + .session_manager + .get_or_create_session(&message.user_id) + .await; + { + let sess = session.lock().await; + if sess.threads.contains_key(&thread_uuid) { + return; + } + } + + // Load history from DB (may be empty for a newly created thread). + let mut chat_messages: Vec = Vec::new(); + let msg_count; + + if let Some(store) = self.store() { + let db_messages = store + .list_conversation_messages(thread_uuid) + .await + .unwrap_or_default(); + msg_count = db_messages.len(); + chat_messages = db_messages + .iter() + .filter_map(|m| match m.role.as_str() { + "user" => Some(ChatMessage::user(&m.content)), + "assistant" => Some(ChatMessage::assistant(&m.content)), + _ => None, + }) + .collect(); + } else { + msg_count = 0; + } + + // Create thread with the historical ID and restore messages + let session_id = { + let sess = session.lock().await; + sess.id + }; + + let mut thread = crate::agent::session::Thread::with_id(thread_uuid, session_id); + if !chat_messages.is_empty() { + thread.restore_from_messages(chat_messages); + } + + // Restore response chain from conversation metadata + if let Some(store) = self.store() { + if let Ok(Some(metadata)) = store.get_conversation_metadata(thread_uuid).await { + if let Some(rid) = metadata + .get("last_response_id") + .and_then(|v| v.as_str()) + .map(String::from) + { + thread.last_response_id = Some(rid.clone()); + self.llm() + .seed_response_chain(&thread_uuid.to_string(), rid); + tracing::debug!("Restored response chain for thread {}", thread_uuid); + } + } + } + + // Insert into session and register with session manager + { + let mut sess = session.lock().await; + sess.threads.insert(thread_uuid, thread); + sess.active_thread = Some(thread_uuid); + sess.last_active_at = chrono::Utc::now(); + } + + self.session_manager + .register_thread( + &message.user_id, + &message.channel, + thread_uuid, + Arc::clone(&session), + ) + .await; + + tracing::debug!( + "Hydrated thread {} from DB ({} messages)", + thread_uuid, + msg_count + ); + } + async fn process_user_input( &self, message: &IncomingMessage, @@ -694,6 +872,7 @@ impl Agent { match result { Ok(AgenticLoopResult::Response(response)) => { thread.complete_turn(&response); + self.persist_response_chain(thread); let _ = self .channels .send_status( @@ -702,6 +881,10 @@ impl Agent { &message.metadata, ) .await; + + // Fire-and-forget: persist turn to DB + self.persist_turn(thread_id, &message.user_id, content, Some(&response)); + Ok(SubmissionResult::response(response)) } Ok(AgenticLoopResult::NeedApproval { pending }) => { @@ -728,11 +911,95 @@ impl Agent { } Err(e) => { thread.fail_turn(e.to_string()); + + // Persist the user message even on failure + self.persist_turn(thread_id, &message.user_id, content, None); + Ok(SubmissionResult::error(e.to_string())) } } } + /// Fire-and-forget: persist a turn (user message + optional assistant response) to the DB. + fn persist_turn( + &self, + thread_id: Uuid, + user_id: &str, + user_input: &str, + response: Option<&str>, + ) { + let store = match self.store() { + Some(s) => Arc::clone(s), + None => return, + }; + + let user_id = user_id.to_string(); + let user_input = user_input.to_string(); + let response = response.map(String::from); + + tokio::spawn(async move { + if let Err(e) = store + .ensure_conversation(thread_id, "gateway", &user_id, None) + .await + { + tracing::warn!("Failed to ensure conversation {}: {}", thread_id, e); + return; + } + + if let Err(e) = store + .add_conversation_message(thread_id, "user", &user_input) + .await + { + tracing::warn!("Failed to persist user message: {}", e); + return; + } + + if let Some(ref resp) = response { + if let Err(e) = store + .add_conversation_message(thread_id, "assistant", resp) + .await + { + tracing::warn!("Failed to persist assistant message: {}", e); + } + } + }); + } + + /// Sync the provider's response chain ID to the thread and DB metadata. + /// + /// Call after a successful agentic loop to persist the latest + /// `previous_response_id` so chaining survives restarts. + fn persist_response_chain(&self, thread: &mut crate::agent::session::Thread) { + let tid = thread.id.to_string(); + let response_id = match self.llm().get_response_chain_id(&tid) { + Some(rid) => rid, + None => return, + }; + + // Update in-memory thread + thread.last_response_id = Some(response_id.clone()); + + // Fire-and-forget DB write + let store = match self.store() { + Some(s) => Arc::clone(s), + None => return, + }; + let thread_id = thread.id; + tokio::spawn(async move { + let val = serde_json::json!(response_id); + if let Err(e) = store + .update_conversation_metadata_field(thread_id, "last_response_id", &val) + .await + { + tracing::warn!( + "Failed to persist response chain for thread {}: {}", + thread_id, + e + ); + } + }); + } + /// Run the agentic loop: call LLM, execute tools, repeat until text response. /// /// Returns `AgenticLoopResult::Response` on completion, or @@ -808,7 +1075,12 @@ impl Agent { // Call LLM with current context let context = ReasoningContext::new() .with_messages(context_messages.clone()) - .with_tools(tool_defs); + .with_tools(tool_defs) + .with_metadata({ + let mut m = std::collections::HashMap::new(); + m.insert("thread_id".to_string(), thread_id.to_string()); + m + }); let result = reasoning.respond_with_tools(&context).await?; @@ -832,13 +1104,16 @@ impl Agent { // Tools have been executed or we've tried multiple times, return response return Ok(AgenticLoopResult::Response(text)); } - RespondResult::ToolCalls(tool_calls) => { + RespondResult::ToolCalls { + tool_calls, + content, + } => { tools_executed = true; // Add the assistant message with tool_calls to context. - // OpenAI-compatible APIs require this before tool-result messages. + // OpenAI protocol requires this before tool-result messages. context_messages.push(ChatMessage::assistant_with_tool_calls( - "", + content, tool_calls.clone(), )); @@ -960,10 +1235,26 @@ impl Agent { if let Some((ext_name, instructions)) = detect_auth_awaiting(&tc.name, &tool_result) { - let mut sess = session.lock().await; - if let Some(thread) = sess.threads.get_mut(&thread_id) { - thread.enter_auth_mode(ext_name); + let auth_data = parse_auth_result(&tool_result); + { + let mut sess = session.lock().await; + if let Some(thread) = sess.threads.get_mut(&thread_id) { + thread.enter_auth_mode(ext_name.clone()); + } } + let _ = self + .channels + .send_status( + &message.channel, + StatusUpdate::AuthRequired { + extension_name: ext_name, + instructions: Some(instructions.clone()), + auth_url: auth_data.auth_url, + setup_url: auth_data.setup_url, + }, + &message.metadata, + ) + .await; return Ok(AgenticLoopResult::Response(instructions)); } @@ -1024,19 +1315,59 @@ impl Agent { .into()); } - // Execute with timeout - let result = tokio::time::timeout(std::time::Duration::from_secs(60), async { + tracing::debug!( + tool = %tool_name, + params = %params, + "Tool call started" + ); + + // Execute with per-tool timeout + let timeout = tool.execution_timeout(); + let start = std::time::Instant::now(); + let result = tokio::time::timeout(timeout, async { tool.execute(params.clone(), job_ctx).await }) - .await - .map_err(|_| crate::error::ToolError::Timeout { - name: tool_name.to_string(), - timeout: std::time::Duration::from_secs(60), - })? - .map_err(|e| crate::error::ToolError::ExecutionFailed { - name: tool_name.to_string(), - reason: e.to_string(), - })?; + .await; + let elapsed = start.elapsed(); + + match &result { + Ok(Ok(output)) => { + let result_str = serde_json::to_string(&output.result) + .unwrap_or_else(|_| "".to_string()); + tracing::debug!( + tool = %tool_name, + elapsed_ms = elapsed.as_millis() as u64, + result = %result_str, + "Tool call succeeded" + ); + } + Ok(Err(e)) => { + tracing::debug!( + tool = %tool_name, + elapsed_ms = elapsed.as_millis() as u64, + error = %e, + "Tool call failed" + ); + } + Err(_) => { + tracing::debug!( + tool = %tool_name, + elapsed_ms = elapsed.as_millis() as u64, + timeout_secs = timeout.as_secs(), + "Tool call timed out" + ); + } + } + + let result = result + .map_err(|_| crate::error::ToolError::Timeout { + name: tool_name.to_string(), + timeout, + })? + .map_err(|e| crate::error::ToolError::ExecutionFailed { + name: tool_name.to_string(), + reason: e.to_string(), + })?; // Convert result to string serde_json::to_string_pretty(&result.result).map_err(|e| { @@ -1380,10 +1711,11 @@ impl Agent { if let Some((ext_name, instructions)) = detect_auth_awaiting(&pending.tool_name, &tool_result) { + let auth_data = parse_auth_result(&tool_result); { let mut sess = session.lock().await; if let Some(thread) = sess.threads.get_mut(&thread_id) { - thread.enter_auth_mode(ext_name); + thread.enter_auth_mode(ext_name.clone()); thread.complete_turn(&instructions); } } @@ -1391,7 +1723,12 @@ impl Agent { .channels .send_status( &message.channel, - StatusUpdate::Status("Awaiting token".into()), + StatusUpdate::AuthRequired { + extension_name: ext_name, + instructions: Some(instructions.clone()), + auth_url: auth_data.auth_url, + setup_url: auth_data.setup_url, + }, &message.metadata, ) .await; @@ -1434,6 +1771,7 @@ impl Agent { match result { Ok(AgenticLoopResult::Response(response)) => { thread.complete_turn(&response); + self.persist_response_chain(thread); let _ = self .channels .send_status( @@ -1532,16 +1870,6 @@ impl Agent { pending.extension_name ); - // Notify via channel status so the response doesn't echo the token - let _ = self - .channels - .send_status( - &message.channel, - StatusUpdate::Status("Authenticated, loading tools...".into()), - &message.metadata, - ) - .await; - // Auto-activate so tools are available immediately after auth match ext_mgr.activate(&pending.extension_name).await { Ok(activate_result) => { @@ -1551,10 +1879,23 @@ impl Agent { } else { format!("\n\nTools: {}", activate_result.tools_loaded.join(", ")) }; - Ok(Some(format!( + let msg = format!( "{} authenticated and activated ({} tools loaded).{}", pending.extension_name, tool_count, tool_list - ))) + ); + let _ = self + .channels + .send_status( + &message.channel, + StatusUpdate::AuthCompleted { + extension_name: pending.extension_name.clone(), + success: true, + message: msg.clone(), + }, + &message.metadata, + ) + .await; + Ok(Some(msg)) } Err(e) => { tracing::warn!( @@ -1562,16 +1903,29 @@ impl Agent { pending.extension_name, e ); - Ok(Some(format!( + let msg = format!( "{} authenticated successfully, but activation failed: {}. \ Try activating manually.", pending.extension_name, e - ))) + ); + let _ = self + .channels + .send_status( + &message.channel, + StatusUpdate::AuthCompleted { + extension_name: pending.extension_name.clone(), + success: true, + message: msg.clone(), + }, + &message.metadata, + ) + .await; + Ok(Some(msg)) } } } Ok(result) => { - // Unexpected state, re-enter auth mode + // Invalid token, re-enter auth mode { let mut sess = session.lock().await; if let Some(thread) = sess.threads.get_mut(&thread_id) { @@ -1580,13 +1934,43 @@ impl Agent { } let msg = result .instructions + .clone() .unwrap_or_else(|| "Invalid token. Please try again.".to_string()); + // Re-emit AuthRequired so web UI re-shows the card + let _ = self + .channels + .send_status( + &message.channel, + StatusUpdate::AuthRequired { + extension_name: pending.extension_name.clone(), + instructions: Some(msg.clone()), + auth_url: result.auth_url, + setup_url: result.setup_url, + }, + &message.metadata, + ) + .await; + Ok(Some(msg)) + } + Err(e) => { + let msg = format!( + "Authentication failed for {}: {}", + pending.extension_name, e + ); + let _ = self + .channels + .send_status( + &message.channel, + StatusUpdate::AuthCompleted { + extension_name: pending.extension_name.clone(), + success: false, + message: msg.clone(), + }, + &message.metadata, + ) + .await; Ok(Some(msg)) } - Err(e) => Ok(Some(format!( - "Authentication failed for {}: {}", - pending.extension_name, e - ))), } } @@ -1937,40 +2321,49 @@ impl Agent { } } - async fn handle_command( + /// Handle system commands that bypass thread-state checks entirely. + async fn handle_system_command( &self, command: &str, - _args: &[String], - ) -> Result, Error> { + args: &[String], + ) -> Result { match command { - "help" => Ok(Some( - r#"Commands: - /job - Create a job - /status [id] - Check job status - /cancel - Cancel a job - /list - List all jobs - /help - Help a stuck job + "help" => Ok(SubmissionResult::response(concat!( + "System:\n", + " /help Show this help\n", + " /model [name] Show or switch the active model\n", + " /version Show version info\n", + " /tools List available tools\n", + " /debug Toggle debug mode\n", + " /ping Connectivity check\n", + "\n", + "Jobs:\n", + " /job Create a new job\n", + " /status [id] Check job status\n", + " /cancel Cancel a job\n", + " /list List all jobs\n", + "\n", + "Session:\n", + " /undo Undo last turn\n", + " /redo Redo undone turn\n", + " /compact Compress context window\n", + " /clear Clear current thread\n", + " /interrupt Stop current operation\n", + " /new New conversation thread\n", + " /thread Switch to thread\n", + " /resume Resume from checkpoint\n", + "\n", + "Agent:\n", + " /heartbeat Run heartbeat check\n", + " /summarize Summarize current thread\n", + " /suggest Suggest next steps\n", + "\n", + " /quit Exit", + ))), - /undo - Undo last turn - /redo - Redo undone turn - /compact - Compress context - /clear - Clear thread - /interrupt - Stop current turn - /thread new - New thread - /thread - Switch thread - /resume - Resume checkpoint + "ping" => Ok(SubmissionResult::response("pong!")), - /heartbeat - Run heartbeat check now - /summarize - Summarize current thread - /suggest - Suggest next steps - - /quit - Exit"# - .to_string(), - )), - - "ping" => Ok(Some("pong!".to_string())), - - "version" => Ok(Some(format!( + "version" => Ok(SubmissionResult::response(format!( "{} v{}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION") @@ -1978,12 +2371,113 @@ impl Agent { "tools" => { let tools = self.tools().list().await; - Ok(Some(format!("Available tools: {}", tools.join(", ")))) + Ok(SubmissionResult::response(format!( + "Available tools: {}", + tools.join(", ") + ))) } - _ => Ok(Some(format!("Unknown command: {}. Try /help", command))), + "debug" => { + // Debug toggle is handled client-side in the REPL. + // For non-REPL channels, just acknowledge. + Ok(SubmissionResult::ok_with_message( + "Debug toggle is handled by your client.", + )) + } + + "model" => { + if args.is_empty() { + // Show current model + let name = self.llm().active_model_name(); + Ok(SubmissionResult::response(format!( + "Active model: {}", + name + ))) + } else { + let requested = &args[0]; + + // Validate the model exists + match self.llm().list_models().await { + Ok(models) if !models.is_empty() => { + if !models.iter().any(|m| m == requested) { + return Ok(SubmissionResult::error(format!( + "Unknown model: {}. Available models:\n {}", + requested, + models.join("\n ") + ))); + } + } + Ok(_) => { + // Empty model list, can't validate but try anyway + } + Err(e) => { + tracing::warn!("Could not fetch model list for validation: {}", e); + // Proceed anyway, the provider will error on the next call if invalid + } + } + + match self.llm().set_model(requested) { + Ok(()) => Ok(SubmissionResult::response(format!( + "Switched model to: {}", + requested + ))), + Err(e) => Ok(SubmissionResult::error(format!( + "Failed to switch model: {}", + e + ))), + } + } + } + + _ => Ok(SubmissionResult::error(format!( + "Unknown command: {}. Try /help", + command + ))), } } + + /// Handle legacy command routing from the Router (job commands that go through + /// process_user_input -> router -> handle_job_or_command -> here). + async fn handle_command( + &self, + command: &str, + args: &[String], + ) -> Result, Error> { + // System commands are now handled directly via Submission::SystemCommand, + // but the router may still send us unknown /commands. + match self.handle_system_command(command, args).await? { + SubmissionResult::Response { content } => Ok(Some(content)), + SubmissionResult::Ok { message } => Ok(message), + SubmissionResult::Error { message } => Ok(Some(format!("Error: {}", message))), + _ => Ok(None), + } + } +} + +/// Parsed auth result fields for emitting StatusUpdate::AuthRequired. +struct ParsedAuthData { + auth_url: Option, + setup_url: Option, +} + +/// Extract auth_url and setup_url from a tool_auth result JSON string. +fn parse_auth_result(result: &Result) -> ParsedAuthData { + let parsed = result + .as_ref() + .ok() + .and_then(|s| serde_json::from_str::(s).ok()); + ParsedAuthData { + auth_url: parsed + .as_ref() + .and_then(|v| v.get("auth_url")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + setup_url: parsed + .as_ref() + .and_then(|v| v.get("setup_url")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + } } /// Check if a tool_auth result indicates the extension is awaiting a token. @@ -1994,7 +2488,7 @@ fn detect_auth_awaiting( tool_name: &str, result: &Result, ) -> Option<(String, String)> { - if tool_name != "tool_auth" { + if tool_name != "tool_auth" && tool_name != "tool_activate" { return None; } let output = result.as_ref().ok()?; @@ -2078,4 +2572,34 @@ mod tests { let (_, instructions) = detect_auth_awaiting("tool_auth", &result).unwrap(); assert_eq!(instructions, "Please provide your API token/key."); } + + #[test] + fn test_detect_auth_awaiting_tool_activate() { + let result: Result = Ok(serde_json::json!({ + "name": "slack", + "kind": "McpServer", + "awaiting_token": true, + "status": "awaiting_token", + "instructions": "Provide your Slack Bot token." + }) + .to_string()); + + let detected = detect_auth_awaiting("tool_activate", &result); + assert!(detected.is_some()); + let (name, instructions) = detected.unwrap(); + assert_eq!(name, "slack"); + assert!(instructions.contains("Slack Bot")); + } + + #[test] + fn test_detect_auth_awaiting_tool_activate_not_awaiting() { + let result: Result = Ok(serde_json::json!({ + "name": "slack", + "tools_loaded": ["slack_post_message"], + "message": "Activated" + }) + .to_string()); + + assert!(detect_auth_awaiting("tool_activate", &result).is_none()); + } } diff --git a/src/agent/heartbeat.rs b/src/agent/heartbeat.rs index 115b8159..ff35955d 100644 --- a/src/agent/heartbeat.rs +++ b/src/agent/heartbeat.rs @@ -29,7 +29,7 @@ use std::time::Duration; use tokio::sync::mpsc; use crate::channels::OutgoingResponse; -use crate::llm::{ChatMessage, CompletionRequest, LlmProvider}; +use crate::llm::{ChatMessage, CompletionRequest, FinishReason, LlmProvider}; use crate::workspace::Workspace; /// Configuration for the heartbeat runner. @@ -217,9 +217,26 @@ impl HeartbeatRunner { ] }; + // Use the model's context_length to set max_tokens. The API returns + // the total context window; we cap output at half of that (the rest is + // the prompt) with a floor of 4096. + let max_tokens = match self.llm.model_metadata().await { + Ok(meta) => { + let from_api = meta.context_length.map(|ctx| ctx / 2).unwrap_or(4096); + from_api.max(4096) + } + Err(e) => { + tracing::warn!( + "Could not fetch model metadata, using default max_tokens: {}", + e + ); + 4096 + } + }; + let request = CompletionRequest::new(messages) - .with_max_tokens(1024) - .with_temperature(0.3); // Lower temperature for more focused responses + .with_max_tokens(max_tokens) + .with_temperature(0.3); let response = match self.llm.complete(request).await { Ok(r) => r, @@ -228,6 +245,20 @@ impl HeartbeatRunner { let content = response.content.trim(); + // Guard against empty content. Reasoning models (e.g. GLM-4.7) may + // burn all output tokens on chain-of-thought and return content: null. + if content.is_empty() { + return if response.finish_reason == FinishReason::Length { + HeartbeatResult::Failed( + "LLM response was truncated (finish_reason=length) with no content. \ + The model may have exhausted its token budget on reasoning." + .to_string(), + ) + } else { + HeartbeatResult::Failed("LLM returned empty content.".to_string()) + }; + } + // Check if nothing needs attention if content == "HEARTBEAT_OK" || content.contains("HEARTBEAT_OK") { return HeartbeatResult::Ok; diff --git a/src/agent/mod.rs b/src/agent/mod.rs index 7c3d7685..1455c92c 100644 --- a/src/agent/mod.rs +++ b/src/agent/mod.rs @@ -6,6 +6,7 @@ //! - Tool invocation with safety //! - Self-repair for stuck jobs //! - Proactive heartbeat execution +//! - Routine-based scheduled and reactive jobs //! - Turn-based session management with undo //! - Context compaction for long conversations @@ -14,6 +15,8 @@ pub mod compaction; pub mod context_monitor; mod heartbeat; mod router; +pub mod routine; +pub mod routine_engine; mod scheduler; mod self_repair; pub mod session; @@ -28,6 +31,8 @@ pub use compaction::{CompactionResult, ContextCompactor}; pub use context_monitor::{CompactionStrategy, ContextBreakdown, ContextMonitor}; pub use heartbeat::{HeartbeatConfig, HeartbeatResult, HeartbeatRunner, spawn_heartbeat}; pub use router::{MessageIntent, Router}; +pub use routine::{Routine, RoutineAction, RoutineRun, Trigger}; +pub use routine_engine::RoutineEngine; pub use scheduler::Scheduler; pub use self_repair::{BrokenTool, RepairResult, RepairTask, SelfRepair, StuckJob}; pub use session::{PendingApproval, PendingAuth, Session, Thread, ThreadState, Turn, TurnState}; diff --git a/src/agent/routine.rs b/src/agent/routine.rs new file mode 100644 index 00000000..084a9b9f --- /dev/null +++ b/src/agent/routine.rs @@ -0,0 +1,509 @@ +//! Core types for the routines system. +//! +//! A routine is a named, persistent, user-owned task with a trigger and an action. +//! Each routine fires independently when its trigger condition is met, with only +//! that routine's prompt and context sent to the LLM. +//! +//! ```text +//! ┌──────────┐ ┌─────────┐ ┌──────────────────┐ +//! │ Trigger │────▶│ Engine │────▶│ Execution Mode │ +//! │ cron/event│ │guardrail│ │lightweight│full_job│ +//! │ webhook │ │ check │ └──────────────────┘ +//! │ manual │ └─────────┘ │ +//! └──────────┘ ▼ +//! ┌──────────────┐ +//! │ Notify user │ +//! │ if needed │ +//! └──────────────┘ +//! ``` + +use std::collections::hash_map::DefaultHasher; +use std::hash::{Hash, Hasher}; +use std::str::FromStr; +use std::time::Duration; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// A routine is a named, persistent, user-owned task with a trigger and an action. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Routine { + pub id: Uuid, + pub name: String, + pub description: String, + pub user_id: String, + pub enabled: bool, + pub trigger: Trigger, + pub action: RoutineAction, + pub guardrails: RoutineGuardrails, + pub notify: NotifyConfig, + + // Runtime state (DB-managed) + pub last_run_at: Option>, + pub next_fire_at: Option>, + pub run_count: u64, + pub consecutive_failures: u32, + pub state: serde_json::Value, + + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// When a routine should fire. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum Trigger { + /// Fire on a cron schedule (e.g. "0 9 * * MON-FRI" or "every 2h"). + Cron { schedule: String }, + /// Fire when a channel message matches a pattern. + Event { + /// Optional channel filter (e.g. "telegram", "slack"). + channel: Option, + /// Regex pattern to match against message content. + pattern: String, + }, + /// Fire on incoming webhook POST to /hooks/routine/{id}. + Webhook { + /// Optional webhook path suffix (defaults to routine id). + path: Option, + /// Optional shared secret for HMAC validation. + secret: Option, + }, + /// Only fires via tool call or CLI. + Manual, +} + +impl Trigger { + /// The string tag stored in the DB trigger_type column. + pub fn type_tag(&self) -> &'static str { + match self { + Trigger::Cron { .. } => "cron", + Trigger::Event { .. } => "event", + Trigger::Webhook { .. } => "webhook", + Trigger::Manual => "manual", + } + } + + /// Parse a trigger from its DB representation. + pub fn from_db(trigger_type: &str, config: serde_json::Value) -> Result { + match trigger_type { + "cron" => { + let schedule = config + .get("schedule") + .and_then(|v| v.as_str()) + .ok_or("cron trigger missing 'schedule'")? + .to_string(); + Ok(Trigger::Cron { schedule }) + } + "event" => { + let pattern = config + .get("pattern") + .and_then(|v| v.as_str()) + .ok_or("event trigger missing 'pattern'")? + .to_string(); + let channel = config + .get("channel") + .and_then(|v| v.as_str()) + .map(String::from); + Ok(Trigger::Event { channel, pattern }) + } + "webhook" => { + let path = config + .get("path") + .and_then(|v| v.as_str()) + .map(String::from); + let secret = config + .get("secret") + .and_then(|v| v.as_str()) + .map(String::from); + Ok(Trigger::Webhook { path, secret }) + } + "manual" => Ok(Trigger::Manual), + other => Err(format!("unknown trigger type: {other}")), + } + } + + /// Serialize trigger-specific config to JSON for DB storage. + pub fn to_config_json(&self) -> serde_json::Value { + match self { + Trigger::Cron { schedule } => serde_json::json!({ "schedule": schedule }), + Trigger::Event { channel, pattern } => serde_json::json!({ + "pattern": pattern, + "channel": channel, + }), + Trigger::Webhook { path, secret } => serde_json::json!({ + "path": path, + "secret": secret, + }), + Trigger::Manual => serde_json::json!({}), + } + } +} + +/// What happens when a routine fires. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum RoutineAction { + /// Single LLM call, no tools. Cheap and fast. + Lightweight { + /// The prompt sent to the LLM. + prompt: String, + /// Workspace paths to load as context (e.g. ["context/priorities.md"]). + #[serde(default)] + context_paths: Vec, + /// Max output tokens (default: 4096). + #[serde(default = "default_max_tokens")] + max_tokens: u32, + }, + /// Full multi-turn worker job with tool access. + FullJob { + /// Job title for the scheduler. + title: String, + /// Job description / initial prompt. + description: String, + /// Max reasoning iterations (default: 10). + #[serde(default = "default_max_iterations")] + max_iterations: u32, + }, +} + +fn default_max_tokens() -> u32 { + 4096 +} + +fn default_max_iterations() -> u32 { + 10 +} + +impl RoutineAction { + /// The string tag stored in the DB action_type column. + pub fn type_tag(&self) -> &'static str { + match self { + RoutineAction::Lightweight { .. } => "lightweight", + RoutineAction::FullJob { .. } => "full_job", + } + } + + /// Parse an action from its DB representation. + pub fn from_db(action_type: &str, config: serde_json::Value) -> Result { + match action_type { + "lightweight" => { + let prompt = config + .get("prompt") + .and_then(|v| v.as_str()) + .ok_or("lightweight action missing 'prompt'")? + .to_string(); + let context_paths = config + .get("context_paths") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(); + let max_tokens = config + .get("max_tokens") + .and_then(|v| v.as_u64()) + .unwrap_or(default_max_tokens() as u64) as u32; + Ok(RoutineAction::Lightweight { + prompt, + context_paths, + max_tokens, + }) + } + "full_job" => { + let title = config + .get("title") + .and_then(|v| v.as_str()) + .ok_or("full_job action missing 'title'")? + .to_string(); + let description = config + .get("description") + .and_then(|v| v.as_str()) + .ok_or("full_job action missing 'description'")? + .to_string(); + let max_iterations = config + .get("max_iterations") + .and_then(|v| v.as_u64()) + .unwrap_or(default_max_iterations() as u64) + as u32; + Ok(RoutineAction::FullJob { + title, + description, + max_iterations, + }) + } + other => Err(format!("unknown action type: {other}")), + } + } + + /// Serialize action config to JSON for DB storage. + pub fn to_config_json(&self) -> serde_json::Value { + match self { + RoutineAction::Lightweight { + prompt, + context_paths, + max_tokens, + } => serde_json::json!({ + "prompt": prompt, + "context_paths": context_paths, + "max_tokens": max_tokens, + }), + RoutineAction::FullJob { + title, + description, + max_iterations, + } => serde_json::json!({ + "title": title, + "description": description, + "max_iterations": max_iterations, + }), + } + } +} + +/// Guardrails to prevent runaway execution. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RoutineGuardrails { + /// Minimum time between fires. + pub cooldown: Duration, + /// Max simultaneous runs of this routine. + pub max_concurrent: u32, + /// Window for content-hash dedup (event triggers). None = no dedup. + pub dedup_window: Option, +} + +impl Default for RoutineGuardrails { + fn default() -> Self { + Self { + cooldown: Duration::from_secs(300), + max_concurrent: 1, + dedup_window: None, + } + } +} + +/// Notification preferences for a routine. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NotifyConfig { + /// Channel to notify on (None = default/broadcast all). + pub channel: Option, + /// User to notify. + pub user: String, + /// Notify when routine produces actionable output. + pub on_attention: bool, + /// Notify when routine errors. + pub on_failure: bool, + /// Notify when routine runs with no findings. + pub on_success: bool, +} + +impl Default for NotifyConfig { + fn default() -> Self { + Self { + channel: None, + user: "default".to_string(), + on_attention: true, + on_failure: true, + on_success: false, + } + } +} + +/// Status of a routine run. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RunStatus { + Running, + Ok, + Attention, + Failed, +} + +impl std::fmt::Display for RunStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + RunStatus::Running => write!(f, "running"), + RunStatus::Ok => write!(f, "ok"), + RunStatus::Attention => write!(f, "attention"), + RunStatus::Failed => write!(f, "failed"), + } + } +} + +impl FromStr for RunStatus { + type Err = String; + fn from_str(s: &str) -> Result { + match s { + "running" => Ok(RunStatus::Running), + "ok" => Ok(RunStatus::Ok), + "attention" => Ok(RunStatus::Attention), + "failed" => Ok(RunStatus::Failed), + other => Err(format!("unknown run status: {other}")), + } + } +} + +/// A single execution of a routine. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RoutineRun { + pub id: Uuid, + pub routine_id: Uuid, + pub trigger_type: String, + pub trigger_detail: Option, + pub started_at: DateTime, + pub completed_at: Option>, + pub status: RunStatus, + pub result_summary: Option, + pub tokens_used: Option, + pub job_id: Option, + pub created_at: DateTime, +} + +/// Compute a content hash for event dedup. +pub fn content_hash(content: &str) -> u64 { + let mut hasher = DefaultHasher::new(); + content.hash(&mut hasher); + hasher.finish() +} + +/// Parse a cron expression and compute the next fire time from now. +pub fn next_cron_fire(schedule: &str) -> Result>, String> { + let cron_schedule = + cron::Schedule::from_str(schedule).map_err(|e| format!("invalid cron: {e}"))?; + Ok(cron_schedule.upcoming(Utc).next()) +} + +#[cfg(test)] +mod tests { + use crate::agent::routine::{ + RoutineAction, RoutineGuardrails, RunStatus, Trigger, content_hash, next_cron_fire, + }; + + #[test] + fn test_trigger_roundtrip() { + let trigger = Trigger::Cron { + schedule: "0 9 * * MON-FRI".to_string(), + }; + let json = trigger.to_config_json(); + let parsed = Trigger::from_db("cron", json).expect("parse cron"); + assert!(matches!(parsed, Trigger::Cron { schedule } if schedule == "0 9 * * MON-FRI")); + } + + #[test] + fn test_event_trigger_roundtrip() { + let trigger = Trigger::Event { + channel: Some("telegram".to_string()), + pattern: r"deploy\s+\w+".to_string(), + }; + let json = trigger.to_config_json(); + let parsed = Trigger::from_db("event", json).expect("parse event"); + assert!(matches!(parsed, Trigger::Event { channel, pattern } + if channel == Some("telegram".to_string()) && pattern == r"deploy\s+\w+")); + } + + #[test] + fn test_action_lightweight_roundtrip() { + let action = RoutineAction::Lightweight { + prompt: "Check PRs".to_string(), + context_paths: vec!["context/priorities.md".to_string()], + max_tokens: 2048, + }; + let json = action.to_config_json(); + let parsed = RoutineAction::from_db("lightweight", json).expect("parse lightweight"); + assert!( + matches!(parsed, RoutineAction::Lightweight { prompt, context_paths, max_tokens } + if prompt == "Check PRs" && context_paths.len() == 1 && max_tokens == 2048) + ); + } + + #[test] + fn test_action_full_job_roundtrip() { + let action = RoutineAction::FullJob { + title: "Deploy review".to_string(), + description: "Review and deploy pending changes".to_string(), + max_iterations: 5, + }; + let json = action.to_config_json(); + let parsed = RoutineAction::from_db("full_job", json).expect("parse full_job"); + assert!( + matches!(parsed, RoutineAction::FullJob { title, max_iterations, .. } + if title == "Deploy review" && max_iterations == 5) + ); + } + + #[test] + fn test_run_status_display_parse() { + for status in [ + RunStatus::Running, + RunStatus::Ok, + RunStatus::Attention, + RunStatus::Failed, + ] { + let s = status.to_string(); + let parsed: RunStatus = s.parse().expect("parse status"); + assert_eq!(parsed, status); + } + } + + #[test] + fn test_content_hash_deterministic() { + let h1 = content_hash("deploy production"); + let h2 = content_hash("deploy production"); + assert_eq!(h1, h2); + + let h3 = content_hash("deploy staging"); + assert_ne!(h1, h3); + } + + #[test] + fn test_next_cron_fire_valid() { + // Every minute should always have a next fire + let next = next_cron_fire("* * * * * *").expect("valid cron"); + assert!(next.is_some()); + } + + #[test] + fn test_next_cron_fire_invalid() { + let result = next_cron_fire("not a cron"); + assert!(result.is_err()); + } + + #[test] + fn test_guardrails_default() { + let g = RoutineGuardrails::default(); + assert_eq!(g.cooldown.as_secs(), 300); + assert_eq!(g.max_concurrent, 1); + assert!(g.dedup_window.is_none()); + } + + #[test] + fn test_trigger_type_tag() { + assert_eq!( + Trigger::Cron { + schedule: String::new() + } + .type_tag(), + "cron" + ); + assert_eq!( + Trigger::Event { + channel: None, + pattern: String::new() + } + .type_tag(), + "event" + ); + assert_eq!( + Trigger::Webhook { + path: None, + secret: None + } + .type_tag(), + "webhook" + ); + assert_eq!(Trigger::Manual.type_tag(), "manual"); + } +} diff --git a/src/agent/routine_engine.rs b/src/agent/routine_engine.rs new file mode 100644 index 00000000..6a35e879 --- /dev/null +++ b/src/agent/routine_engine.rs @@ -0,0 +1,606 @@ +//! Routine execution engine. +//! +//! Handles loading routines, checking triggers, enforcing guardrails, +//! and executing both lightweight (single LLM call) and full-job routines. +//! +//! The engine runs two independent loops: +//! - A **cron ticker** that polls the DB every N seconds for due cron routines +//! - An **event matcher** called synchronously from the agent main loop +//! +//! Lightweight routines execute inline (single LLM call, no scheduler slot). +//! Full-job routines are delegated to the existing `Scheduler`. + +use std::sync::Arc; +use std::time::Duration; + +use chrono::Utc; +use regex::Regex; +use tokio::sync::{RwLock, mpsc}; +use uuid::Uuid; + +use crate::agent::routine::{ + NotifyConfig, Routine, RoutineAction, RoutineRun, RunStatus, Trigger, next_cron_fire, +}; +use crate::channels::{IncomingMessage, OutgoingResponse}; +use crate::config::RoutineConfig; +use crate::history::Store; +use crate::llm::{ChatMessage, CompletionRequest, FinishReason, LlmProvider}; +use crate::workspace::Workspace; + +/// The routine execution engine. +pub struct RoutineEngine { + config: RoutineConfig, + store: Arc, + llm: Arc, + workspace: Arc, + /// Sender for notifications (routed to channel manager). + notify_tx: mpsc::Sender, + /// Currently running routine count (across all routines). + running_count: Arc>, + /// Compiled event regex cache: routine_id -> compiled regex. + event_cache: Arc>>, +} + +impl RoutineEngine { + pub fn new( + config: RoutineConfig, + store: Arc, + llm: Arc, + workspace: Arc, + notify_tx: mpsc::Sender, + ) -> Self { + Self { + config, + store, + llm, + workspace, + notify_tx, + running_count: Arc::new(RwLock::new(0)), + event_cache: Arc::new(RwLock::new(Vec::new())), + } + } + + /// Refresh the in-memory event trigger cache from DB. + pub async fn refresh_event_cache(&self) { + match self.store.list_event_routines().await { + Ok(routines) => { + let mut cache = Vec::new(); + for routine in routines { + if let Trigger::Event { ref pattern, .. } = routine.trigger { + match Regex::new(pattern) { + Ok(re) => cache.push((routine.id, routine.clone(), re)), + Err(e) => { + tracing::warn!( + routine = %routine.name, + "Invalid event regex '{}': {}", + pattern, e + ); + } + } + } + } + let count = cache.len(); + *self.event_cache.write().await = cache; + tracing::debug!("Refreshed event cache: {} routines", count); + } + Err(e) => { + tracing::error!("Failed to refresh event cache: {}", e); + } + } + } + + /// Check incoming message against event triggers. Returns number of routines fired. + /// + /// Called synchronously from the main loop after handle_message(). The actual + /// execution is spawned async so this returns quickly. + pub async fn check_event_triggers(&self, message: &IncomingMessage) -> usize { + let cache = self.event_cache.read().await; + let mut fired = 0; + + for (_, routine, re) in cache.iter() { + // Channel filter + if let Trigger::Event { + channel: Some(ch), .. + } = &routine.trigger + { + if ch != &message.channel { + continue; + } + } + + // Regex match + if !re.is_match(&message.content) { + continue; + } + + // Cooldown check + if !self.check_cooldown(routine) { + tracing::debug!(routine = %routine.name, "Skipped: cooldown active"); + continue; + } + + // Concurrent run check + if !self.check_concurrent(routine).await { + tracing::debug!(routine = %routine.name, "Skipped: max concurrent reached"); + continue; + } + + // Global capacity check + if *self.running_count.read().await >= self.config.max_concurrent_routines { + tracing::warn!(routine = %routine.name, "Skipped: global max concurrent reached"); + continue; + } + + let detail = truncate(&message.content, 200); + self.spawn_fire(routine.clone(), "event", Some(detail)); + fired += 1; + } + + fired + } + + /// Check all due cron routines and fire them. Called by the cron ticker. + pub async fn check_cron_triggers(&self) { + let routines = match self.store.list_due_cron_routines().await { + Ok(r) => r, + Err(e) => { + tracing::error!("Failed to load due cron routines: {}", e); + return; + } + }; + + for routine in routines { + if *self.running_count.read().await >= self.config.max_concurrent_routines { + tracing::warn!("Global max concurrent routines reached, skipping remaining"); + break; + } + + if !self.check_cooldown(&routine) { + continue; + } + + if !self.check_concurrent(&routine).await { + continue; + } + + let detail = if let Trigger::Cron { ref schedule } = routine.trigger { + Some(schedule.clone()) + } else { + None + }; + + self.spawn_fire(routine, "cron", detail); + } + } + + /// Fire a routine manually (from tool call or CLI). + pub async fn fire_manual(&self, routine_id: Uuid) -> Result { + let routine = self + .store + .get_routine(routine_id) + .await + .map_err(|e| format!("DB error: {e}"))? + .ok_or_else(|| format!("routine {routine_id} not found"))?; + + if !routine.enabled { + return Err(format!("routine '{}' is disabled", routine.name)); + } + + if !self.check_concurrent(&routine).await { + return Err(format!( + "routine '{}' already at max concurrent runs", + routine.name + )); + } + + let run_id = Uuid::new_v4(); + let run = RoutineRun { + id: run_id, + routine_id: routine.id, + trigger_type: "manual".to_string(), + trigger_detail: None, + started_at: Utc::now(), + completed_at: None, + status: RunStatus::Running, + result_summary: None, + tokens_used: None, + job_id: None, + created_at: Utc::now(), + }; + + if let Err(e) = self.store.create_routine_run(&run).await { + return Err(format!("failed to create run record: {e}")); + } + + // Execute inline for manual triggers (caller wants to wait) + let engine = EngineContext { + store: self.store.clone(), + llm: self.llm.clone(), + workspace: self.workspace.clone(), + notify_tx: self.notify_tx.clone(), + running_count: self.running_count.clone(), + max_lightweight_tokens: self.config.max_lightweight_tokens, + }; + + tokio::spawn(async move { + execute_routine(engine, routine, run).await; + }); + + Ok(run_id) + } + + /// Spawn a fire in a background task. + fn spawn_fire(&self, routine: Routine, trigger_type: &str, trigger_detail: Option) { + let run = RoutineRun { + id: Uuid::new_v4(), + routine_id: routine.id, + trigger_type: trigger_type.to_string(), + trigger_detail, + started_at: Utc::now(), + completed_at: None, + status: RunStatus::Running, + result_summary: None, + tokens_used: None, + job_id: None, + created_at: Utc::now(), + }; + + let engine = EngineContext { + store: self.store.clone(), + llm: self.llm.clone(), + workspace: self.workspace.clone(), + notify_tx: self.notify_tx.clone(), + running_count: self.running_count.clone(), + max_lightweight_tokens: self.config.max_lightweight_tokens, + }; + + // Record the run in DB, then spawn execution + let store = self.store.clone(); + tokio::spawn(async move { + if let Err(e) = store.create_routine_run(&run).await { + tracing::error!(routine = %routine.name, "Failed to record run: {}", e); + return; + } + execute_routine(engine, routine, run).await; + }); + } + + fn check_cooldown(&self, routine: &Routine) -> bool { + if let Some(last_run) = routine.last_run_at { + let elapsed = Utc::now().signed_duration_since(last_run); + let cooldown = chrono::Duration::from_std(routine.guardrails.cooldown) + .unwrap_or(chrono::Duration::seconds(300)); + if elapsed < cooldown { + return false; + } + } + true + } + + async fn check_concurrent(&self, routine: &Routine) -> bool { + match self.store.count_running_routine_runs(routine.id).await { + Ok(count) => count < routine.guardrails.max_concurrent as i64, + Err(e) => { + tracing::error!( + routine = %routine.name, + "Failed to check concurrent runs: {}", e + ); + false + } + } + } +} + +/// Shared context passed to the execution function. +struct EngineContext { + store: Arc, + llm: Arc, + workspace: Arc, + notify_tx: mpsc::Sender, + running_count: Arc>, + max_lightweight_tokens: u32, +} + +/// Execute a routine run. Handles both lightweight and full_job modes. +async fn execute_routine(ctx: EngineContext, routine: Routine, run: RoutineRun) { + // Increment running count + { + let mut count = ctx.running_count.write().await; + *count += 1; + } + + let result = match &routine.action { + RoutineAction::Lightweight { + prompt, + context_paths, + max_tokens, + } => execute_lightweight(&ctx, &routine, prompt, context_paths, *max_tokens).await, + RoutineAction::FullJob { description, .. } => { + // Full job mode: for now, execute as lightweight with the description + // as prompt. Full scheduler integration will come as a follow-up. + tracing::info!( + routine = %routine.name, + "FullJob mode executing as lightweight (scheduler integration pending)" + ); + execute_lightweight(&ctx, &routine, description, &[], ctx.max_lightweight_tokens).await + } + }; + + // Decrement running count + { + let mut count = ctx.running_count.write().await; + *count = count.saturating_sub(1); + } + + // Process result + let (status, summary, tokens) = match result { + Ok(execution) => execution, + Err(e) => { + tracing::error!(routine = %routine.name, "Execution failed: {}", e); + (RunStatus::Failed, Some(e), None) + } + }; + + // Complete the run record + if let Err(e) = ctx + .store + .complete_routine_run(run.id, status, summary.as_deref(), tokens) + .await + { + tracing::error!(routine = %routine.name, "Failed to complete run record: {}", e); + } + + // Update routine runtime state + let now = Utc::now(); + let next_fire = if let Trigger::Cron { ref schedule } = routine.trigger { + next_cron_fire(schedule).unwrap_or(None) + } else { + None + }; + + let new_failures = if status == RunStatus::Failed { + routine.consecutive_failures + 1 + } else { + 0 + }; + + if let Err(e) = ctx + .store + .update_routine_runtime( + routine.id, + now, + next_fire, + routine.run_count + 1, + new_failures, + &routine.state, + ) + .await + { + tracing::error!(routine = %routine.name, "Failed to update runtime state: {}", e); + } + + // Send notifications based on config + send_notification( + &ctx.notify_tx, + &routine.notify, + &routine.name, + status, + summary.as_deref(), + ) + .await; +} + +/// Execute a lightweight routine (single LLM call). +async fn execute_lightweight( + ctx: &EngineContext, + routine: &Routine, + prompt: &str, + context_paths: &[String], + max_tokens: u32, +) -> Result<(RunStatus, Option, Option), String> { + // Load context from workspace + let mut context_parts = Vec::new(); + for path in context_paths { + match ctx.workspace.read(path).await { + Ok(doc) => { + context_parts.push(format!("## {}\n\n{}", path, doc.content)); + } + Err(e) => { + tracing::debug!( + routine = %routine.name, + "Failed to read context path {}: {}", path, e + ); + } + } + } + + // Load routine state from workspace + let state_path = format!("routines/{}/state.md", routine.name); + let state_content = match ctx.workspace.read(&state_path).await { + Ok(doc) => Some(doc.content), + Err(_) => None, + }; + + // Build the prompt + let mut full_prompt = String::new(); + full_prompt.push_str(prompt); + + if !context_parts.is_empty() { + full_prompt.push_str("\n\n---\n\n# Context\n\n"); + full_prompt.push_str(&context_parts.join("\n\n")); + } + + if let Some(state) = &state_content { + full_prompt.push_str("\n\n---\n\n# Previous State\n\n"); + full_prompt.push_str(state); + } + + full_prompt.push_str( + "\n\n---\n\nIf nothing needs attention, reply EXACTLY with: ROUTINE_OK\n\ + If something needs attention, provide a concise summary.", + ); + + // Get system prompt + let system_prompt = match ctx.workspace.system_prompt().await { + Ok(p) => p, + Err(e) => { + tracing::warn!(routine = %routine.name, "Failed to get system prompt: {}", e); + String::new() + } + }; + + let messages = if system_prompt.is_empty() { + vec![ChatMessage::user(&full_prompt)] + } else { + vec![ + ChatMessage::system(&system_prompt), + ChatMessage::user(&full_prompt), + ] + }; + + // Determine max_tokens from model metadata with fallback + let effective_max_tokens = match ctx.llm.model_metadata().await { + Ok(meta) => { + let from_api = meta.context_length.map(|ctx| ctx / 2).unwrap_or(max_tokens); + from_api.max(max_tokens) + } + Err(_) => max_tokens, + }; + + let request = CompletionRequest::new(messages) + .with_max_tokens(effective_max_tokens) + .with_temperature(0.3); + + let response = ctx + .llm + .complete(request) + .await + .map_err(|e| format!("LLM call failed: {e}"))?; + + let content = response.content.trim(); + let tokens_used = Some((response.input_tokens + response.output_tokens) as i32); + + // Empty content guard (same as heartbeat) + if content.is_empty() { + return if response.finish_reason == FinishReason::Length { + Err( + "LLM response truncated (finish_reason=length) with no content. \ + Model may have exhausted token budget on reasoning." + .to_string(), + ) + } else { + Err("LLM returned empty content.".to_string()) + }; + } + + // Check for the "nothing to do" sentinel + if content == "ROUTINE_OK" || content.contains("ROUTINE_OK") { + return Ok((RunStatus::Ok, None, tokens_used)); + } + + Ok((RunStatus::Attention, Some(content.to_string()), tokens_used)) +} + +/// Send a notification based on the routine's notify config and run status. +async fn send_notification( + tx: &mpsc::Sender, + notify: &NotifyConfig, + routine_name: &str, + status: RunStatus, + summary: Option<&str>, +) { + let should_notify = match status { + RunStatus::Ok => notify.on_success, + RunStatus::Attention => notify.on_attention, + RunStatus::Failed => notify.on_failure, + RunStatus::Running => false, + }; + + if !should_notify { + return; + } + + let icon = match status { + RunStatus::Ok => "✅", + RunStatus::Attention => "🔔", + RunStatus::Failed => "❌", + RunStatus::Running => "⏳", + }; + + let message = match summary { + Some(s) => format!("{} *Routine '{}'*: {}\n\n{}", icon, routine_name, status, s), + None => format!("{} *Routine '{}'*: {}", icon, routine_name, status), + }; + + let response = OutgoingResponse { + content: message, + thread_id: None, + metadata: serde_json::json!({ + "source": "routine", + "routine_name": routine_name, + "status": status.to_string(), + }), + }; + + if let Err(e) = tx.send(response).await { + tracing::error!(routine = %routine_name, "Failed to send notification: {}", e); + } +} + +/// Spawn the cron ticker background task. +pub fn spawn_cron_ticker( + engine: Arc, + interval: Duration, +) -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { + let mut ticker = tokio::time::interval(interval); + // Skip immediate first tick + ticker.tick().await; + + loop { + ticker.tick().await; + engine.check_cron_triggers().await; + } + }) +} + +fn truncate(s: &str, max: usize) -> String { + if s.len() <= max { + s.to_string() + } else { + format!("{}...", &s[..max]) + } +} + +#[cfg(test)] +mod tests { + use crate::agent::routine::{NotifyConfig, RunStatus}; + + #[test] + fn test_notification_gating() { + let config = NotifyConfig { + on_success: false, + on_failure: true, + on_attention: true, + ..Default::default() + }; + + // on_success = false means Ok status should not notify + assert!(!config.on_success); + assert!(config.on_failure); + assert!(config.on_attention); + } + + #[test] + fn test_run_status_icons() { + // Just verify the mapping doesn't panic + for status in [ + RunStatus::Ok, + RunStatus::Attention, + RunStatus::Failed, + RunStatus::Running, + ] { + let _ = status.to_string(); + } + } +} diff --git a/src/agent/scheduler.rs b/src/agent/scheduler.rs index a88df10a..5175e3b2 100644 --- a/src/agent/scheduler.rs +++ b/src/agent/scheduler.rs @@ -373,23 +373,23 @@ impl Scheduler { .into()); } - // Execute with timeout - let result = tokio::time::timeout(Duration::from_secs(60), async { - tool.execute(params, &job_ctx).await - }) - .await - .map_err(|_| { - Error::Tool(crate::error::ToolError::Timeout { - name: tool_name.to_string(), - timeout: Duration::from_secs(60), - }) - })? - .map_err(|e| { - Error::Tool(crate::error::ToolError::ExecutionFailed { - name: tool_name.to_string(), - reason: e.to_string(), - }) - })?; + // Execute with per-tool timeout + let tool_timeout = tool.execution_timeout(); + let result = + tokio::time::timeout(tool_timeout, async { tool.execute(params, &job_ctx).await }) + .await + .map_err(|_| { + Error::Tool(crate::error::ToolError::Timeout { + name: tool_name.to_string(), + timeout: tool_timeout, + }) + })? + .map_err(|e| { + Error::Tool(crate::error::ToolError::ExecutionFailed { + name: tool_name.to_string(), + reason: e.to_string(), + }) + })?; Ok(TaskOutput::new(result.result, start.elapsed())) } diff --git a/src/agent/session.rs b/src/agent/session.rs index a40d3fbc..fbfb4aa3 100644 --- a/src/agent/session.rs +++ b/src/agent/session.rs @@ -173,6 +173,10 @@ pub struct Thread { /// Pending auth token request (thread is in auth mode). #[serde(default)] pub pending_auth: Option, + /// Last NEAR AI response ID for response chaining. Persisted to DB + /// metadata so we can resume chaining across restarts. + #[serde(default)] + pub last_response_id: Option, } impl Thread { @@ -189,6 +193,24 @@ impl Thread { metadata: serde_json::Value::Null, pending_approval: None, pending_auth: None, + last_response_id: None, + } + } + + /// Create a thread with a specific ID (for DB hydration). + pub fn with_id(id: Uuid, session_id: Uuid) -> Self { + let now = Utc::now(); + Self { + id, + session_id, + state: ThreadState::Idle, + turns: Vec::new(), + created_at: now, + updated_at: now, + metadata: serde_json::Value::Null, + pending_approval: None, + pending_auth: None, + last_response_id: None, } } @@ -593,4 +615,386 @@ mod tests { let restored: Thread = serde_json::from_str(&json).expect("should deserialize"); assert!(restored.pending_auth.is_none()); } + + #[test] + fn test_thread_with_id() { + let specific_id = Uuid::new_v4(); + let session_id = Uuid::new_v4(); + let thread = Thread::with_id(specific_id, session_id); + + assert_eq!(thread.id, specific_id); + assert_eq!(thread.session_id, session_id); + assert_eq!(thread.state, ThreadState::Idle); + assert!(thread.turns.is_empty()); + } + + #[test] + fn test_thread_with_id_restore_messages() { + let thread_id = Uuid::new_v4(); + let session_id = Uuid::new_v4(); + let mut thread = Thread::with_id(thread_id, session_id); + + let messages = vec![ + ChatMessage::user("Hello from DB"), + ChatMessage::assistant("Restored response"), + ]; + thread.restore_from_messages(messages); + + assert_eq!(thread.id, thread_id); + assert_eq!(thread.turns.len(), 1); + assert_eq!(thread.turns[0].user_input, "Hello from DB"); + assert_eq!( + thread.turns[0].response, + Some("Restored response".to_string()) + ); + } + + #[test] + fn test_restore_from_messages_empty() { + let mut thread = Thread::new(Uuid::new_v4()); + + // Add a turn first, then restore with empty vec + thread.start_turn("hello"); + thread.complete_turn("hi"); + assert_eq!(thread.turns.len(), 1); + + thread.restore_from_messages(Vec::new()); + + // Should clear all turns and stay idle + assert!(thread.turns.is_empty()); + assert_eq!(thread.state, ThreadState::Idle); + } + + #[test] + fn test_restore_from_messages_only_assistant_messages() { + let mut thread = Thread::new(Uuid::new_v4()); + + // Only assistant messages (no user messages to anchor turns) + let messages = vec![ + ChatMessage::assistant("I'm here"), + ChatMessage::assistant("Still here"), + ]; + + thread.restore_from_messages(messages); + + // Assistant-only messages have no user turn to attach to, so + // they should be skipped entirely. + assert!(thread.turns.is_empty()); + } + + #[test] + fn test_restore_from_messages_multiple_user_messages_in_a_row() { + let mut thread = Thread::new(Uuid::new_v4()); + + // Two user messages with no assistant response between them + let messages = vec![ + ChatMessage::user("first"), + ChatMessage::user("second"), + ChatMessage::assistant("reply to second"), + ]; + + thread.restore_from_messages(messages); + + // First user message becomes a turn with no response, + // second user message pairs with the assistant response. + assert_eq!(thread.turns.len(), 2); + assert_eq!(thread.turns[0].user_input, "first"); + assert!(thread.turns[0].response.is_none()); + assert_eq!(thread.turns[1].user_input, "second"); + assert_eq!( + thread.turns[1].response, + Some("reply to second".to_string()) + ); + } + + #[test] + fn test_thread_switch() { + let mut session = Session::new("user-1"); + + let t1_id = session.create_thread().id; + let t2_id = session.create_thread().id; + + // After creating two threads, active should be the last one + assert_eq!(session.active_thread, Some(t2_id)); + + // Switch back to the first + assert!(session.switch_thread(t1_id)); + assert_eq!(session.active_thread, Some(t1_id)); + + // Switching to a nonexistent thread should fail + let fake_id = Uuid::new_v4(); + assert!(!session.switch_thread(fake_id)); + // Active thread should remain unchanged + assert_eq!(session.active_thread, Some(t1_id)); + } + + #[test] + fn test_get_or_create_thread_idempotent() { + let mut session = Session::new("user-1"); + + let tid1 = session.get_or_create_thread().id; + let tid2 = session.get_or_create_thread().id; + + // Should return the same thread (not create a new one each time) + assert_eq!(tid1, tid2); + assert_eq!(session.threads.len(), 1); + } + + #[test] + fn test_truncate_turns() { + let mut thread = Thread::new(Uuid::new_v4()); + + for i in 0..5 { + thread.start_turn(format!("msg-{}", i)); + thread.complete_turn(format!("resp-{}", i)); + } + assert_eq!(thread.turns.len(), 5); + + thread.truncate_turns(3); + assert_eq!(thread.turns.len(), 3); + + // Should keep the most recent turns + assert_eq!(thread.turns[0].user_input, "msg-2"); + assert_eq!(thread.turns[1].user_input, "msg-3"); + assert_eq!(thread.turns[2].user_input, "msg-4"); + + // Turn numbers should be re-indexed + assert_eq!(thread.turns[0].turn_number, 0); + assert_eq!(thread.turns[1].turn_number, 1); + assert_eq!(thread.turns[2].turn_number, 2); + } + + #[test] + fn test_truncate_turns_noop_when_fewer() { + let mut thread = Thread::new(Uuid::new_v4()); + + thread.start_turn("only one"); + thread.complete_turn("response"); + + thread.truncate_turns(10); + assert_eq!(thread.turns.len(), 1); + assert_eq!(thread.turns[0].user_input, "only one"); + } + + #[test] + fn test_thread_interrupt_and_resume() { + let mut thread = Thread::new(Uuid::new_v4()); + + thread.start_turn("do something"); + assert_eq!(thread.state, ThreadState::Processing); + + thread.interrupt(); + assert_eq!(thread.state, ThreadState::Interrupted); + + let last_turn = thread.last_turn().unwrap(); + assert_eq!(last_turn.state, TurnState::Interrupted); + assert!(last_turn.completed_at.is_some()); + + thread.resume(); + assert_eq!(thread.state, ThreadState::Idle); + } + + #[test] + fn test_resume_only_from_interrupted() { + let mut thread = Thread::new(Uuid::new_v4()); + + // Idle thread: resume should be a no-op + assert_eq!(thread.state, ThreadState::Idle); + thread.resume(); + assert_eq!(thread.state, ThreadState::Idle); + + // Processing thread: resume should not change state + thread.start_turn("work"); + assert_eq!(thread.state, ThreadState::Processing); + thread.resume(); + assert_eq!(thread.state, ThreadState::Processing); + } + + #[test] + fn test_turn_fail() { + let mut thread = Thread::new(Uuid::new_v4()); + + thread.start_turn("risky operation"); + thread.fail_turn("connection timed out"); + + assert_eq!(thread.state, ThreadState::Idle); + + let turn = thread.last_turn().unwrap(); + assert_eq!(turn.state, TurnState::Failed); + assert_eq!(turn.error, Some("connection timed out".to_string())); + assert!(turn.response.is_none()); + assert!(turn.completed_at.is_some()); + } + + #[test] + fn test_messages_with_incomplete_last_turn() { + let mut thread = Thread::new(Uuid::new_v4()); + + thread.start_turn("first"); + thread.complete_turn("first reply"); + thread.start_turn("second (in progress)"); + + let messages = thread.messages(); + // Should have 3 messages: user, assistant, user (no assistant for in-progress) + assert_eq!(messages.len(), 3); + assert_eq!(messages[0].content, "first"); + assert_eq!(messages[1].content, "first reply"); + assert_eq!(messages[2].content, "second (in progress)"); + } + + #[test] + fn test_thread_serialization_round_trip() { + let mut thread = Thread::new(Uuid::new_v4()); + + thread.start_turn("hello"); + thread.complete_turn("world"); + thread.last_response_id = Some("resp_abc123".to_string()); + + let json = serde_json::to_string(&thread).unwrap(); + let restored: Thread = serde_json::from_str(&json).unwrap(); + + assert_eq!(restored.id, thread.id); + assert_eq!(restored.session_id, thread.session_id); + assert_eq!(restored.turns.len(), 1); + assert_eq!(restored.turns[0].user_input, "hello"); + assert_eq!(restored.turns[0].response, Some("world".to_string())); + assert_eq!(restored.last_response_id, Some("resp_abc123".to_string())); + } + + #[test] + fn test_session_serialization_round_trip() { + let mut session = Session::new("user-ser"); + session.create_thread(); + session.auto_approve_tool("echo"); + + let json = serde_json::to_string(&session).unwrap(); + let restored: Session = serde_json::from_str(&json).unwrap(); + + assert_eq!(restored.user_id, "user-ser"); + assert_eq!(restored.threads.len(), 1); + assert!(restored.is_tool_auto_approved("echo")); + assert!(!restored.is_tool_auto_approved("shell")); + } + + #[test] + fn test_auto_approved_tools() { + let mut session = Session::new("user-1"); + + assert!(!session.is_tool_auto_approved("shell")); + session.auto_approve_tool("shell"); + assert!(session.is_tool_auto_approved("shell")); + + // Idempotent + session.auto_approve_tool("shell"); + assert_eq!(session.auto_approved_tools.len(), 1); + } + + #[test] + fn test_turn_tool_call_error() { + let mut turn = Turn::new(0, "test"); + turn.record_tool_call("http", serde_json::json!({"url": "example.com"})); + turn.record_tool_error("timeout"); + + assert_eq!(turn.tool_calls.len(), 1); + assert_eq!(turn.tool_calls[0].error, Some("timeout".to_string())); + assert!(turn.tool_calls[0].result.is_none()); + } + + #[test] + fn test_turn_number_increments() { + let mut thread = Thread::new(Uuid::new_v4()); + + // Before any turns, turn_number() is 1 (1-indexed for display) + assert_eq!(thread.turn_number(), 1); + + thread.start_turn("first"); + thread.complete_turn("done"); + assert_eq!(thread.turn_number(), 2); + + thread.start_turn("second"); + assert_eq!(thread.turn_number(), 3); + } + + #[test] + fn test_complete_turn_on_empty_thread() { + let mut thread = Thread::new(Uuid::new_v4()); + + // Completing a turn when there are no turns should be a safe no-op + thread.complete_turn("phantom response"); + assert_eq!(thread.state, ThreadState::Idle); + assert!(thread.turns.is_empty()); + } + + #[test] + fn test_fail_turn_on_empty_thread() { + let mut thread = Thread::new(Uuid::new_v4()); + + // Failing a turn when there are no turns should be a safe no-op + thread.fail_turn("phantom error"); + assert_eq!(thread.state, ThreadState::Idle); + assert!(thread.turns.is_empty()); + } + + #[test] + fn test_pending_approval_flow() { + let mut thread = Thread::new(Uuid::new_v4()); + + let approval = PendingApproval { + request_id: Uuid::new_v4(), + tool_name: "shell".to_string(), + parameters: serde_json::json!({"command": "rm -rf /"}), + description: "dangerous command".to_string(), + tool_call_id: "call_123".to_string(), + context_messages: vec![ChatMessage::user("do it")], + }; + + thread.await_approval(approval); + assert_eq!(thread.state, ThreadState::AwaitingApproval); + assert!(thread.pending_approval.is_some()); + + let taken = thread.take_pending_approval(); + assert!(taken.is_some()); + assert_eq!(taken.unwrap().tool_name, "shell"); + assert!(thread.pending_approval.is_none()); + } + + #[test] + fn test_clear_pending_approval() { + let mut thread = Thread::new(Uuid::new_v4()); + + let approval = PendingApproval { + request_id: Uuid::new_v4(), + tool_name: "http".to_string(), + parameters: serde_json::json!({}), + description: "test".to_string(), + tool_call_id: "call_456".to_string(), + context_messages: vec![], + }; + + thread.await_approval(approval); + thread.clear_pending_approval(); + + assert_eq!(thread.state, ThreadState::Idle); + assert!(thread.pending_approval.is_none()); + } + + #[test] + fn test_active_thread_accessors() { + let mut session = Session::new("user-1"); + + assert!(session.active_thread().is_none()); + assert!(session.active_thread_mut().is_none()); + + let tid = session.create_thread().id; + + assert!(session.active_thread().is_some()); + assert_eq!(session.active_thread().unwrap().id, tid); + + // Mutably modify through accessor + session.active_thread_mut().unwrap().start_turn("test"); + assert_eq!( + session.active_thread().unwrap().state, + ThreadState::Processing + ); + } } diff --git a/src/agent/session_manager.rs b/src/agent/session_manager.rs index dcf46343..2ea6bfa1 100644 --- a/src/agent/session_manager.rs +++ b/src/agent/session_manager.rs @@ -110,6 +110,41 @@ impl SessionManager { (session, thread_id) } + /// Register a hydrated thread so subsequent `resolve_thread` calls find it. + /// + /// Inserts into the thread_map and creates an undo manager for the thread. + pub async fn register_thread( + &self, + user_id: &str, + channel: &str, + thread_id: Uuid, + session: Arc>, + ) { + let key = ThreadKey { + user_id: user_id.to_string(), + channel: channel.to_string(), + external_thread_id: Some(thread_id.to_string()), + }; + + { + let mut thread_map = self.thread_map.write().await; + thread_map.insert(key, thread_id); + } + + { + let mut undo_managers = self.undo_managers.write().await; + undo_managers + .entry(thread_id) + .or_insert_with(|| Arc::new(Mutex::new(UndoManager::new()))); + } + + // Ensure the session is tracked + { + let mut sessions = self.sessions.write().await; + sessions.entry(user_id.to_string()).or_insert(session); + } + } + /// Get undo manager for a thread. pub async fn get_undo_manager(&self, thread_id: Uuid) -> Arc> { // Fast path @@ -296,4 +331,344 @@ mod tests { .await; assert_eq!(pruned, 0); } + + #[tokio::test] + async fn test_register_thread() { + use crate::agent::session::{Session, Thread}; + + let manager = SessionManager::new(); + let thread_id = Uuid::new_v4(); + + // Create a session with a hydrated thread + let session = Arc::new(Mutex::new(Session::new("user-hydrate"))); + { + let mut sess = session.lock().await; + let thread = Thread::with_id(thread_id, sess.id); + sess.threads.insert(thread_id, thread); + sess.active_thread = Some(thread_id); + } + + // Register the thread + manager + .register_thread("user-hydrate", "gateway", thread_id, Arc::clone(&session)) + .await; + + // resolve_thread should find it (using the UUID as external_thread_id) + let (resolved_session, resolved_tid) = manager + .resolve_thread("user-hydrate", "gateway", Some(&thread_id.to_string())) + .await; + assert_eq!(resolved_tid, thread_id); + + // Should be the same session object + let sess = resolved_session.lock().await; + assert!(sess.threads.contains_key(&thread_id)); + } + + #[tokio::test] + async fn test_resolve_thread_with_explicit_external_id() { + let manager = SessionManager::new(); + + // Two calls with the same explicit external thread ID should resolve + // to the same internal thread. + let (_, t1) = manager + .resolve_thread("user-1", "gateway", Some("ext-abc")) + .await; + let (_, t2) = manager + .resolve_thread("user-1", "gateway", Some("ext-abc")) + .await; + assert_eq!(t1, t2); + + // A different external ID on the same channel/user gets a new thread. + let (_, t3) = manager + .resolve_thread("user-1", "gateway", Some("ext-xyz")) + .await; + assert_ne!(t1, t3); + } + + #[tokio::test] + async fn test_resolve_thread_none_vs_some_external_id() { + let manager = SessionManager::new(); + + // None external_thread_id is a distinct key from Some("ext-1"). + let (_, t_none) = manager.resolve_thread("user-1", "cli", None).await; + let (_, t_some) = manager.resolve_thread("user-1", "cli", Some("ext-1")).await; + assert_ne!(t_none, t_some); + } + + #[tokio::test] + async fn test_resolve_thread_different_users_isolated() { + let manager = SessionManager::new(); + + let (_, t1) = manager + .resolve_thread("user-a", "gateway", Some("same-ext")) + .await; + let (_, t2) = manager + .resolve_thread("user-b", "gateway", Some("same-ext")) + .await; + + // Same channel + same external ID but different users = different threads + assert_ne!(t1, t2); + } + + #[tokio::test] + async fn test_resolve_thread_different_channels_isolated() { + let manager = SessionManager::new(); + + let (_, t1) = manager + .resolve_thread("user-1", "gateway", Some("thread-x")) + .await; + let (_, t2) = manager + .resolve_thread("user-1", "telegram", Some("thread-x")) + .await; + + // Same user + same external ID but different channels = different threads + assert_ne!(t1, t2); + } + + #[tokio::test] + async fn test_resolve_thread_stale_mapping_creates_new_thread() { + let manager = SessionManager::new(); + + // Create a thread normally + let (session, original_tid) = manager + .resolve_thread("user-1", "gateway", Some("ext-1")) + .await; + + // Simulate the thread being removed from the session (e.g. pruned) + { + let mut sess = session.lock().await; + sess.threads.remove(&original_tid); + } + + // Next resolve should detect the stale mapping and create a fresh thread + let (_, new_tid) = manager + .resolve_thread("user-1", "gateway", Some("ext-1")) + .await; + assert_ne!(original_tid, new_tid); + + // The new thread should actually exist in the session + let sess = session.lock().await; + assert!(sess.threads.contains_key(&new_tid)); + } + + #[tokio::test] + async fn test_register_thread_preserves_uuid_on_resolve() { + use crate::agent::session::{Session, Thread}; + + let manager = SessionManager::new(); + let known_uuid = Uuid::new_v4(); + + let session = Arc::new(Mutex::new(Session::new("user-web"))); + let session_id = { + let sess = session.lock().await; + sess.id + }; + + // Simulate hydration: create thread with a known UUID + { + let mut sess = session.lock().await; + let thread = Thread::with_id(known_uuid, session_id); + sess.threads.insert(known_uuid, thread); + } + + // Register it + manager + .register_thread("user-web", "gateway", known_uuid, Arc::clone(&session)) + .await; + + // resolve_thread with UUID as external_thread_id MUST return the same UUID, + // not mint a new one (this was the root cause of the "wrong conversation" bug) + let (_, resolved) = manager + .resolve_thread("user-web", "gateway", Some(&known_uuid.to_string())) + .await; + assert_eq!(resolved, known_uuid); + } + + #[tokio::test] + async fn test_register_thread_idempotent() { + use crate::agent::session::{Session, Thread}; + + let manager = SessionManager::new(); + let tid = Uuid::new_v4(); + + let session = Arc::new(Mutex::new(Session::new("user-idem"))); + { + let mut sess = session.lock().await; + let thread = Thread::with_id(tid, sess.id); + sess.threads.insert(tid, thread); + } + + // Register twice + manager + .register_thread("user-idem", "gateway", tid, Arc::clone(&session)) + .await; + manager + .register_thread("user-idem", "gateway", tid, Arc::clone(&session)) + .await; + + // Should still resolve to the same thread + let (_, resolved) = manager + .resolve_thread("user-idem", "gateway", Some(&tid.to_string())) + .await; + assert_eq!(resolved, tid); + } + + #[tokio::test] + async fn test_register_thread_creates_undo_manager() { + use crate::agent::session::{Session, Thread}; + + let manager = SessionManager::new(); + let tid = Uuid::new_v4(); + + let session = Arc::new(Mutex::new(Session::new("user-undo"))); + { + let mut sess = session.lock().await; + let thread = Thread::with_id(tid, sess.id); + sess.threads.insert(tid, thread); + } + + manager + .register_thread("user-undo", "gateway", tid, Arc::clone(&session)) + .await; + + // Undo manager should exist for the registered thread + let undo = manager.get_undo_manager(tid).await; + let undo2 = manager.get_undo_manager(tid).await; + assert!(Arc::ptr_eq(&undo, &undo2)); + } + + #[tokio::test] + async fn test_register_thread_stores_session() { + use crate::agent::session::{Session, Thread}; + + let manager = SessionManager::new(); + let tid = Uuid::new_v4(); + + let session = Arc::new(Mutex::new(Session::new("user-new"))); + { + let mut sess = session.lock().await; + let thread = Thread::with_id(tid, sess.id); + sess.threads.insert(tid, thread); + } + + // The user has no session yet in the manager + { + let sessions = manager.sessions.read().await; + assert!(!sessions.contains_key("user-new")); + } + + manager + .register_thread("user-new", "gateway", tid, Arc::clone(&session)) + .await; + + // Now the session should be tracked + { + let sessions = manager.sessions.read().await; + assert!(sessions.contains_key("user-new")); + } + } + + #[tokio::test] + async fn test_multiple_threads_per_user() { + let manager = SessionManager::new(); + + let (_, t1) = manager + .resolve_thread("user-1", "gateway", Some("thread-a")) + .await; + let (_, t2) = manager + .resolve_thread("user-1", "gateway", Some("thread-b")) + .await; + let (session, t3) = manager + .resolve_thread("user-1", "gateway", Some("thread-c")) + .await; + + // All three should be distinct + assert_ne!(t1, t2); + assert_ne!(t2, t3); + assert_ne!(t1, t3); + + // All three should exist in the same session + let sess = session.lock().await; + assert!(sess.threads.contains_key(&t1)); + assert!(sess.threads.contains_key(&t2)); + assert!(sess.threads.contains_key(&t3)); + } + + #[tokio::test] + async fn test_prune_cleans_thread_map_and_undo_managers() { + let manager = SessionManager::new(); + + let (stale_session, stale_tid) = manager.resolve_thread("user-stale", "cli", None).await; + + // Backdate the session + { + let mut sess = stale_session.lock().await; + sess.last_active_at = chrono::Utc::now() - chrono::TimeDelta::seconds(86400 * 30); + } + + // Verify thread_map and undo_managers have entries + { + let tm = manager.thread_map.read().await; + assert!(!tm.is_empty()); + } + { + let um = manager.undo_managers.read().await; + assert!(um.contains_key(&stale_tid)); + } + + let pruned = manager + .prune_stale_sessions(std::time::Duration::from_secs(86400 * 7)) + .await; + assert_eq!(pruned, 1); + + // Thread map and undo managers should be cleaned up + { + let tm = manager.thread_map.read().await; + assert!(tm.is_empty()); + } + { + let um = manager.undo_managers.read().await; + assert!(!um.contains_key(&stale_tid)); + } + } + + #[tokio::test] + async fn test_resolve_thread_active_thread_set() { + let manager = SessionManager::new(); + + let (session, thread_id) = manager + .resolve_thread("user-1", "gateway", Some("ext-1")) + .await; + + // The resolved thread should be set as the active thread + let sess = session.lock().await; + assert_eq!(sess.active_thread, Some(thread_id)); + } + + #[tokio::test] + async fn test_register_then_resolve_different_channel_creates_new() { + use crate::agent::session::{Session, Thread}; + + let manager = SessionManager::new(); + let tid = Uuid::new_v4(); + + let session = Arc::new(Mutex::new(Session::new("user-cross"))); + { + let mut sess = session.lock().await; + let thread = Thread::with_id(tid, sess.id); + sess.threads.insert(tid, thread); + } + + // Register on "gateway" channel + manager + .register_thread("user-cross", "gateway", tid, Arc::clone(&session)) + .await; + + // Resolve on a different channel with the same UUID string should NOT + // find the registered thread (channel is part of the key) + let (_, resolved) = manager + .resolve_thread("user-cross", "telegram", Some(&tid.to_string())) + .await; + assert_ne!(resolved, tid); + } } diff --git a/src/agent/submission.rs b/src/agent/submission.rs index 7a28356c..11f86263 100644 --- a/src/agent/submission.rs +++ b/src/agent/submission.rs @@ -43,6 +43,49 @@ impl SubmissionParser { if lower == "/thread new" || lower == "/new" { return Submission::NewThread; } + // System commands (bypass thread-state checks) + if lower == "/help" || lower == "/?" { + return Submission::SystemCommand { + command: "help".to_string(), + args: vec![], + }; + } + if lower == "/version" { + return Submission::SystemCommand { + command: "version".to_string(), + args: vec![], + }; + } + if lower == "/tools" { + return Submission::SystemCommand { + command: "tools".to_string(), + args: vec![], + }; + } + if lower == "/ping" { + return Submission::SystemCommand { + command: "ping".to_string(), + args: vec![], + }; + } + if lower == "/debug" { + return Submission::SystemCommand { + command: "debug".to_string(), + args: vec![], + }; + } + if lower.starts_with("/model") { + let args: Vec = trimmed + .split_whitespace() + .skip(1) + .map(|s| s.to_string()) + .collect(); + return Submission::SystemCommand { + command: "model".to_string(), + args, + }; + } + if lower == "/quit" || lower == "/exit" || lower == "/shutdown" { return Submission::Quit; } @@ -172,6 +215,15 @@ pub enum Submission { /// Quit the agent. Bypasses thread-state checks. Quit, + + /// System command (help, model, version, tools, ping, debug). + /// Bypasses thread-state checks and safety validation. + SystemCommand { + /// The command name (e.g. "help", "model", "version"). + command: String, + /// Arguments to the command. + args: Vec, + }, } impl Submission { @@ -238,6 +290,7 @@ impl Submission { | Self::Heartbeat | Self::Summarize | Self::Suggest + | Self::SystemCommand { .. } ) } } @@ -504,6 +557,84 @@ mod tests { ); } + #[test] + fn test_parser_system_command_help() { + let submission = SubmissionParser::parse("/help"); + assert!( + matches!(submission, Submission::SystemCommand { command, args } if command == "help" && args.is_empty()) + ); + + let submission = SubmissionParser::parse("/?"); + assert!( + matches!(submission, Submission::SystemCommand { command, .. } if command == "help") + ); + + let submission = SubmissionParser::parse("/HELP"); + assert!( + matches!(submission, Submission::SystemCommand { command, .. } if command == "help") + ); + } + + #[test] + fn test_parser_system_command_model() { + // No args: show current model + let submission = SubmissionParser::parse("/model"); + assert!( + matches!(submission, Submission::SystemCommand { command, args } if command == "model" && args.is_empty()) + ); + + // With args: switch model + let submission = SubmissionParser::parse("/model gpt-4o"); + assert!( + matches!(submission, Submission::SystemCommand { command, args } if command == "model" && args == vec!["gpt-4o"]) + ); + + // Case insensitive command, preserves arg case + let submission = SubmissionParser::parse("/MODEL Claude-3.5"); + assert!( + matches!(submission, Submission::SystemCommand { command, args } if command == "model" && args == vec!["Claude-3.5"]) + ); + } + + #[test] + fn test_parser_system_command_version() { + let submission = SubmissionParser::parse("/version"); + assert!( + matches!(submission, Submission::SystemCommand { command, args } if command == "version" && args.is_empty()) + ); + } + + #[test] + fn test_parser_system_command_tools() { + let submission = SubmissionParser::parse("/tools"); + assert!( + matches!(submission, Submission::SystemCommand { command, args } if command == "tools" && args.is_empty()) + ); + } + + #[test] + fn test_parser_system_command_ping() { + let submission = SubmissionParser::parse("/ping"); + assert!( + matches!(submission, Submission::SystemCommand { command, args } if command == "ping" && args.is_empty()) + ); + } + + #[test] + fn test_parser_system_command_debug() { + let submission = SubmissionParser::parse("/debug"); + assert!( + matches!(submission, Submission::SystemCommand { command, args } if command == "debug" && args.is_empty()) + ); + } + + #[test] + fn test_parser_system_command_is_control() { + let submission = SubmissionParser::parse("/help"); + assert!(submission.is_control()); + assert!(!submission.starts_turn()); + } + #[test] fn test_parser_quit() { assert!(matches!(SubmissionParser::parse("/quit"), Submission::Quit)); diff --git a/src/agent/worker.rs b/src/agent/worker.rs index c05ece63..77b11004 100644 --- a/src/agent/worker.rs +++ b/src/agent/worker.rs @@ -272,7 +272,10 @@ Report when the job is complete or if you encounter issues you cannot resolve."# )); } } - RespondResult::ToolCalls(tool_calls) => { + RespondResult::ToolCalls { + tool_calls, + content, + } => { // Model returned tool calls - execute them tracing::debug!( "Job {} respond_with_tools returned {} tool calls", @@ -280,6 +283,14 @@ Report when the job is complete or if you encounter issues you cannot resolve."# tool_calls.len() ); + // Add assistant message with tool_calls (OpenAI protocol) + reason_ctx + .messages + .push(ChatMessage::assistant_with_tool_calls( + content, + tool_calls.clone(), + )); + for tc in tool_calls { let result = self.execute_tool(&tc.name, &tc.arguments).await; @@ -417,14 +428,51 @@ Report when the job is complete or if you encounter issues you cannot resolve."# .into()); } - // Execute with timeout and timing + tracing::debug!( + tool = %tool_name, + params = %params, + job = %job_id, + "Tool call started" + ); + + // Execute with per-tool timeout and timing + let tool_timeout = tool.execution_timeout(); let start = std::time::Instant::now(); - let result = tokio::time::timeout(Duration::from_secs(60), async { + let result = tokio::time::timeout(tool_timeout, async { tool.execute(params.clone(), &job_ctx).await }) .await; let elapsed = start.elapsed(); + match &result { + Ok(Ok(output)) => { + let result_str = serde_json::to_string(&output.result) + .unwrap_or_else(|_| "".to_string()); + tracing::debug!( + tool = %tool_name, + elapsed_ms = elapsed.as_millis() as u64, + result = %result_str, + "Tool call succeeded" + ); + } + Ok(Err(e)) => { + tracing::debug!( + tool = %tool_name, + elapsed_ms = elapsed.as_millis() as u64, + error = %e, + "Tool call failed" + ); + } + Err(_) => { + tracing::debug!( + tool = %tool_name, + elapsed_ms = elapsed.as_millis() as u64, + timeout_secs = tool_timeout.as_secs(), + "Tool call timed out" + ); + } + } + // Record action in memory and get the ActionRecord for persistence let action = match &result { Ok(Ok(output)) => { @@ -479,7 +527,7 @@ Report when the job is complete or if you encounter issues you cannot resolve."# let output = result .map_err(|_| crate::error::ToolError::Timeout { name: tool_name.to_string(), - timeout: Duration::from_secs(60), + timeout: tool_timeout, })? .map_err(|e| crate::error::ToolError::ExecutionFailed { name: tool_name.to_string(), diff --git a/src/bootstrap.rs b/src/bootstrap.rs new file mode 100644 index 00000000..72ff65c0 --- /dev/null +++ b/src/bootstrap.rs @@ -0,0 +1,325 @@ +//! Bootstrap configuration for IronClaw. +//! +//! These are the only settings that MUST live on disk because they're needed +//! before the database connection is established. Everything else lives in the +//! `settings` table in PostgreSQL. +//! +//! File: `~/.ironclaw/bootstrap.json` + +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +use crate::settings::KeySource; + +/// Minimal config needed to connect to the database and decrypt secrets. +/// +/// This is the only JSON file IronClaw reads from disk at startup. +/// All other configuration lives in the `settings` table in PostgreSQL. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BootstrapConfig { + /// Database connection URL (postgres://...). + #[serde(default)] + pub database_url: Option, + + /// Database connection pool size. + #[serde(default)] + pub database_pool_size: Option, + + /// Source for the secrets master key. + #[serde(default)] + pub secrets_master_key_source: KeySource, + + /// Whether onboarding wizard has been completed. + #[serde(default)] + pub onboard_completed: bool, +} + +impl Default for BootstrapConfig { + fn default() -> Self { + Self { + database_url: None, + database_pool_size: None, + secrets_master_key_source: KeySource::None, + onboard_completed: false, + } + } +} + +impl BootstrapConfig { + /// Default bootstrap file path: `~/.ironclaw/bootstrap.json`. + pub fn default_path() -> PathBuf { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".ironclaw") + .join("bootstrap.json") + } + + /// Legacy settings.json path (for migration detection). + pub fn legacy_settings_path() -> PathBuf { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".ironclaw") + .join("settings.json") + } + + /// Load from the default path, falling back to legacy settings.json, + /// then to defaults if neither exists. + pub fn load() -> Self { + let bootstrap_path = Self::default_path(); + if bootstrap_path.exists() { + return Self::load_from(&bootstrap_path); + } + + // Fall back to legacy settings.json (extract just the 4 bootstrap fields) + let legacy_path = Self::legacy_settings_path(); + if legacy_path.exists() { + return Self::load_from_legacy(&legacy_path); + } + + Self::default() + } + + /// Load from a specific path. + pub fn load_from(path: &PathBuf) -> Self { + match std::fs::read_to_string(path) { + Ok(data) => serde_json::from_str(&data).unwrap_or_default(), + Err(_) => Self::default(), + } + } + + /// Extract bootstrap fields from a legacy settings.json. + fn load_from_legacy(path: &PathBuf) -> Self { + match std::fs::read_to_string(path) { + Ok(data) => { + // The legacy Settings struct is a superset; serde will ignore extra fields. + serde_json::from_str(&data).unwrap_or_default() + } + Err(_) => Self::default(), + } + } + + /// Save to the default path. + pub fn save(&self) -> std::io::Result<()> { + self.save_to(&Self::default_path()) + } + + /// Save to a specific path. + pub fn save_to(&self, path: &PathBuf) -> std::io::Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let json = serde_json::to_string_pretty(self) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?; + std::fs::write(path, json) + } +} + +/// One-time migration from disk config files to the database settings table. +/// +/// On first boot after upgrade, checks if: +/// 1. `~/.ironclaw/settings.json` exists +/// 2. The DB settings table is empty for this user +/// +/// If both conditions hold, migrates settings, MCP servers, and session data +/// to the database, writes `bootstrap.json`, and renames old files to `.migrated`. +pub async fn migrate_disk_to_db( + store: &crate::history::Store, + user_id: &str, +) -> Result<(), MigrationError> { + let legacy_settings_path = BootstrapConfig::legacy_settings_path(); + if !legacy_settings_path.exists() { + tracing::debug!("No legacy settings.json found, skipping disk-to-DB migration"); + return Ok(()); + } + + // Only migrate if DB is empty for this user + let has_settings = store.has_settings(user_id).await.map_err(|e| { + MigrationError::Database(format!("Failed to check existing settings: {}", e)) + })?; + if has_settings { + tracing::debug!( + "DB already has settings for user '{}', skipping migration", + user_id + ); + return Ok(()); + } + + tracing::info!("Migrating disk settings to database..."); + + // 1. Load and migrate settings.json + let settings = crate::settings::Settings::load_from(&legacy_settings_path); + let db_map = settings.to_db_map(); + if !db_map.is_empty() { + store + .set_all_settings(user_id, &db_map) + .await + .map_err(|e| { + MigrationError::Database(format!("Failed to write settings to DB: {}", e)) + })?; + tracing::info!("Migrated {} settings to database", db_map.len()); + } + + // 2. Write bootstrap.json with the 4 essential fields + let bootstrap = BootstrapConfig { + database_url: settings.database_url.clone(), + database_pool_size: settings.database_pool_size, + secrets_master_key_source: settings.secrets_master_key_source, + onboard_completed: settings.onboard_completed, + }; + bootstrap + .save() + .map_err(|e| MigrationError::Io(format!("Failed to write bootstrap.json: {}", e)))?; + tracing::info!("Wrote bootstrap.json"); + + // 3. Migrate mcp-servers.json if it exists + let ironclaw_dir = dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".ironclaw"); + let mcp_path = ironclaw_dir.join("mcp-servers.json"); + if mcp_path.exists() { + match std::fs::read_to_string(&mcp_path) { + Ok(content) => match serde_json::from_str::(&content) { + Ok(value) => { + store + .set_setting(user_id, "mcp_servers", &value) + .await + .map_err(|e| { + MigrationError::Database(format!( + "Failed to write MCP servers to DB: {}", + e + )) + })?; + tracing::info!("Migrated mcp-servers.json to database"); + + rename_to_migrated(&mcp_path); + } + Err(e) => { + tracing::warn!("Failed to parse mcp-servers.json: {}", e); + } + }, + Err(e) => { + tracing::warn!("Failed to read mcp-servers.json: {}", e); + } + } + } + + // 4. Migrate session.json if it exists + let session_path = ironclaw_dir.join("session.json"); + if session_path.exists() { + match std::fs::read_to_string(&session_path) { + Ok(content) => match serde_json::from_str::(&content) { + Ok(value) => { + store + .set_setting(user_id, "nearai.session", &value) + .await + .map_err(|e| { + MigrationError::Database(format!( + "Failed to write session to DB: {}", + e + )) + })?; + tracing::info!("Migrated session.json to database"); + + rename_to_migrated(&session_path); + } + Err(e) => { + tracing::warn!("Failed to parse session.json: {}", e); + } + }, + Err(e) => { + tracing::warn!("Failed to read session.json: {}", e); + } + } + } + + // 5. Rename settings.json to .migrated (don't delete, safety net) + rename_to_migrated(&legacy_settings_path); + + tracing::info!("Disk-to-DB migration complete"); + Ok(()) +} + +/// Rename a file to `.migrated` as a safety net. +fn rename_to_migrated(path: &PathBuf) { + let mut migrated = path.as_os_str().to_owned(); + migrated.push(".migrated"); + if let Err(e) = std::fs::rename(path, &migrated) { + tracing::warn!("Failed to rename {} to .migrated: {}", path.display(), e); + } +} + +/// Errors that can occur during disk-to-DB migration. +#[derive(Debug, thiserror::Error)] +pub enum MigrationError { + #[error("Database error: {0}")] + Database(String), + #[error("IO error: {0}")] + Io(String), +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn test_bootstrap_save_load() { + let dir = tempdir().unwrap(); + let path = dir.path().join("bootstrap.json"); + + let config = BootstrapConfig { + database_url: Some("postgres://localhost/test".to_string()), + database_pool_size: Some(5), + secrets_master_key_source: KeySource::Keychain, + onboard_completed: true, + }; + + config.save_to(&path).unwrap(); + + let loaded = BootstrapConfig::load_from(&path); + assert_eq!( + loaded.database_url, + Some("postgres://localhost/test".to_string()) + ); + assert_eq!(loaded.database_pool_size, Some(5)); + assert_eq!(loaded.secrets_master_key_source, KeySource::Keychain); + assert!(loaded.onboard_completed); + } + + #[test] + fn test_bootstrap_from_legacy_settings() { + let dir = tempdir().unwrap(); + let path = dir.path().join("settings.json"); + + // Write a legacy settings.json with many extra fields + let legacy = serde_json::json!({ + "database_url": "postgres://localhost/ironclaw", + "database_pool_size": 10, + "secrets_master_key_source": "keychain", + "onboard_completed": true, + "selected_model": "claude-3-5-sonnet", + "agent": { "name": "testbot", "max_parallel_jobs": 3 }, + "heartbeat": { "enabled": true } + }); + std::fs::write(&path, serde_json::to_string_pretty(&legacy).unwrap()).unwrap(); + + let config = BootstrapConfig::load_from_legacy(&path); + assert_eq!( + config.database_url, + Some("postgres://localhost/ironclaw".to_string()) + ); + assert_eq!(config.database_pool_size, Some(10)); + assert_eq!(config.secrets_master_key_source, KeySource::Keychain); + assert!(config.onboard_completed); + } + + #[test] + fn test_bootstrap_defaults() { + let config = BootstrapConfig::default(); + assert!(config.database_url.is_none()); + assert!(config.database_pool_size.is_none()); + assert_eq!(config.secrets_master_key_source, KeySource::None); + assert!(!config.onboard_completed); + } +} diff --git a/src/channels/channel.rs b/src/channels/channel.rs index c5575ccd..d87c8240 100644 --- a/src/channels/channel.rs +++ b/src/channels/channel.rs @@ -114,6 +114,12 @@ pub enum StatusUpdate { StreamChunk(String), /// General status message. Status(String), + /// A sandbox job has started (shown as a clickable card in the UI). + JobStarted { + job_id: String, + title: String, + browse_url: String, + }, /// Tool requires user approval before execution. ApprovalNeeded { request_id: String, @@ -121,6 +127,19 @@ pub enum StatusUpdate { description: String, parameters: serde_json::Value, }, + /// Extension needs user authentication (token or OAuth). + AuthRequired { + extension_name: String, + instructions: Option, + auth_url: Option, + setup_url: Option, + }, + /// Extension authentication completed. + AuthCompleted { + extension_name: String, + success: bool, + message: String, + }, } /// Trait for message channels. diff --git a/src/channels/repl.rs b/src/channels/repl.rs index ef5db362..cbfd1c4a 100644 --- a/src/channels/repl.rs +++ b/src/channels/repl.rs @@ -42,12 +42,25 @@ const SLASH_COMMANDS: &[&str] = &[ "/quit", "/exit", "/debug", + "/model", "/undo", "/redo", "/clear", "/compact", "/new", "/interrupt", + "/version", + "/tools", + "/ping", + "/job", + "/status", + "/cancel", + "/list", + "/heartbeat", + "/summarize", + "/suggest", + "/thread", + "/resume", ]; /// Rustyline helper for slash-command tab completion. @@ -295,10 +308,11 @@ impl Channel for ReplChannel { continue; } - // Handle local REPL commands + // Handle local REPL commands (only commands that need + // immediate local handling stay here) match line.to_lowercase().as_str() { "/quit" | "/exit" => break, - "/help" | "/?" => { + "/help" => { print_help(); continue; } @@ -413,6 +427,15 @@ impl Channel for ReplChannel { print!("{chunk}"); let _ = io::stdout().flush(); } + StatusUpdate::JobStarted { + job_id, + title, + browse_url, + } => { + eprintln!( + " \x1b[36m[job]\x1b[0m {title} \x1b[90m({job_id})\x1b[0m \x1b[4m{browse_url}\x1b[0m" + ); + } StatusUpdate::Status(msg) => { if debug || msg.contains("approval") || msg.contains("Approval") { eprintln!(" \x1b[90m{msg}\x1b[0m"); @@ -472,6 +495,33 @@ impl Channel for ReplChannel { eprintln!(" {bot_border}"); eprintln!(); } + StatusUpdate::AuthRequired { + extension_name, + instructions, + setup_url, + .. + } => { + eprintln!(); + eprintln!("\x1b[33m Authentication required for {extension_name}\x1b[0m"); + if let Some(ref instr) = instructions { + eprintln!(" {instr}"); + } + if let Some(ref url) = setup_url { + eprintln!(" \x1b[4m{url}\x1b[0m"); + } + eprintln!(); + } + StatusUpdate::AuthCompleted { + extension_name, + success, + message, + } => { + if success { + eprintln!("\x1b[32m {extension_name}: {message}\x1b[0m"); + } else { + eprintln!("\x1b[31m {extension_name}: {message}\x1b[0m"); + } + } } Ok(()) } diff --git a/src/channels/wasm/bundled.rs b/src/channels/wasm/bundled.rs index 9825b72b..1974be41 100644 --- a/src/channels/wasm/bundled.rs +++ b/src/channels/wasm/bundled.rs @@ -1,68 +1,125 @@ -//! Bundled WASM channels that can be installed locally. +//! Known WASM channels that can be installed from build artifacts. +//! +//! Instead of embedding WASM binaries in the host binary via include_bytes!, +//! channels are compiled separately and installed from their build output +//! directories during onboarding. +//! +//! Channel source layout: +//! channels-src// +//! target/wasm32-wasip2/release/_channel.wasm +//! .capabilities.json -use std::path::Path; +use std::path::{Path, PathBuf}; use tokio::fs; -#[derive(Clone, Copy)] -struct BundledChannel { - name: &'static str, - wasm: &'static [u8], - capabilities: &'static [u8], +/// Compile-time project root, used to locate channels-src/ in dev builds. +const CARGO_MANIFEST_DIR: &str = env!("CARGO_MANIFEST_DIR"); + +/// Known channel names and their crate names (for locating build artifacts). +const KNOWN_CHANNELS: &[(&str, &str)] = &[ + ("telegram", "telegram_channel"), + ("slack", "slack_channel"), + ("whatsapp", "whatsapp_channel"), +]; + +/// Names of known channels that can be installed. +pub fn bundled_channel_names() -> Vec<&'static str> { + KNOWN_CHANNELS.iter().map(|(name, _)| *name).collect() } -/// Names of bundled channels shipped with IronClaw. -pub fn bundled_channel_names() -> &'static [&'static str] { - &["telegram"] +/// Resolve the channels source directory. +/// +/// Checks (in order): +/// 1. `IRONCLAW_CHANNELS_SRC` env var +/// 2. `/channels-src/` (dev builds) +fn channels_src_dir() -> PathBuf { + if let Ok(dir) = std::env::var("IRONCLAW_CHANNELS_SRC") { + return PathBuf::from(dir); + } + PathBuf::from(CARGO_MANIFEST_DIR).join("channels-src") } -/// Install a bundled channel into a channels directory. +/// Locate the build artifacts for a channel. +/// +/// Returns (wasm_path, capabilities_path) or an error if files are missing. +fn locate_channel_artifacts(name: &str) -> Result<(PathBuf, PathBuf), String> { + let (_, crate_name) = KNOWN_CHANNELS + .iter() + .find(|(n, _)| *n == name) + .ok_or_else(|| format!("Unknown channel '{}'", name))?; + + let src_dir = channels_src_dir(); + let channel_dir = src_dir.join(name); + + let wasm_path = channel_dir + .join("target/wasm32-wasip2/release") + .join(format!("{}.wasm", crate_name)); + + let caps_path = channel_dir.join(format!("{}.capabilities.json", name)); + + if !wasm_path.exists() { + return Err(format!( + "Channel '{}' WASM not found at {}. Build it first:\n \ + cd {} && cargo build --target wasm32-wasip2 --release", + name, + wasm_path.display(), + channel_dir.display() + )); + } + + if !caps_path.exists() { + return Err(format!( + "Channel '{}' capabilities not found at {}", + name, + caps_path.display() + )); + } + + Ok((wasm_path, caps_path)) +} + +/// Install a channel from build artifacts into the channels directory. pub async fn install_bundled_channel( name: &str, target_dir: &Path, force: bool, ) -> Result<(), String> { - let channel = bundled_channel(name) - .ok_or_else(|| format!("Unknown bundled channel '{}'", name.to_lowercase()))?; + let (wasm_src, caps_src) = locate_channel_artifacts(name)?; fs::create_dir_all(target_dir) .await .map_err(|e| format!("Failed to create channels directory: {}", e))?; - let wasm_path = target_dir.join(format!("{}.wasm", channel.name)); - let caps_path = target_dir.join(format!("{}.capabilities.json", channel.name)); + let wasm_dst = target_dir.join(format!("{}.wasm", name)); + let caps_dst = target_dir.join(format!("{}.capabilities.json", name)); - let has_existing = wasm_path.exists() || caps_path.exists(); + let has_existing = wasm_dst.exists() || caps_dst.exists(); if has_existing && !force { return Err(format!( "Channel '{}' already exists at {}", - channel.name, + name, target_dir.display() )); } - fs::write(&wasm_path, channel.wasm) + fs::copy(&wasm_src, &wasm_dst) .await - .map_err(|e| format!("Failed to write {}: {}", wasm_path.display(), e))?; - fs::write(&caps_path, channel.capabilities) + .map_err(|e| format!("Failed to copy {}: {}", wasm_src.display(), e))?; + fs::copy(&caps_src, &caps_dst) .await - .map_err(|e| format!("Failed to write {}: {}", caps_path.display(), e))?; + .map_err(|e| format!("Failed to copy {}: {}", caps_src.display(), e))?; Ok(()) } -fn bundled_channel(name: &str) -> Option { - if name.eq_ignore_ascii_case("telegram") { - Some(BundledChannel { - name: "telegram", - wasm: include_bytes!("../../../channels-src/telegram/telegram.wasm"), - capabilities: include_bytes!( - "../../../channels-src/telegram/telegram.capabilities.json" - ), - }) - } else { - None - } +/// Check which known channels have build artifacts available. +pub fn available_channel_names() -> Vec<&'static str> { + KNOWN_CHANNELS + .iter() + .filter(|(name, _)| locate_channel_artifacts(name).is_ok()) + .map(|(name, _)| *name) + .collect() } #[cfg(test)] @@ -73,31 +130,35 @@ mod tests { use super::*; #[test] - fn test_bundled_channel_names_contains_telegram() { - assert!(bundled_channel_names().contains(&"telegram")); + fn test_known_channels_includes_all_three() { + let names = bundled_channel_names(); + assert!(names.contains(&"telegram")); + assert!(names.contains(&"slack")); + assert!(names.contains(&"whatsapp")); + } + + #[test] + fn test_channels_src_dir_default() { + let dir = channels_src_dir(); + assert!(dir.ends_with("channels-src")); + } + + #[test] + fn test_locate_unknown_channel_errors() { + assert!(locate_channel_artifacts("nonexistent").is_err()); } #[tokio::test] - async fn test_install_bundled_channel_writes_files() { - let dir = tempdir().unwrap(); - - install_bundled_channel("telegram", dir.path(), false) - .await - .unwrap(); - - assert!(dir.path().join("telegram.wasm").exists()); - assert!(dir.path().join("telegram.capabilities.json").exists()); - } - - #[tokio::test] - async fn test_install_bundled_channel_refuses_overwrite_without_force() { + async fn test_install_refuses_overwrite_without_force() { let dir = tempdir().unwrap(); let wasm_path = dir.path().join("telegram.wasm"); fs::write(&wasm_path, b"custom").await.unwrap(); let result = install_bundled_channel("telegram", dir.path(), false).await; + // Either fails because artifacts missing OR because file exists assert!(result.is_err()); + // Original file should be untouched let existing = fs::read(&wasm_path).await.unwrap(); assert_eq!(existing, b"custom"); } diff --git a/src/channels/wasm/mod.rs b/src/channels/wasm/mod.rs index 9f7b7c37..17ac7726 100644 --- a/src/channels/wasm/mod.rs +++ b/src/channels/wasm/mod.rs @@ -89,7 +89,7 @@ mod schema; mod wrapper; // Core types -pub use bundled::{bundled_channel_names, install_bundled_channel}; +pub use bundled::{available_channel_names, bundled_channel_names, install_bundled_channel}; pub use capabilities::{ChannelCapabilities, EmitRateLimitConfig, HttpEndpointConfig, PollConfig}; pub use error::WasmChannelError; pub use host::{ChannelEmitRateLimiter, ChannelHostState, EmittedMessage}; diff --git a/src/channels/wasm/wrapper.rs b/src/channels/wasm/wrapper.rs index 14a33a24..127fa372 100644 --- a/src/channels/wasm/wrapper.rs +++ b/src/channels/wasm/wrapper.rs @@ -141,6 +141,22 @@ impl ChannelStoreData { result } + + /// Replace injected credential values with `[REDACTED]` in text. + /// + /// Prevents credentials from leaking through error messages, logs, or + /// return values to WASM. reqwest::Error includes the full URL in its + /// Display output, so any error from an injected-URL request will + /// contain the raw credential unless we scrub it. + fn redact_credentials(&self, text: &str) -> String { + let mut result = text.to_string(); + for (name, value) in &self.credentials { + if !value.is_empty() { + result = result.replace(value, &format!("[REDACTED:{}]", name)); + } + } + result + } } // Implement WasiView to provide WASI context and resource table @@ -187,6 +203,7 @@ impl near::agent::channel_host::Host for ChannelStoreData { url: String, headers_json: String, body: Option>, + timeout_ms: Option, ) -> Result { tracing::info!( method = %method, @@ -276,12 +293,21 @@ impl near::agent::channel_host::Host for ChannelStoreData { request = request.body(body_bytes); } - // Send request with timeout - let response = request - .timeout(std::time::Duration::from_secs(30)) - .send() - .await - .map_err(|e| format!("HTTP request failed: {}", e))?; + // Send request with caller-specified timeout (default 30s). + // Cap at callback_timeout to prevent outliving the host wrapper. + let timeout = std::time::Duration::from_millis(timeout_ms.unwrap_or(30_000) as u64); + let response = request.timeout(timeout).send().await.map_err(|e| { + // Walk the full error chain so we get the actual root cause + // (DNS, TLS, connection refused, etc.) instead of just + // "error sending request for url (...)". + let mut chain = format!("HTTP request failed: {}", e); + let mut source = std::error::Error::source(&e); + while let Some(cause) = source { + chain.push_str(&format!(" -> {}", cause)); + source = cause.source(); + } + chain + })?; let status = response.status().as_u16(); let response_headers: std::collections::HashMap = response @@ -330,6 +356,11 @@ impl near::agent::channel_host::Host for ChannelStoreData { }) }); + // Scrub credential values from error messages before logging or returning + // to WASM. reqwest::Error includes the full URL (with injected credentials) + // in its Display output. + let result = result.map_err(|e| self.redact_credentials(&e)); + match &result { Ok(resp) => { tracing::info!(status = resp.status, "http_request completed successfully"); @@ -1858,6 +1889,29 @@ fn status_to_wit(status: &StatusUpdate, metadata: &serde_json::Value) -> wit_cha message: format!("Approval needed: {} - {}", tool_name, description), metadata_json, }, + StatusUpdate::JobStarted { job_id, title, .. } => wit_channel::StatusUpdate { + status: wit_channel::StatusType::Thinking, + message: format!("Job started: {} ({})", title, job_id), + metadata_json, + }, + StatusUpdate::AuthRequired { extension_name, .. } => wit_channel::StatusUpdate { + status: wit_channel::StatusType::Thinking, + message: format!("Auth required: {}", extension_name), + metadata_json, + }, + StatusUpdate::AuthCompleted { + extension_name, + success, + .. + } => wit_channel::StatusUpdate { + status: wit_channel::StatusType::Thinking, + message: format!( + "Auth {}: {}", + if *success { "completed" } else { "failed" }, + extension_name + ), + metadata_json, + }, } } @@ -2350,4 +2404,78 @@ mod tests { assert_eq!(cloned.message, "hello"); assert_eq!(cloned.metadata_json, "{\"a\":1}"); } + + #[test] + fn test_redact_credentials_replaces_values() { + use super::ChannelStoreData; + + let mut creds = std::collections::HashMap::new(); + creds.insert( + "TELEGRAM_BOT_TOKEN".to_string(), + "8218490433:AAEZeUxwqZ5OO3mOCXv7fKvpdhDgsmBBNis".to_string(), + ); + creds.insert("OTHER_SECRET".to_string(), "s3cret".to_string()); + + let store = + ChannelStoreData::new(1024 * 1024, "test", ChannelCapabilities::default(), creds); + + let error = "HTTP request failed: error sending request for url \ + (https://api.telegram.org/bot8218490433:AAEZeUxwqZ5OO3mOCXv7fKvpdhDgsmBBNis/getUpdates)"; + + let redacted = store.redact_credentials(error); + + assert!( + !redacted.contains("8218490433:AAEZeUxwqZ5OO3mOCXv7fKvpdhDgsmBBNis"), + "credential value should be redacted" + ); + assert!( + redacted.contains("[REDACTED:TELEGRAM_BOT_TOKEN]"), + "redacted text should contain placeholder name" + ); + assert!( + !redacted.contains("s3cret"), + "other credentials should also be redacted" + ); + } + + #[test] + fn test_redact_credentials_no_op_without_credentials() { + use super::ChannelStoreData; + + let store = ChannelStoreData::new( + 1024 * 1024, + "test", + ChannelCapabilities::default(), + std::collections::HashMap::new(), + ); + + let input = "some error message"; + assert_eq!(store.redact_credentials(input), input); + } + + #[test] + fn test_redact_credentials_skips_empty_values() { + use super::ChannelStoreData; + + let mut creds = std::collections::HashMap::new(); + creds.insert("EMPTY_TOKEN".to_string(), String::new()); + + let store = + ChannelStoreData::new(1024 * 1024, "test", ChannelCapabilities::default(), creds); + + let input = "should not match anything"; + assert_eq!(store.redact_credentials(input), input); + } + + /// Verify that the block_on-inside-spawn_blocking pattern used by the WASM + /// channel HTTP host function doesn't deadlock or panic. + #[tokio::test] + async fn test_block_on_inside_spawn_blocking_does_not_deadlock() { + let result = tokio::task::spawn_blocking(|| { + tokio::runtime::Handle::current().block_on(async { 42 }) + }) + .await + .expect("spawn_blocking panicked"); + assert_eq!(result, 42); + } } diff --git a/src/channels/web/mod.rs b/src/channels/web/mod.rs index 46590402..c4df28c1 100644 --- a/src/channels/web/mod.rs +++ b/src/channels/web/mod.rs @@ -10,7 +10,7 @@ //! ◄── GET /api/chat/events ── SSE stream //! ─── GET /api/chat/ws ─────► WebSocket (bidirectional) //! ─── GET /api/memory/* ────► Workspace -//! ─── GET /api/jobs/* ──────► ContextManager +//! ─── GET /api/jobs/* ──────► Database //! ◄── GET / ───────────────── Static HTML/CSS/JS //! ``` @@ -31,9 +31,10 @@ use tokio_stream::wrappers::ReceiverStream; use crate::agent::SessionManager; use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate}; use crate::config::GatewayConfig; -use crate::context::ContextManager; use crate::error::ChannelError; use crate::extensions::ExtensionManager; +use crate::history::Store; +use crate::orchestrator::job_manager::ContainerJobManager; use crate::tools::ToolRegistry; use crate::workspace::Workspace; @@ -70,11 +71,13 @@ impl GatewayChannel { msg_tx: tokio::sync::RwLock::new(None), sse: SseManager::new(), workspace: None, - context_manager: None, session_manager: None, log_broadcaster: None, extension_manager: None, tool_registry: None, + store: None, + job_manager: None, + prompt_queue: None, user_id: config.user_id.clone(), shutdown_tx: tokio::sync::RwLock::new(None), ws_tracker: Some(Arc::new(ws::WsConnectionTracker::new())), @@ -93,11 +96,13 @@ impl GatewayChannel { msg_tx: tokio::sync::RwLock::new(None), sse: SseManager::new(), workspace: self.state.workspace.clone(), - context_manager: self.state.context_manager.clone(), session_manager: self.state.session_manager.clone(), log_broadcaster: self.state.log_broadcaster.clone(), extension_manager: self.state.extension_manager.clone(), tool_registry: self.state.tool_registry.clone(), + store: self.state.store.clone(), + job_manager: self.state.job_manager.clone(), + prompt_queue: self.state.prompt_queue.clone(), user_id: self.state.user_id.clone(), shutdown_tx: tokio::sync::RwLock::new(None), ws_tracker: self.state.ws_tracker.clone(), @@ -112,12 +117,6 @@ impl GatewayChannel { self } - /// Inject the context manager for the jobs API. - pub fn with_context_manager(mut self, cm: Arc) -> Self { - self.rebuild_state(|s| s.context_manager = Some(cm)); - self - } - /// Inject the session manager for thread/session info. pub fn with_session_manager(mut self, sm: Arc) -> Self { self.rebuild_state(|s| s.session_manager = Some(sm)); @@ -142,6 +141,34 @@ impl GatewayChannel { self } + /// Inject the database store for sandbox job persistence. + pub fn with_store(mut self, store: Arc) -> Self { + self.rebuild_state(|s| s.store = Some(store)); + self + } + + /// Inject the container job manager for sandbox operations. + pub fn with_job_manager(mut self, jm: Arc) -> Self { + self.rebuild_state(|s| s.job_manager = Some(jm)); + self + } + + /// Inject the prompt queue for Claude Code follow-up prompts. + pub fn with_prompt_queue( + mut self, + pq: Arc< + tokio::sync::Mutex< + std::collections::HashMap< + uuid::Uuid, + std::collections::VecDeque, + >, + >, + >, + ) -> Self { + self.rebuild_state(|s| s.prompt_queue = Some(pq)); + self + } + /// Get the auth token (for printing to console on startup). pub fn auth_token(&self) -> &str { &self.auth_token @@ -173,11 +200,7 @@ impl Channel for GatewayChannel { ), })?; - let bound_addr = - server::start_server(addr, self.state.clone(), self.auth_token.clone()).await?; - - tracing::info!("Web gateway listening on http://{}", bound_addr); - tracing::info!("Auth token: {}", self.auth_token); + server::start_server(addr, self.state.clone(), self.auth_token.clone()).await?; Ok(Box::pin(ReceiverStream::new(rx))) } @@ -200,17 +223,48 @@ impl Channel for GatewayChannel { async fn send_status( &self, status: StatusUpdate, - _metadata: &serde_json::Value, + metadata: &serde_json::Value, ) -> Result<(), ChannelError> { + let thread_id = metadata + .get("thread_id") + .and_then(|v| v.as_str()) + .map(String::from); let event = match status { - StatusUpdate::Thinking(msg) => SseEvent::Thinking { message: msg }, - StatusUpdate::ToolStarted { name } => SseEvent::ToolStarted { name }, - StatusUpdate::ToolCompleted { name, success } => { - SseEvent::ToolCompleted { name, success } - } - StatusUpdate::ToolResult { name, preview } => SseEvent::ToolResult { name, preview }, - StatusUpdate::StreamChunk(content) => SseEvent::StreamChunk { content }, - StatusUpdate::Status(msg) => SseEvent::Status { message: msg }, + StatusUpdate::Thinking(msg) => SseEvent::Thinking { + message: msg, + thread_id: thread_id.clone(), + }, + StatusUpdate::ToolStarted { name } => SseEvent::ToolStarted { + name, + thread_id: thread_id.clone(), + }, + StatusUpdate::ToolCompleted { name, success } => SseEvent::ToolCompleted { + name, + success, + thread_id: thread_id.clone(), + }, + StatusUpdate::ToolResult { name, preview } => SseEvent::ToolResult { + name, + preview, + thread_id: thread_id.clone(), + }, + StatusUpdate::StreamChunk(content) => SseEvent::StreamChunk { + content, + thread_id: thread_id.clone(), + }, + StatusUpdate::Status(msg) => SseEvent::Status { + message: msg, + thread_id: thread_id.clone(), + }, + StatusUpdate::JobStarted { + job_id, + title, + browse_url, + } => SseEvent::JobStarted { + job_id, + title, + browse_url, + }, StatusUpdate::ApprovalNeeded { request_id, tool_name, @@ -223,6 +277,26 @@ impl Channel for GatewayChannel { parameters: serde_json::to_string_pretty(¶meters) .unwrap_or_else(|_| parameters.to_string()), }, + StatusUpdate::AuthRequired { + extension_name, + instructions, + auth_url, + setup_url, + } => SseEvent::AuthRequired { + extension_name, + instructions, + auth_url, + setup_url, + }, + StatusUpdate::AuthCompleted { + extension_name, + success, + message, + } => SseEvent::AuthCompleted { + extension_name, + success, + message, + }, }; self.state.sse.broadcast(event); diff --git a/src/channels/web/server.rs b/src/channels/web/server.rs index 11363dfa..b4a56bb0 100644 --- a/src/channels/web/server.rs +++ b/src/channels/web/server.rs @@ -28,11 +28,22 @@ use crate::channels::web::auth::{AuthState, auth_middleware}; use crate::channels::web::log_layer::LogBroadcaster; use crate::channels::web::sse::SseManager; use crate::channels::web::types::*; -use crate::context::ContextManager; use crate::extensions::ExtensionManager; +use crate::history::Store; +use crate::orchestrator::job_manager::ContainerJobManager; use crate::tools::ToolRegistry; use crate::workspace::Workspace; +/// Shared prompt queue: maps job IDs to pending follow-up prompts for Claude Code bridges. +pub type PromptQueue = Arc< + tokio::sync::Mutex< + std::collections::HashMap< + uuid::Uuid, + std::collections::VecDeque, + >, + >, +>; + /// Shared state for all gateway handlers. pub struct GatewayState { /// Channel to send messages to the agent loop. @@ -41,8 +52,6 @@ pub struct GatewayState { pub sse: SseManager, /// Workspace for memory API. pub workspace: Option>, - /// Context manager for jobs API. - pub context_manager: Option>, /// Session manager for thread info. pub session_manager: Option>, /// Log broadcaster for the logs SSE endpoint. @@ -51,6 +60,12 @@ pub struct GatewayState { pub extension_manager: Option>, /// Tool registry for listing registered tools. pub tool_registry: Option>, + /// Database store for sandbox job persistence. + pub store: Option>, + /// Container job manager for sandbox operations. + pub job_manager: Option>, + /// Prompt queue for Claude Code follow-up prompts. + pub prompt_queue: Option, /// User ID for this gateway. pub user_id: String, /// Shutdown signal sender. @@ -90,6 +105,8 @@ pub async fn start_server( // Chat .route("/api/chat/send", post(chat_send_handler)) .route("/api/chat/approval", post(chat_approval_handler)) + .route("/api/chat/auth-token", post(chat_auth_token_handler)) + .route("/api/chat/auth-cancel", post(chat_auth_cancel_handler)) .route("/api/chat/events", get(chat_events_handler)) .route("/api/chat/ws", get(chat_ws_handler)) .route("/api/chat/history", get(chat_history_handler)) @@ -106,6 +123,11 @@ pub async fn start_server( .route("/api/jobs/summary", get(jobs_summary_handler)) .route("/api/jobs/{id}", get(jobs_detail_handler)) .route("/api/jobs/{id}/cancel", post(jobs_cancel_handler)) + .route("/api/jobs/{id}/restart", post(jobs_restart_handler)) + .route("/api/jobs/{id}/prompt", post(jobs_prompt_handler)) + .route("/api/jobs/{id}/events", get(jobs_events_handler)) + .route("/api/jobs/{id}/files/list", get(job_files_list_handler)) + .route("/api/jobs/{id}/files/read", get(job_files_read_handler)) // Logs .route("/api/logs/events", get(logs_events_handler)) // Extensions @@ -120,6 +142,30 @@ pub async fn start_server( "/api/extensions/{name}/remove", post(extensions_remove_handler), ) + // Routines + .route("/api/routines", get(routines_list_handler)) + .route("/api/routines/summary", get(routines_summary_handler)) + .route("/api/routines/{id}", get(routines_detail_handler)) + .route("/api/routines/{id}/trigger", post(routines_trigger_handler)) + .route("/api/routines/{id}/toggle", post(routines_toggle_handler)) + .route( + "/api/routines/{id}", + axum::routing::delete(routines_delete_handler), + ) + .route("/api/routines/{id}/runs", get(routines_runs_handler)) + // Settings + .route("/api/settings", get(settings_list_handler)) + .route("/api/settings/export", get(settings_export_handler)) + .route("/api/settings/import", post(settings_import_handler)) + .route("/api/settings/{key}", get(settings_get_handler)) + .route( + "/api/settings/{key}", + axum::routing::put(settings_set_handler), + ) + .route( + "/api/settings/{key}", + axum::routing::delete(settings_delete_handler), + ) // Gateway control plane .route("/api/gateway/status", get(gateway_status_handler)) .route_layer(middleware::from_fn_with_state(auth_state, auth_middleware)); @@ -130,9 +176,18 @@ pub async fn start_server( .route("/style.css", get(css_handler)) .route("/app.js", get(js_handler)); + // Project file serving (no auth, local browsing of sandbox outputs). + // The trailing-slash route serves index.html; the bare route redirects so + // relative paths in the HTML (e.g. href="style.css") resolve correctly. + let projects = Router::new() + .route("/projects/{project_id}", get(project_redirect_handler)) + .route("/projects/{project_id}/", get(project_index_handler)) + .route("/projects/{project_id}/{*path}", get(project_file_handler)); + let app = Router::new() .merge(public) .merge(statics) + .merge(projects) .merge(protected) .with_state(state.clone()); @@ -193,6 +248,7 @@ async fn chat_send_handler( if let Some(ref thread_id) = req.thread_id { msg = msg.with_thread(thread_id); + msg = msg.with_metadata(serde_json::json!({"thread_id": thread_id})); } let msg_id = msg.id; @@ -256,7 +312,12 @@ async fn chat_approval_handler( ) })?; - let msg = IncomingMessage::new("gateway", &state.user_id, content); + let mut msg = IncomingMessage::new("gateway", &state.user_id, content); + + if let Some(ref thread_id) = req.thread_id { + msg = msg.with_thread(thread_id); + } + let msg_id = msg.id; let tx_guard = state.msg_tx.read().await; @@ -281,6 +342,85 @@ async fn chat_approval_handler( )) } +/// Submit an auth token directly to the extension manager, bypassing the message pipeline. +/// +/// The token never touches the LLM, chat history, or SSE stream. +async fn chat_auth_token_handler( + State(state): State>, + Json(req): Json, +) -> Result, (StatusCode, String)> { + let ext_mgr = state.extension_manager.as_ref().ok_or(( + StatusCode::SERVICE_UNAVAILABLE, + "Extension manager not available".to_string(), + ))?; + + let result = ext_mgr + .auth(&req.extension_name, Some(&req.token)) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + if result.status == "authenticated" { + // Auto-activate so tools are available immediately + let msg = match ext_mgr.activate(&req.extension_name).await { + Ok(r) => format!( + "{} authenticated ({} tools loaded)", + req.extension_name, + r.tools_loaded.len() + ), + Err(e) => format!( + "{} authenticated but activation failed: {}", + req.extension_name, e + ), + }; + + // Clear auth mode on the active thread + clear_auth_mode(&state).await; + + state.sse.broadcast(SseEvent::AuthCompleted { + extension_name: req.extension_name, + success: true, + message: msg.clone(), + }); + + Ok(Json(ActionResponse::ok(msg))) + } else { + // Re-emit auth_required for retry + state.sse.broadcast(SseEvent::AuthRequired { + extension_name: req.extension_name.clone(), + instructions: result.instructions.clone(), + auth_url: result.auth_url.clone(), + setup_url: result.setup_url.clone(), + }); + Ok(Json(ActionResponse::fail( + result + .instructions + .unwrap_or_else(|| "Invalid token".to_string()), + ))) + } +} + +/// Cancel an in-progress auth flow. +async fn chat_auth_cancel_handler( + State(state): State>, + Json(_req): Json, +) -> Result, (StatusCode, String)> { + clear_auth_mode(&state).await; + Ok(Json(ActionResponse::ok("Auth cancelled"))) +} + +/// Clear pending auth mode on the active thread. +pub async fn clear_auth_mode(state: &GatewayState) { + if let Some(ref sm) = state.session_manager { + let session = sm.get_or_create_session(&state.user_id).await; + let mut sess = session.lock().await; + if let Some(thread_id) = sess.active_thread { + if let Some(thread) = sess.threads.get_mut(&thread_id) { + thread.pending_auth = None; + } + } + } +} + async fn chat_events_handler(State(state): State>) -> impl IntoResponse { // subscribe() returns Sse> so no lifetime issues state.sse.subscribe() @@ -296,6 +436,8 @@ async fn chat_ws_handler( #[derive(Deserialize)] struct HistoryQuery { thread_id: Option, + limit: Option, + before: Option, } async fn chat_history_handler( @@ -310,6 +452,22 @@ async fn chat_history_handler( let session = session_manager.get_or_create_session(&state.user_id).await; let sess = session.lock().await; + let limit = query.limit.unwrap_or(50); + let before_cursor = query + .before + .as_deref() + .map(|s| { + chrono::DateTime::parse_from_rfc3339(s) + .map(|dt| dt.with_timezone(&chrono::Utc)) + .map_err(|_| { + ( + StatusCode::BAD_REQUEST, + "Invalid 'before' timestamp".to_string(), + ) + }) + }) + .transpose()?; + // Find the thread let thread_id = if let Some(ref tid) = query.thread_id { Uuid::parse_str(tid) @@ -319,34 +477,125 @@ async fn chat_history_handler( .ok_or((StatusCode::NOT_FOUND, "No active thread".to_string()))? }; - let thread = sess - .threads - .get(&thread_id) - .ok_or((StatusCode::NOT_FOUND, "Thread not found".to_string()))?; + // For paginated requests (before cursor set), always go to DB + if before_cursor.is_some() { + if let Some(ref store) = state.store { + let (messages, has_more) = store + .list_conversation_messages_paginated(thread_id, before_cursor, limit as i64) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - let turns: Vec = thread - .turns - .iter() - .map(|t| TurnInfo { - turn_number: t.turn_number, - user_input: t.user_input.clone(), - response: t.response.clone(), - state: format!("{:?}", t.state), - started_at: t.started_at.to_rfc3339(), - completed_at: t.completed_at.map(|dt| dt.to_rfc3339()), - tool_calls: t - .tool_calls + let oldest_timestamp = messages.first().map(|m| m.created_at.to_rfc3339()); + let turns = build_turns_from_db_messages(&messages); + return Ok(Json(HistoryResponse { + thread_id, + turns, + has_more, + oldest_timestamp, + })); + } + } + + // Try in-memory first (freshest data for active threads) + if let Some(thread) = sess.threads.get(&thread_id) { + if !thread.turns.is_empty() { + let turns: Vec = thread + .turns .iter() - .map(|tc| ToolCallInfo { - name: tc.name.clone(), - has_result: tc.result.is_some(), - has_error: tc.error.is_some(), + .map(|t| TurnInfo { + turn_number: t.turn_number, + user_input: t.user_input.clone(), + response: t.response.clone(), + state: format!("{:?}", t.state), + started_at: t.started_at.to_rfc3339(), + completed_at: t.completed_at.map(|dt| dt.to_rfc3339()), + tool_calls: t + .tool_calls + .iter() + .map(|tc| ToolCallInfo { + name: tc.name.clone(), + has_result: tc.result.is_some(), + has_error: tc.error.is_some(), + }) + .collect(), }) - .collect(), - }) - .collect(); + .collect(); - Ok(Json(HistoryResponse { thread_id, turns })) + return Ok(Json(HistoryResponse { + thread_id, + turns, + has_more: false, + oldest_timestamp: None, + })); + } + } + + // Fall back to DB for historical threads not in memory (paginated) + if let Some(ref store) = state.store { + let (messages, has_more) = store + .list_conversation_messages_paginated(thread_id, None, limit as i64) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + if !messages.is_empty() { + let oldest_timestamp = messages.first().map(|m| m.created_at.to_rfc3339()); + let turns = build_turns_from_db_messages(&messages); + return Ok(Json(HistoryResponse { + thread_id, + turns, + has_more, + oldest_timestamp, + })); + } + } + + // Empty thread (just created, no messages yet) + Ok(Json(HistoryResponse { + thread_id, + turns: Vec::new(), + has_more: false, + oldest_timestamp: None, + })) +} + +/// Build TurnInfo pairs from flat DB messages (alternating user/assistant). +fn build_turns_from_db_messages(messages: &[crate::history::ConversationMessage]) -> Vec { + let mut turns = Vec::new(); + let mut turn_number = 0; + let mut iter = messages.iter().peekable(); + + while let Some(msg) = iter.next() { + if msg.role == "user" { + let mut turn = TurnInfo { + turn_number, + user_input: msg.content.clone(), + response: None, + state: "Completed".to_string(), + started_at: msg.created_at.to_rfc3339(), + completed_at: None, + tool_calls: Vec::new(), + }; + + // Check if next message is an assistant response + if let Some(next) = iter.peek() { + if next.role == "assistant" { + let assistant_msg = iter.next().expect("peeked"); + turn.response = Some(assistant_msg.content.clone()); + turn.completed_at = Some(assistant_msg.created_at.to_rfc3339()); + } + } + + // Incomplete turn (user message without response) + if turn.response.is_none() { + turn.state = "Failed".to_string(); + } + + turns.push(turn); + turn_number += 1; + } + } + + turns } async fn chat_threads_handler( @@ -360,6 +609,61 @@ async fn chat_threads_handler( let session = session_manager.get_or_create_session(&state.user_id).await; let sess = session.lock().await; + // Try DB first for persistent thread list + if let Some(ref store) = state.store { + // Auto-create assistant thread if it doesn't exist + let assistant_id = store + .get_or_create_assistant_conversation(&state.user_id, "gateway") + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + if let Ok(summaries) = store + .list_conversations_with_preview(&state.user_id, "gateway", 50) + .await + { + let mut assistant_thread = None; + let mut threads = Vec::new(); + + for s in &summaries { + let info = ThreadInfo { + id: s.id, + state: "Idle".to_string(), + turn_count: (s.message_count / 2).max(0) as usize, + created_at: s.started_at.to_rfc3339(), + updated_at: s.last_activity.to_rfc3339(), + title: s.title.clone(), + thread_type: s.thread_type.clone(), + }; + + if s.id == assistant_id { + assistant_thread = Some(info); + } else { + threads.push(info); + } + } + + // If assistant wasn't in the list (0 messages), synthesize it + if assistant_thread.is_none() { + assistant_thread = Some(ThreadInfo { + id: assistant_id, + state: "Idle".to_string(), + turn_count: 0, + created_at: chrono::Utc::now().to_rfc3339(), + updated_at: chrono::Utc::now().to_rfc3339(), + title: None, + thread_type: Some("assistant".to_string()), + }); + } + + return Ok(Json(ThreadListResponse { + assistant_thread, + threads, + active_thread: sess.active_thread, + })); + } + } + + // Fallback: in-memory only (no assistant thread without DB) let threads: Vec = sess .threads .values() @@ -369,10 +673,13 @@ async fn chat_threads_handler( turn_count: t.turns.len(), created_at: t.created_at.to_rfc3339(), updated_at: t.updated_at.to_rfc3339(), + title: None, + thread_type: None, }) .collect(); Ok(Json(ThreadListResponse { + assistant_thread: None, threads, active_thread: sess.active_thread, })) @@ -389,14 +696,39 @@ async fn chat_new_thread_handler( let session = session_manager.get_or_create_session(&state.user_id).await; let mut sess = session.lock().await; let thread = sess.create_thread(); - - Ok(Json(ThreadInfo { + let thread_id = thread.id; + let info = ThreadInfo { id: thread.id, state: format!("{:?}", thread.state), turn_count: thread.turns.len(), created_at: thread.created_at.to_rfc3339(), updated_at: thread.updated_at.to_rfc3339(), - })) + title: None, + thread_type: Some("thread".to_string()), + }; + + // Persist the empty conversation row with thread_type metadata + if let Some(ref store) = state.store { + let store = Arc::clone(store); + let user_id = state.user_id.clone(); + tokio::spawn(async move { + if let Err(e) = store + .ensure_conversation(thread_id, "gateway", &user_id, None) + .await + { + tracing::warn!("Failed to persist new thread: {}", e); + } + let metadata_val = serde_json::json!("thread"); + if let Err(e) = store + .update_conversation_metadata_field(thread_id, "thread_type", &metadata_val) + .await + { + tracing::warn!("Failed to set thread_type metadata: {}", e); + } + }); + } + + Ok(Json(info)) } // --- Memory handlers --- @@ -564,26 +896,38 @@ async fn memory_search_handler( async fn jobs_list_handler( State(state): State>, ) -> Result, (StatusCode, String)> { - let context_manager = state.context_manager.as_ref().ok_or(( + let store = state.store.as_ref().ok_or(( StatusCode::SERVICE_UNAVAILABLE, - "Context manager not available".to_string(), + "Database not available".to_string(), ))?; - let job_ids = context_manager.all_jobs_for(&state.user_id).await; - let mut jobs = Vec::new(); + // Fetch sandbox jobs from the DB. + let sandbox_jobs = store + .list_sandbox_jobs() + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - for job_id in job_ids { - if let Ok(ctx) = context_manager.get_context(job_id).await { - jobs.push(JobInfo { - id: ctx.job_id, - title: ctx.title.clone(), - state: ctx.state.to_string(), - user_id: ctx.user_id.clone(), - created_at: ctx.created_at.to_rfc3339(), - started_at: ctx.started_at.map(|dt| dt.to_rfc3339()), - }); - } - } + let mut jobs: Vec = sandbox_jobs + .iter() + .map(|j| { + let ui_state = match j.status.as_str() { + "creating" => "pending", + "running" => "in_progress", + s => s, + }; + JobInfo { + id: j.id, + title: j.task.clone(), + state: ui_state.to_string(), + user_id: j.user_id.clone(), + created_at: j.created_at.to_rfc3339(), + started_at: j.started_at.map(|dt| dt.to_rfc3339()), + } + }) + .collect(); + + // Most recent first. + jobs.sort_by(|a, b| b.created_at.cmp(&a.created_at)); Ok(Json(JobListResponse { jobs })) } @@ -591,87 +935,404 @@ async fn jobs_list_handler( async fn jobs_summary_handler( State(state): State>, ) -> Result, (StatusCode, String)> { - let context_manager = state.context_manager.as_ref().ok_or(( + let store = state.store.as_ref().ok_or(( StatusCode::SERVICE_UNAVAILABLE, - "Context manager not available".to_string(), + "Database not available".to_string(), ))?; - let summary = context_manager.summary_for(&state.user_id).await; + let s = store + .sandbox_job_summary() + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok(Json(JobSummaryResponse { - total: summary.total, - pending: summary.pending, - in_progress: summary.in_progress, - completed: summary.completed, - failed: summary.failed, - stuck: summary.stuck, + total: s.total, + pending: s.creating, + in_progress: s.running, + completed: s.completed, + failed: s.failed + s.interrupted, + stuck: 0, })) } async fn jobs_detail_handler( State(state): State>, Path(id): Path, -) -> Result, (StatusCode, String)> { - let context_manager = state.context_manager.as_ref().ok_or(( - StatusCode::SERVICE_UNAVAILABLE, - "Context manager not available".to_string(), - ))?; - +) -> Result, (StatusCode, String)> { let job_id = Uuid::parse_str(&id) .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?; - let ctx = context_manager - .get_context(job_id) - .await - .map_err(|_| (StatusCode::NOT_FOUND, "Job not found".to_string()))?; + // Try sandbox job from DB first. + if let Some(ref store) = state.store { + if let Ok(Some(job)) = store.get_sandbox_job(job_id).await { + let browse_id = std::path::Path::new(&job.project_dir) + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| job.id.to_string()); - if ctx.user_id != state.user_id { - return Err((StatusCode::NOT_FOUND, "Job not found".to_string())); + let ui_state = match job.status.as_str() { + "creating" => "pending", + "running" => "in_progress", + s => s, + }; + + let elapsed_secs = job.started_at.map(|start| { + let end = job.completed_at.unwrap_or_else(chrono::Utc::now); + (end - start).num_seconds().max(0) as u64 + }); + + // Synthesize transitions from timestamps. + let mut transitions = Vec::new(); + if let Some(started) = job.started_at { + transitions.push(TransitionInfo { + from: "creating".to_string(), + to: "running".to_string(), + timestamp: started.to_rfc3339(), + reason: None, + }); + } + if let Some(completed) = job.completed_at { + transitions.push(TransitionInfo { + from: "running".to_string(), + to: job.status.clone(), + timestamp: completed.to_rfc3339(), + reason: job.failure_reason.clone(), + }); + } + + return Ok(Json(JobDetailResponse { + id: job.id, + title: job.task.clone(), + description: String::new(), + state: ui_state.to_string(), + user_id: job.user_id.clone(), + created_at: job.created_at.to_rfc3339(), + started_at: job.started_at.map(|dt| dt.to_rfc3339()), + completed_at: job.completed_at.map(|dt| dt.to_rfc3339()), + elapsed_secs, + project_dir: Some(job.project_dir.clone()), + browse_url: Some(format!("/projects/{}/", browse_id)), + job_mode: { + let mode = store.get_sandbox_job_mode(job.id).await.ok().flatten(); + mode.filter(|m| m != "worker") + }, + transitions, + })); + } } - Ok(Json(JobInfo { - id: ctx.job_id, - title: ctx.title.clone(), - state: ctx.state.to_string(), - user_id: ctx.user_id.clone(), - created_at: ctx.created_at.to_rfc3339(), - started_at: ctx.started_at.map(|dt| dt.to_rfc3339()), - })) + Err((StatusCode::NOT_FOUND, "Job not found".to_string())) } async fn jobs_cancel_handler( State(state): State>, Path(id): Path, ) -> Result, (StatusCode, String)> { - let context_manager = state.context_manager.as_ref().ok_or(( + let job_id = Uuid::parse_str(&id) + .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?; + + // Try sandbox job cancellation. + if let Some(ref store) = state.store { + if let Ok(Some(job)) = store.get_sandbox_job(job_id).await { + if job.status == "running" || job.status == "creating" { + // Stop the container if we have a job manager. + if let Some(ref jm) = state.job_manager { + let _ = jm.stop_job(job_id).await; + } + store + .update_sandbox_job_status( + job_id, + "failed", + Some(false), + Some("Cancelled by user"), + None, + Some(chrono::Utc::now()), + ) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + } + return Ok(Json(serde_json::json!({ + "status": "cancelled", + "job_id": job_id, + }))); + } + } + + Err((StatusCode::NOT_FOUND, "Job not found".to_string())) +} + +async fn jobs_restart_handler( + State(state): State>, + Path(id): Path, +) -> Result, (StatusCode, String)> { + let store = state.store.as_ref().ok_or(( StatusCode::SERVICE_UNAVAILABLE, - "Context manager not available".to_string(), + "Database not available".to_string(), + ))?; + let jm = state.job_manager.as_ref().ok_or(( + StatusCode::SERVICE_UNAVAILABLE, + "Sandbox not enabled".to_string(), + ))?; + + let old_job_id = Uuid::parse_str(&id) + .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?; + + let old_job = store + .get_sandbox_job(old_job_id) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .ok_or((StatusCode::NOT_FOUND, "Job not found".to_string()))?; + + if old_job.status != "interrupted" && old_job.status != "failed" { + return Err(( + StatusCode::CONFLICT, + format!("Cannot restart job in state '{}'", old_job.status), + )); + } + + // Create a new job with the same task and project_dir. + let new_job_id = Uuid::new_v4(); + let now = chrono::Utc::now(); + + let record = crate::history::SandboxJobRecord { + id: new_job_id, + task: old_job.task.clone(), + status: "creating".to_string(), + user_id: old_job.user_id.clone(), + project_dir: old_job.project_dir.clone(), + success: None, + failure_reason: None, + created_at: now, + started_at: None, + completed_at: None, + }; + store + .save_sandbox_job(&record) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + // Look up the original job's mode so the restart uses the same mode. + let mode = match store.get_sandbox_job_mode(old_job_id).await { + Ok(Some(m)) if m == "claude_code" => crate::orchestrator::job_manager::JobMode::ClaudeCode, + _ => crate::orchestrator::job_manager::JobMode::Worker, + }; + + let project_dir = std::path::PathBuf::from(&old_job.project_dir); + let _token = jm + .create_job(new_job_id, &old_job.task, Some(project_dir), mode) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to create container: {}", e), + ) + })?; + + store + .update_sandbox_job_status(new_job_id, "running", None, None, Some(now), None) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json(serde_json::json!({ + "status": "restarted", + "old_job_id": old_job_id, + "new_job_id": new_job_id, + }))) +} + +// --- Claude Code prompt and events handlers --- + +/// Submit a follow-up prompt to a running Claude Code sandbox job. +async fn jobs_prompt_handler( + State(state): State>, + Path(id): Path, + Json(body): Json, +) -> Result, (StatusCode, String)> { + let prompt_queue = state.prompt_queue.as_ref().ok_or(( + StatusCode::NOT_IMPLEMENTED, + "Claude Code not configured".to_string(), + ))?; + + let job_id: uuid::Uuid = id + .parse() + .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?; + + let content = body + .get("content") + .and_then(|v| v.as_str()) + .ok_or(( + StatusCode::BAD_REQUEST, + "Missing 'content' field".to_string(), + ))? + .to_string(); + + let done = body.get("done").and_then(|v| v.as_bool()).unwrap_or(false); + + let prompt = crate::orchestrator::api::PendingPrompt { content, done }; + + { + let mut queue = prompt_queue.lock().await; + queue.entry(job_id).or_default().push_back(prompt); + } + + Ok(Json(serde_json::json!({ + "status": "queued", + "job_id": job_id.to_string(), + }))) +} + +/// Load persisted job events for a job (for history replay on page open). +async fn jobs_events_handler( + State(state): State>, + Path(id): Path, +) -> Result, (StatusCode, String)> { + let store = state.store.as_ref().ok_or(( + StatusCode::NOT_IMPLEMENTED, + "Database not available".to_string(), + ))?; + + let job_id: uuid::Uuid = id + .parse() + .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?; + + let events = store + .list_job_events(job_id) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let events_json: Vec = events + .into_iter() + .map(|e| { + serde_json::json!({ + "id": e.id, + "event_type": e.event_type, + "data": e.data, + "created_at": e.created_at.to_rfc3339(), + }) + }) + .collect(); + + Ok(Json(serde_json::json!({ + "job_id": job_id.to_string(), + "events": events_json, + }))) +} + +// --- Project file handlers for sandbox jobs --- + +#[derive(Deserialize)] +struct FilePathQuery { + path: Option, +} + +async fn job_files_list_handler( + State(state): State>, + Path(id): Path, + Query(query): Query, +) -> Result, (StatusCode, String)> { + let store = state.store.as_ref().ok_or(( + StatusCode::SERVICE_UNAVAILABLE, + "Database not available".to_string(), ))?; let job_id = Uuid::parse_str(&id) .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?; - let ctx = context_manager - .get_context(job_id) - .await - .map_err(|_| (StatusCode::NOT_FOUND, "Job not found".to_string()))?; - - if ctx.user_id != state.user_id { - return Err((StatusCode::NOT_FOUND, "Job not found".to_string())); - } - - context_manager - .update_context(job_id, |ctx| { - ctx.transition_to(crate::context::JobState::Cancelled, None) - }) + let job = store + .get_sandbox_job(job_id) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? - .map_err(|msg| (StatusCode::CONFLICT, msg))?; + .ok_or((StatusCode::NOT_FOUND, "Job not found".to_string()))?; - Ok(Json(serde_json::json!({ - "status": "cancelled", - "job_id": job_id, - }))) + let base = std::path::PathBuf::from(&job.project_dir); + let rel_path = query.path.as_deref().unwrap_or(""); + let target = base.join(rel_path); + + // Path traversal guard. + let canonical = target + .canonicalize() + .map_err(|_| (StatusCode::NOT_FOUND, "Path not found".to_string()))?; + let base_canonical = base + .canonicalize() + .map_err(|_| (StatusCode::NOT_FOUND, "Project dir not found".to_string()))?; + if !canonical.starts_with(&base_canonical) { + return Err((StatusCode::FORBIDDEN, "Forbidden".to_string())); + } + + let mut entries = Vec::new(); + let mut read_dir = tokio::fs::read_dir(&canonical) + .await + .map_err(|_| (StatusCode::NOT_FOUND, "Cannot read directory".to_string()))?; + + while let Ok(Some(entry)) = read_dir.next_entry().await { + let name = entry.file_name().to_string_lossy().to_string(); + let is_dir = entry + .file_type() + .await + .map(|ft| ft.is_dir()) + .unwrap_or(false); + let rel = if rel_path.is_empty() { + name.clone() + } else { + format!("{}/{}", rel_path, name) + }; + entries.push(ProjectFileEntry { + name, + path: rel, + is_dir, + }); + } + + entries.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then_with(|| a.name.cmp(&b.name))); + + Ok(Json(ProjectFilesResponse { entries })) +} + +async fn job_files_read_handler( + State(state): State>, + Path(id): Path, + Query(query): Query, +) -> Result, (StatusCode, String)> { + let store = state.store.as_ref().ok_or(( + StatusCode::SERVICE_UNAVAILABLE, + "Database not available".to_string(), + ))?; + + let job_id = Uuid::parse_str(&id) + .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?; + + let job = store + .get_sandbox_job(job_id) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .ok_or((StatusCode::NOT_FOUND, "Job not found".to_string()))?; + + let path = query.path.as_deref().ok_or(( + StatusCode::BAD_REQUEST, + "path parameter required".to_string(), + ))?; + + let base = std::path::PathBuf::from(&job.project_dir); + let file_path = base.join(path); + + let canonical = file_path + .canonicalize() + .map_err(|_| (StatusCode::NOT_FOUND, "File not found".to_string()))?; + let base_canonical = base + .canonicalize() + .map_err(|_| (StatusCode::NOT_FOUND, "Project dir not found".to_string()))?; + if !canonical.starts_with(&base_canonical) { + return Err((StatusCode::FORBIDDEN, "Forbidden".to_string())); + } + + let content = tokio::fs::read_to_string(&canonical) + .await + .map_err(|_| (StatusCode::NOT_FOUND, "Cannot read file".to_string()))?; + + Ok(Json(ProjectFileReadResponse { + path: path.to_string(), + content, + })) } // --- Logs handlers --- @@ -841,6 +1502,61 @@ async fn extensions_activate_handler( } } +// --- Project file serving handlers --- + +/// Redirect `/projects/{id}` to `/projects/{id}/` so relative paths in +/// the served HTML resolve within the project namespace. +async fn project_redirect_handler(Path(project_id): Path) -> impl IntoResponse { + axum::response::Redirect::permanent(&format!("/projects/{project_id}/")) +} + +/// Serve `index.html` when hitting `/projects/{project_id}/`. +async fn project_index_handler(Path(project_id): Path) -> impl IntoResponse { + serve_project_file(&project_id, "index.html").await +} + +/// Serve any file under `/projects/{project_id}/{path}`. +async fn project_file_handler( + Path((project_id, path)): Path<(String, String)>, +) -> impl IntoResponse { + serve_project_file(&project_id, &path).await +} + +/// Shared logic: resolve the file inside `~/.ironclaw/projects/{project_id}/`, +/// guard against path traversal, and stream the content with the right MIME type. +async fn serve_project_file(project_id: &str, path: &str) -> axum::response::Response { + let base = dirs::home_dir() + .unwrap_or_else(|| std::path::PathBuf::from(".")) + .join(".ironclaw") + .join("projects") + .join(project_id); + + let file_path = base.join(path); + + // Path traversal guard + let canonical = match file_path.canonicalize() { + Ok(p) => p, + Err(_) => return (StatusCode::NOT_FOUND, "Not found").into_response(), + }; + let base_canonical = match base.canonicalize() { + Ok(p) => p, + Err(_) => return (StatusCode::NOT_FOUND, "Not found").into_response(), + }; + if !canonical.starts_with(&base_canonical) { + return (StatusCode::FORBIDDEN, "Forbidden").into_response(); + } + + match tokio::fs::read(&canonical).await { + Ok(contents) => { + let mime = mime_guess::from_path(&canonical) + .first_or_octet_stream() + .to_string(); + ([(header::CONTENT_TYPE, mime)], contents).into_response() + } + Err(_) => (StatusCode::NOT_FOUND, "Not found").into_response(), + } +} + async fn extensions_remove_handler( State(state): State>, Path(name): Path, @@ -856,6 +1572,446 @@ async fn extensions_remove_handler( } } +// --- Routines handlers --- + +async fn routines_list_handler( + State(state): State>, +) -> Result, (StatusCode, String)> { + let store = state.store.as_ref().ok_or(( + StatusCode::SERVICE_UNAVAILABLE, + "Database not available".to_string(), + ))?; + + let routines = store + .list_routines(&state.user_id) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let items: Vec = routines.iter().map(routine_to_info).collect(); + + Ok(Json(RoutineListResponse { routines: items })) +} + +async fn routines_summary_handler( + State(state): State>, +) -> Result, (StatusCode, String)> { + let store = state.store.as_ref().ok_or(( + StatusCode::SERVICE_UNAVAILABLE, + "Database not available".to_string(), + ))?; + + let routines = store + .list_routines(&state.user_id) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let total = routines.len() as u64; + let enabled = routines.iter().filter(|r| r.enabled).count() as u64; + let disabled = total - enabled; + let failing = routines + .iter() + .filter(|r| r.consecutive_failures > 0) + .count() as u64; + + let today_start = chrono::Utc::now() + .date_naive() + .and_hms_opt(0, 0, 0) + .map(|dt| dt.and_utc()); + let runs_today = if let Some(start) = today_start { + routines + .iter() + .filter(|r| r.last_run_at.is_some_and(|ts| ts >= start)) + .count() as u64 + } else { + 0 + }; + + Ok(Json(RoutineSummaryResponse { + total, + enabled, + disabled, + failing, + runs_today, + })) +} + +async fn routines_detail_handler( + State(state): State>, + Path(id): Path, +) -> Result, (StatusCode, String)> { + let store = state.store.as_ref().ok_or(( + StatusCode::SERVICE_UNAVAILABLE, + "Database not available".to_string(), + ))?; + + let routine_id = Uuid::parse_str(&id) + .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid routine ID".to_string()))?; + + let routine = store + .get_routine(routine_id) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .ok_or((StatusCode::NOT_FOUND, "Routine not found".to_string()))?; + + let runs = store + .list_routine_runs(routine_id, 20) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let recent_runs: Vec = runs + .iter() + .map(|run| RoutineRunInfo { + id: run.id, + trigger_type: run.trigger_type.clone(), + started_at: run.started_at.to_rfc3339(), + completed_at: run.completed_at.map(|dt| dt.to_rfc3339()), + status: format!("{:?}", run.status), + result_summary: run.result_summary.clone(), + tokens_used: run.tokens_used, + }) + .collect(); + + Ok(Json(RoutineDetailResponse { + id: routine.id, + name: routine.name.clone(), + description: routine.description.clone(), + enabled: routine.enabled, + trigger: serde_json::to_value(&routine.trigger).unwrap_or_default(), + action: serde_json::to_value(&routine.action).unwrap_or_default(), + guardrails: serde_json::to_value(&routine.guardrails).unwrap_or_default(), + notify: serde_json::to_value(&routine.notify).unwrap_or_default(), + last_run_at: routine.last_run_at.map(|dt| dt.to_rfc3339()), + next_fire_at: routine.next_fire_at.map(|dt| dt.to_rfc3339()), + run_count: routine.run_count, + consecutive_failures: routine.consecutive_failures, + created_at: routine.created_at.to_rfc3339(), + recent_runs, + })) +} + +async fn routines_trigger_handler( + State(state): State>, + Path(id): Path, +) -> Result, (StatusCode, String)> { + let store = state.store.as_ref().ok_or(( + StatusCode::SERVICE_UNAVAILABLE, + "Database not available".to_string(), + ))?; + + let routine_id = Uuid::parse_str(&id) + .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid routine ID".to_string()))?; + + let routine = store + .get_routine(routine_id) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .ok_or((StatusCode::NOT_FOUND, "Routine not found".to_string()))?; + + // Send the routine prompt through the message pipeline as a manual trigger. + let prompt = match &routine.action { + crate::agent::routine::RoutineAction::Lightweight { prompt, .. } => prompt.clone(), + crate::agent::routine::RoutineAction::FullJob { + title, description, .. + } => format!("{}: {}", title, description), + }; + + let content = format!("[routine:{}] {}", routine.name, prompt); + let msg = IncomingMessage::new("gateway", &state.user_id, content); + + let tx_guard = state.msg_tx.read().await; + let tx = tx_guard.as_ref().ok_or(( + StatusCode::SERVICE_UNAVAILABLE, + "Channel not started".to_string(), + ))?; + + tx.send(msg).await.map_err(|_| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Channel closed".to_string(), + ) + })?; + + Ok(Json(serde_json::json!({ + "status": "triggered", + "routine_id": routine_id, + }))) +} + +#[derive(Deserialize)] +struct ToggleRequest { + enabled: Option, +} + +async fn routines_toggle_handler( + State(state): State>, + Path(id): Path, + body: Option>, +) -> Result, (StatusCode, String)> { + let store = state.store.as_ref().ok_or(( + StatusCode::SERVICE_UNAVAILABLE, + "Database not available".to_string(), + ))?; + + let routine_id = Uuid::parse_str(&id) + .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid routine ID".to_string()))?; + + let mut routine = store + .get_routine(routine_id) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .ok_or((StatusCode::NOT_FOUND, "Routine not found".to_string()))?; + + // If a specific value was provided, use it; otherwise toggle. + routine.enabled = match body { + Some(Json(req)) => req.enabled.unwrap_or(!routine.enabled), + None => !routine.enabled, + }; + + store + .update_routine(&routine) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json(serde_json::json!({ + "status": if routine.enabled { "enabled" } else { "disabled" }, + "routine_id": routine_id, + }))) +} + +async fn routines_delete_handler( + State(state): State>, + Path(id): Path, +) -> Result, (StatusCode, String)> { + let store = state.store.as_ref().ok_or(( + StatusCode::SERVICE_UNAVAILABLE, + "Database not available".to_string(), + ))?; + + let routine_id = Uuid::parse_str(&id) + .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid routine ID".to_string()))?; + + let deleted = store + .delete_routine(routine_id) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + if deleted { + Ok(Json(serde_json::json!({ + "status": "deleted", + "routine_id": routine_id, + }))) + } else { + Err((StatusCode::NOT_FOUND, "Routine not found".to_string())) + } +} + +async fn routines_runs_handler( + State(state): State>, + Path(id): Path, +) -> Result, (StatusCode, String)> { + let store = state.store.as_ref().ok_or(( + StatusCode::SERVICE_UNAVAILABLE, + "Database not available".to_string(), + ))?; + + let routine_id = Uuid::parse_str(&id) + .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid routine ID".to_string()))?; + + let runs = store + .list_routine_runs(routine_id, 50) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let run_infos: Vec = runs + .iter() + .map(|run| RoutineRunInfo { + id: run.id, + trigger_type: run.trigger_type.clone(), + started_at: run.started_at.to_rfc3339(), + completed_at: run.completed_at.map(|dt| dt.to_rfc3339()), + status: format!("{:?}", run.status), + result_summary: run.result_summary.clone(), + tokens_used: run.tokens_used, + }) + .collect(); + + Ok(Json(serde_json::json!({ + "routine_id": routine_id, + "runs": run_infos, + }))) +} + +/// Convert a Routine to the trimmed RoutineInfo for list display. +fn routine_to_info(r: &crate::agent::routine::Routine) -> RoutineInfo { + let (trigger_type, trigger_summary) = match &r.trigger { + crate::agent::routine::Trigger::Cron { schedule } => { + ("cron".to_string(), format!("cron: {}", schedule)) + } + crate::agent::routine::Trigger::Event { + pattern, channel, .. + } => { + let ch = channel.as_deref().unwrap_or("any"); + ("event".to_string(), format!("on {} /{}/", ch, pattern)) + } + crate::agent::routine::Trigger::Webhook { path, .. } => { + let p = path.as_deref().unwrap_or("/"); + ("webhook".to_string(), format!("webhook: {}", p)) + } + crate::agent::routine::Trigger::Manual => ("manual".to_string(), "manual only".to_string()), + }; + + let action_type = match &r.action { + crate::agent::routine::RoutineAction::Lightweight { .. } => "lightweight", + crate::agent::routine::RoutineAction::FullJob { .. } => "full_job", + }; + + let status = if !r.enabled { + "disabled" + } else if r.consecutive_failures > 0 { + "failing" + } else { + "active" + }; + + RoutineInfo { + id: r.id, + name: r.name.clone(), + description: r.description.clone(), + enabled: r.enabled, + trigger_type, + trigger_summary, + action_type: action_type.to_string(), + last_run_at: r.last_run_at.map(|dt| dt.to_rfc3339()), + next_fire_at: r.next_fire_at.map(|dt| dt.to_rfc3339()), + run_count: r.run_count, + consecutive_failures: r.consecutive_failures, + status: status.to_string(), + } +} + +// --- Settings handlers --- + +async fn settings_list_handler( + State(state): State>, +) -> Result, StatusCode> { + let store = state + .store + .as_ref() + .ok_or(StatusCode::SERVICE_UNAVAILABLE)?; + let rows = store.list_settings(&state.user_id).await.map_err(|e| { + tracing::error!("Failed to list settings: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + let settings = rows + .into_iter() + .map(|r| SettingResponse { + key: r.key, + value: r.value, + updated_at: r.updated_at.to_rfc3339(), + }) + .collect(); + + Ok(Json(SettingsListResponse { settings })) +} + +async fn settings_get_handler( + State(state): State>, + Path(key): Path, +) -> Result, StatusCode> { + let store = state + .store + .as_ref() + .ok_or(StatusCode::SERVICE_UNAVAILABLE)?; + let row = store + .get_setting_full(&state.user_id, &key) + .await + .map_err(|e| { + tracing::error!("Failed to get setting '{}': {}", key, e); + StatusCode::INTERNAL_SERVER_ERROR + })? + .ok_or(StatusCode::NOT_FOUND)?; + + Ok(Json(SettingResponse { + key: row.key, + value: row.value, + updated_at: row.updated_at.to_rfc3339(), + })) +} + +async fn settings_set_handler( + State(state): State>, + Path(key): Path, + Json(body): Json, +) -> Result { + let store = state + .store + .as_ref() + .ok_or(StatusCode::SERVICE_UNAVAILABLE)?; + store + .set_setting(&state.user_id, &key, &body.value) + .await + .map_err(|e| { + tracing::error!("Failed to set setting '{}': {}", key, e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + Ok(StatusCode::NO_CONTENT) +} + +async fn settings_delete_handler( + State(state): State>, + Path(key): Path, +) -> Result { + let store = state + .store + .as_ref() + .ok_or(StatusCode::SERVICE_UNAVAILABLE)?; + store + .delete_setting(&state.user_id, &key) + .await + .map_err(|e| { + tracing::error!("Failed to delete setting '{}': {}", key, e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + Ok(StatusCode::NO_CONTENT) +} + +async fn settings_export_handler( + State(state): State>, +) -> Result, StatusCode> { + let store = state + .store + .as_ref() + .ok_or(StatusCode::SERVICE_UNAVAILABLE)?; + let settings = store.get_all_settings(&state.user_id).await.map_err(|e| { + tracing::error!("Failed to export settings: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + Ok(Json(SettingsExportResponse { settings })) +} + +async fn settings_import_handler( + State(state): State>, + Json(body): Json, +) -> Result { + let store = state + .store + .as_ref() + .ok_or(StatusCode::SERVICE_UNAVAILABLE)?; + store + .set_all_settings(&state.user_id, &body.settings) + .await + .map_err(|e| { + tracing::error!("Failed to import settings: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + Ok(StatusCode::NO_CONTENT) +} + // --- Gateway control plane handlers --- async fn gateway_status_handler( @@ -881,3 +2037,84 @@ struct GatewayStatusResponse { ws_connections: u64, total_connections: u64, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_build_turns_from_db_messages_complete() { + let now = chrono::Utc::now(); + let messages = vec![ + crate::history::ConversationMessage { + id: Uuid::new_v4(), + role: "user".to_string(), + content: "Hello".to_string(), + created_at: now, + }, + crate::history::ConversationMessage { + id: Uuid::new_v4(), + role: "assistant".to_string(), + content: "Hi there!".to_string(), + created_at: now + chrono::TimeDelta::seconds(1), + }, + crate::history::ConversationMessage { + id: Uuid::new_v4(), + role: "user".to_string(), + content: "How are you?".to_string(), + created_at: now + chrono::TimeDelta::seconds(2), + }, + crate::history::ConversationMessage { + id: Uuid::new_v4(), + role: "assistant".to_string(), + content: "Doing well!".to_string(), + created_at: now + chrono::TimeDelta::seconds(3), + }, + ]; + + let turns = build_turns_from_db_messages(&messages); + assert_eq!(turns.len(), 2); + assert_eq!(turns[0].user_input, "Hello"); + assert_eq!(turns[0].response.as_deref(), Some("Hi there!")); + assert_eq!(turns[0].state, "Completed"); + assert_eq!(turns[1].user_input, "How are you?"); + assert_eq!(turns[1].response.as_deref(), Some("Doing well!")); + } + + #[test] + fn test_build_turns_from_db_messages_incomplete_last() { + let now = chrono::Utc::now(); + let messages = vec![ + crate::history::ConversationMessage { + id: Uuid::new_v4(), + role: "user".to_string(), + content: "Hello".to_string(), + created_at: now, + }, + crate::history::ConversationMessage { + id: Uuid::new_v4(), + role: "assistant".to_string(), + content: "Hi!".to_string(), + created_at: now + chrono::TimeDelta::seconds(1), + }, + crate::history::ConversationMessage { + id: Uuid::new_v4(), + role: "user".to_string(), + content: "Lost message".to_string(), + created_at: now + chrono::TimeDelta::seconds(2), + }, + ]; + + let turns = build_turns_from_db_messages(&messages); + assert_eq!(turns.len(), 2); + assert_eq!(turns[1].user_input, "Lost message"); + assert!(turns[1].response.is_none()); + assert_eq!(turns[1].state, "Failed"); + } + + #[test] + fn test_build_turns_from_db_messages_empty() { + let turns = build_turns_from_db_messages(&[]); + assert!(turns.is_empty()); + } +} diff --git a/src/channels/web/sse.rs b/src/channels/web/sse.rs index 240c9c2b..73c73730 100644 --- a/src/channels/web/sse.rs +++ b/src/channels/web/sse.rs @@ -79,7 +79,15 @@ impl SseManager { SseEvent::StreamChunk { .. } => "stream_chunk", SseEvent::Status { .. } => "status", SseEvent::ApprovalNeeded { .. } => "approval_needed", + SseEvent::AuthRequired { .. } => "auth_required", + SseEvent::AuthCompleted { .. } => "auth_completed", SseEvent::Error { .. } => "error", + SseEvent::JobStarted { .. } => "job_started", + SseEvent::JobMessage { .. } => "job_message", + SseEvent::JobToolUse { .. } => "job_tool_use", + SseEvent::JobToolResult { .. } => "job_tool_result", + SseEvent::JobStatus { .. } => "job_status", + SseEvent::JobResult { .. } => "job_result", SseEvent::Heartbeat => "heartbeat", }; Ok(Event::default().event(event_type).data(data)) @@ -152,13 +160,14 @@ mod tests { manager.broadcast(SseEvent::Status { message: "test".to_string(), + thread_id: None, }); let event = rx.next().await; assert!(event.is_some()); let event = event.unwrap().unwrap(); match event { - SseEvent::Status { message } => assert_eq!(message, "test"), + SseEvent::Status { message, .. } => assert_eq!(message, "test"), _ => panic!("unexpected event type"), } } @@ -172,11 +181,12 @@ mod tests { manager.broadcast(SseEvent::Thinking { message: "working".to_string(), + thread_id: None, }); let event = stream.next().await.unwrap(); match event { - SseEvent::Thinking { message } => assert_eq!(message, "working"), + SseEvent::Thinking { message, .. } => assert_eq!(message, "working"), _ => panic!("Expected Thinking event"), } } diff --git a/src/channels/web/static/app.js b/src/channels/web/static/app.js index a7ac57dd..f73ec2af 100644 --- a/src/channels/web/static/app.js +++ b/src/channels/web/static/app.js @@ -4,6 +4,14 @@ let token = ''; let eventSource = null; let logEventSource = null; let currentTab = 'chat'; +let currentThreadId = null; +let assistantThreadId = null; +let hasMore = false; +let oldestTimestamp = null; +let loadingOlder = false; +let jobEvents = new Map(); // job_id -> Array of events +let jobListRefreshTimer = null; +const JOB_EVENTS_CAP = 500; // --- Auth --- @@ -17,15 +25,24 @@ function authenticate() { // Test the token against the health-ish endpoint (chat/threads requires auth) apiFetch('/api/chat/threads') .then(() => { + sessionStorage.setItem('ironclaw_token', token); document.getElementById('auth-screen').style.display = 'none'; document.getElementById('app').style.display = 'flex'; + // Strip token from URL so it's not visible in the address bar + const cleaned = new URL(window.location); + cleaned.searchParams.delete('token'); + window.history.replaceState({}, '', cleaned.pathname + cleaned.search); connectSSE(); connectLogSSE(); - loadHistory(); + startGatewayStatusPolling(); + loadThreads(); loadMemoryTree(); loadJobs(); }) .catch(() => { + sessionStorage.removeItem('ironclaw_token'); + document.getElementById('auth-screen').style.display = ''; + document.getElementById('app').style.display = 'none'; document.getElementById('auth-error').textContent = 'Invalid token'; }); } @@ -34,6 +51,26 @@ document.getElementById('token-input').addEventListener('keydown', (e) => { if (e.key === 'Enter') authenticate(); }); +// Auto-authenticate from URL param or saved session +(function autoAuth() { + const params = new URLSearchParams(window.location.search); + const urlToken = params.get('token'); + if (urlToken) { + document.getElementById('token-input').value = urlToken; + authenticate(); + return; + } + const saved = sessionStorage.getItem('ironclaw_token'); + if (saved) { + document.getElementById('token-input').value = saved; + // Hide auth screen immediately to prevent flash, authenticate() will + // restore it if the token turns out to be invalid. + document.getElementById('auth-screen').style.display = 'none'; + document.getElementById('app').style.display = 'flex'; + authenticate(); + } +})(); + // --- API helper --- function apiFetch(path, options) { @@ -69,34 +106,54 @@ function connectSSE() { eventSource.addEventListener('response', (e) => { const data = JSON.parse(e.data); + if (!isCurrentThread(data.thread_id)) return; addMessage('assistant', data.content); setStatus(''); + enableChatInput(); + // Refresh thread list so new titles appear after first message + loadThreads(); }); eventSource.addEventListener('thinking', (e) => { const data = JSON.parse(e.data); + if (!isCurrentThread(data.thread_id)) return; setStatus(data.message, true); }); eventSource.addEventListener('tool_started', (e) => { const data = JSON.parse(e.data); + if (!isCurrentThread(data.thread_id)) return; setStatus('Running tool: ' + data.name, true); }); eventSource.addEventListener('tool_completed', (e) => { const data = JSON.parse(e.data); + if (!isCurrentThread(data.thread_id)) return; const icon = data.success ? '\u2713' : '\u2717'; setStatus('Tool ' + data.name + ' ' + icon); }); eventSource.addEventListener('stream_chunk', (e) => { const data = JSON.parse(e.data); + if (!isCurrentThread(data.thread_id)) return; appendToLastAssistant(data.content); }); eventSource.addEventListener('status', (e) => { const data = JSON.parse(e.data); + if (!isCurrentThread(data.thread_id)) return; setStatus(data.message); + // "Done" and "Awaiting approval" are terminal signals from the agent: + // the agentic loop finished, so re-enable input as a safety net in case + // the response SSE event is empty or lost. + if (data.message === 'Done' || data.message === 'Awaiting approval') { + enableChatInput(); + } + }); + + eventSource.addEventListener('job_started', (e) => { + const data = JSON.parse(e.data); + showJobCard(data); }); eventSource.addEventListener('approval_needed', (e) => { @@ -104,18 +161,70 @@ function connectSSE() { showApproval(data); }); + eventSource.addEventListener('auth_required', (e) => { + const data = JSON.parse(e.data); + showAuthCard(data); + }); + + eventSource.addEventListener('auth_completed', (e) => { + const data = JSON.parse(e.data); + removeAuthCard(data.extension_name); + showToast(data.message, 'success'); + enableChatInput(); + }); + eventSource.addEventListener('error', (e) => { if (e.data) { const data = JSON.parse(e.data); + if (!isCurrentThread(data.thread_id)) return; addMessage('system', 'Error: ' + data.message); + enableChatInput(); } }); + + // Job event listeners (activity stream for all sandbox jobs) + const jobEventTypes = [ + 'job_message', 'job_tool_use', 'job_tool_result', + 'job_status', 'job_result' + ]; + for (const evtType of jobEventTypes) { + eventSource.addEventListener(evtType, (e) => { + const data = JSON.parse(e.data); + const jobId = data.job_id; + if (!jobId) return; + if (!jobEvents.has(jobId)) jobEvents.set(jobId, []); + const events = jobEvents.get(jobId); + events.push({ type: evtType, data: data, ts: Date.now() }); + // Cap per-job events to prevent memory leak + while (events.length > JOB_EVENTS_CAP) events.shift(); + // If the Activity tab is currently visible for this job, refresh it + refreshActivityTab(jobId); + // Auto-refresh job list when on jobs tab (debounced) + if ((evtType === 'job_result' || evtType === 'job_status') && currentTab === 'jobs' && !currentJobId) { + clearTimeout(jobListRefreshTimer); + jobListRefreshTimer = setTimeout(loadJobs, 200); + } + // Clean up finished job events after a viewing window + if (evtType === 'job_result') { + setTimeout(() => jobEvents.delete(jobId), 60000); + } + }); + } +} + +// Check if an SSE event belongs to the currently viewed thread. +// Events without a thread_id (legacy) are always shown. +function isCurrentThread(threadId) { + if (!threadId) return true; + if (!currentThreadId) return true; + return threadId === currentThreadId; } // --- Chat --- function sendMessage() { const input = document.getElementById('chat-input'); + const sendBtn = document.getElementById('send-btn'); const content = input.value.trim(); if (!content) return; @@ -124,19 +233,31 @@ function sendMessage() { autoResizeTextarea(input); setStatus('Sending...', true); + sendBtn.disabled = true; + input.disabled = true; + apiFetch('/api/chat/send', { method: 'POST', - body: { content }, + body: { content, thread_id: currentThreadId || undefined }, }).catch((err) => { addMessage('system', 'Failed to send: ' + err.message); setStatus(''); + enableChatInput(); }); } +function enableChatInput() { + const input = document.getElementById('chat-input'); + const sendBtn = document.getElementById('send-btn'); + sendBtn.disabled = false; + input.disabled = false; + input.focus(); +} + function sendApprovalAction(requestId, action) { apiFetch('/api/chat/approval', { method: 'POST', - body: { request_id: requestId, action: action }, + body: { request_id: requestId, action: action, thread_id: currentThreadId }, }).catch((err) => { addMessage('system', 'Failed to send approval: ' + err.message); }); @@ -159,11 +280,24 @@ function sendApprovalAction(requestId, action) { function renderMarkdown(text) { if (typeof marked !== 'undefined') { - return marked.parse(text); + let html = marked.parse(text); + // Inject copy buttons into