diff --git a/core/platform_compat.py b/core/platform_compat.py index efa496ac6..e667c1e96 100644 --- a/core/platform_compat.py +++ b/core/platform_compat.py @@ -36,6 +36,19 @@ IS_APPLE_SILICON = ( ) +# ── procfs ────────────────────────────────────────────────────────────────── +# Linux exposes one directory per pid under /proc; macOS and Windows have no +# procfs at all. Any code that walks it must skip the walk rather than raise. +# Kept as a module attribute so both branches stay testable on either kind of +# host. +PROC_ROOT = Path("/proc") + + +def has_procfs() -> bool: + """True when the host exposes a procfs pid tree that can be scanned.""" + return PROC_ROOT.is_dir() + + # ── File permissions ──────────────────────────────────────────────────────── def safe_chmod(path, mode: int) -> bool: """``os.chmod`` that is a harmless no-op on Windows. diff --git a/routes/mcp/mcp_routes.py b/routes/mcp/mcp_routes.py index 94c83f8dd..0e2b30276 100644 --- a/routes/mcp/mcp_routes.py +++ b/routes/mcp/mcp_routes.py @@ -181,10 +181,17 @@ def setup_mcp_routes(mcp_manager: McpManager): if transport == "http" and not url: raise HTTPException(400, "url is required for HTTP transport") - # Parse JSON fields - try: - parsed_args = json.loads(args) if args else [] - except json.JSONDecodeError: + # Parse JSON fields. args is not defaulted on a parse failure: an + # unparseable value is silently discarded downstream (stdio spawns + # with an empty argv), so the caller must be told instead. + if args: + try: + parsed_args = json.loads(args) + except json.JSONDecodeError: + raise HTTPException(400, "args must be valid JSON, e.g. [\"-y\", \"pkg\"]") + if not isinstance(parsed_args, list): + raise HTTPException(400, "args must be a JSON array, e.g. [\"-y\", \"pkg\"]") + else: parsed_args = [] try: parsed_env = json.loads(env) if env else {} diff --git a/scripts/css_snapshot.py b/scripts/css_snapshot.py index ba927775f..360fa11da 100644 --- a/scripts/css_snapshot.py +++ b/scripts/css_snapshot.py @@ -1,8 +1,8 @@ #!/usr/bin/env python3 -"""Computed-style snapshot harness for ``static/style.css``. +"""Computed-style snapshot harness for the shipped app CSS cascade. -``static/style.css`` is a single 51k-line stylesheet whose rendered result -depends on source order: hundreds of selectors are declared more than once and +The app CSS is an ordered multi-file cascade whose rendered result depends +on source order: hundreds of selectors are declared more than once and ``!important`` is used throughout. Any restructuring - extracting a block into its own file, reordering ```` tags, moving an ``@media`` rule - can silently change which declaration wins, and nothing else in the suite would diff --git a/src/model_discovery.py b/src/model_discovery.py index 4d67502c5..116951f9d 100644 --- a/src/model_discovery.py +++ b/src/model_discovery.py @@ -38,7 +38,10 @@ def discover_tailscale_hosts() -> List[str]: global _hosts_cache, _hosts_cache_time now = time.time() - if _hosts_cache and (now - _hosts_cache_time) < _HOSTS_CACHE_TTL: + # Gate on the timestamp, not the list: a successful query that found no + # eligible peers is a real answer, and testing the list's truthiness made + # that case re-run `tailscale status` (up to a 5s timeout) on every call. + if _hosts_cache_time and (now - _hosts_cache_time) < _HOSTS_CACHE_TTL: return list(_hosts_cache) hosts = [] diff --git a/src/tools/cookbook.py b/src/tools/cookbook.py index 96529d94e..c2def959b 100644 --- a/src/tools/cookbook.py +++ b/src/tools/cookbook.py @@ -17,6 +17,7 @@ import re from typing import Any, Dict, List, Optional from fastapi import HTTPException +from core import platform_compat from routes._validators import validate_remote_host, validate_ssh_port from src.tools._common import _parse_tool_args @@ -676,17 +677,17 @@ def _scan_running_model_processes() -> List[Dict[str, Any]]: a dict shaped like a cookbook task so the caller can merge cleanly. """ import os - if not os.path.isdir("/proc"): + if not platform_compat.has_procfs(): return [] + proc_root = platform_compat.PROC_ROOT out: List[Dict[str, Any]] = [] seen_keys = set() try: - for pid_dir in os.listdir("/proc"): + for pid_dir in os.listdir(proc_root): if not pid_dir.isdigit(): continue try: - with open(f"/proc/{pid_dir}/cmdline", "rb") as f: - raw = f.read() + raw = (proc_root / pid_dir / "cmdline").read_bytes() except (OSError, PermissionError): continue if not raw: @@ -1124,12 +1125,16 @@ async def _cookbook_kill_session(session_id: str, *, remote_host: str = "", import signal tracked_cmd = str((matched.get("payload") or {}).get("_cmd") or "").strip() matched_pids: list[int] = [] - if tracked_cmd: - for pid_name in os.listdir("/proc"): + # No procfs means no way to match a survivor by its command line. + # The tmux kill above already stopped the session, so skip the + # sweep instead of failing a stop that worked. + if tracked_cmd and platform_compat.has_procfs(): + proc_root = platform_compat.PROC_ROOT + for pid_name in os.listdir(proc_root): if not pid_name.isdigit() or int(pid_name) == os.getpid(): continue try: - raw = open(f"/proc/{pid_name}/cmdline", "rb").read() + raw = (proc_root / pid_name / "cmdline").read_bytes() process_cmd = raw.replace(b"\x00", b" ").decode("utf-8", errors="replace").strip() except (OSError, PermissionError): continue diff --git a/static/css/01-agent-chat.css b/static/css/01-agent-chat.css index 9e70c4f2c..f06f3b050 100644 --- a/static/css/01-agent-chat.css +++ b/static/css/01-agent-chat.css @@ -1328,7 +1328,7 @@ body.bg-pattern-ascii-fireflies { 0% { opacity: 1; transform: translateY(0) translateX(0) scale(1); } 100% { opacity: 0; transform: translateY(6px) translateX(-3px) scale(0.94); } } - @keyframes spin { to { transform: rotate(360deg); } } + .row { display:flex; gap:6px; align-items:center; } .list-item:hover, .models-row:hover { @@ -2407,7 +2407,7 @@ body.bg-pattern-ascii-fireflies { min-height: 20px; min-width: 80px; } - + /* Ensure code block headers are only slightly larger than regular text */ pre h1, pre h2, pre h3, pre h4, pre h5, pre h6 { font-size: 1.1em; @@ -3944,11 +3944,11 @@ body.bg-pattern-ascii-fireflies { .attach-strip:empty { display: none; } - .attach-strip { - display: flex; - gap: 6px; - flex-wrap: wrap; - margin: 6px 0 0; + .attach-strip { + display: flex; + gap: 6px; + flex-wrap: wrap; + margin: 6px 0 0; min-height: 32px; padding: 2px; } @@ -4991,10 +4991,10 @@ body.bg-pattern-ascii-fireflies { display:flex; align-items:center; gap:6px; border:1px solid var(--border); padding:4px; margin:4px 0; border-radius: 4px; } .models-row .grow, - .models-row select { - flex:1; - display:flex; - align-items:center; + .models-row select { + flex:1; + display:flex; + align-items:center; font-size: 9.75px; } .model-fav-btn { @@ -5967,9 +5967,7 @@ body.bg-pattern-ascii-fireflies { #mobile-backdrop, #mobile-menu-btn { display:none !important; } #sidebar-backdrop { display:none !important; } /* ----- Loading spinner ----- */ - @keyframes spin { - to { transform: rotate(360deg); } - } + .spinner { width: 24px; height: 24px; @@ -5997,7 +5995,7 @@ body.bg-pattern-ascii-fireflies { padding: 4px; border-left: 2px solid var(--red); background: color-mix(in srgb, var(--red) 5%, transparent); - } + } /* Loading indicator for messages */ .loading-indicator { display: flex; @@ -6088,14 +6086,7 @@ body.bg-pattern-ascii-fireflies { .session-skeleton-bubble.is-user { width: 72%; } .session-skeleton-line { height: 9px; margin-top: 7px; } } - @keyframes loading-bounce { - 0%, 80%, 100% { - transform: scale(0); - } - 40% { - transform: scale(1); - } - } + /* Modal styling */ .modal { position:fixed; @@ -6689,7 +6680,7 @@ body.bg-pattern-ascii-fireflies { opacity: 0.6; cursor: not-allowed; } - + /* ── Drag & Drop ── */ /* ---------- Color palette (dark / light) ---------- */ @@ -6699,28 +6690,28 @@ body.bg-pattern-ascii-fireflies { background: color-mix(in srgb, var(--red) 10%, transparent) !important; border-color: var(--red) !important; } - + .section[dnd-over="true"] { background: color-mix(in srgb, var(--red) 20%, transparent) !important; border-color: var(--red) !important; transform: scale(1.02); } - + .drag-handle { cursor: grab; opacity: 0.5; padding: 0 6px; user-select: none; } - + .drag-handle:hover { opacity: 0.8; } - + .drag-handle:active { cursor: grabbing; } - + /* ── UI Controls (Radio, Presets, Toolbar, Settings) ── */ @@ -6736,12 +6727,12 @@ body.bg-pattern-ascii-fireflies { cursor: pointer; transition: all 0.2s ease; } - + .radio-option:hover { background: color-mix(in srgb, var(--fg) 9%, transparent); border-color: var(--fg); } - + .radio-option input[type="radio"] { appearance: none; -webkit-appearance: none; @@ -6756,22 +6747,22 @@ body.bg-pattern-ascii-fireflies { position: relative; flex-shrink: 0; } - + .radio-option input[type="radio"]:checked { border-color: var(--red); background: var(--red); } - + .radio-option input[type="radio"]:focus { box-shadow: 0 0 0 2px color-mix(in srgb, var(--red) 30%, transparent); } - + .radio-label { color: var(--fg); font-size: 14px; user-select: none; } - + /* Preset buttons */ .preset-btn { height: 27.2px; /* 15% smaller than 32px */ @@ -6786,29 +6777,29 @@ body.bg-pattern-ascii-fireflies { cursor: pointer; transition: all 0.2s ease; } - + .preset-btn:hover { background: var(--panel); border-color: var(--fg); } - + .preset-btn.active { background: var(--panel); border-color: var(--fg); box-shadow: 0 0 0 1px var(--fg), 0 0 8px color-mix(in srgb, var(--fg) 16%, transparent); font-weight: 600; } - + /* All preset buttons use the same blue color */ .preset-btn { border-color: var(--red); /* Blue color */ } - + .preset-btn.active { border-color: var(--red); /* Blue color when active */ box-shadow: 0 0 0 1px var(--red), 0 0 8px color-mix(in srgb, var(--red) 30%, transparent); } - + /* Custom preset modal: the base .preset-modal-content sets overflow:hidden (for desktop rounded-corner clipping), which on the mobile sheet clipped the footer (Start/Cancel) off the bottom with no way to reach it. Let the @@ -7366,7 +7357,7 @@ body.bg-pattern-ascii-fireflies { background: var(--border); margin: 4px 8px; } - + /* Search toggle styles */ .search-toggle { display: flex; @@ -7376,7 +7367,7 @@ body.bg-pattern-ascii-fireflies { border-bottom: 1px solid var(--border); margin-bottom: 8px; } - + .search-provider-label { font-size: 14px; color: var(--fg); @@ -7391,24 +7382,20 @@ body.bg-pattern-ascii-fireflies { border-radius: 4px; transition: all 0.2s ease; } - + #mic-btn:hover { background: color-mix(in srgb, var(--fg) 6%, transparent); border-color: var(--fg); } - + #mic-btn.recording { background: var(--color-recording); border-color: var(--color-recording); animation: pulse 1.5s infinite; } - - @keyframes pulse { - 0% { opacity: 1; } - 50% { opacity: 0.7; } - 100% { opacity: 1; } - } - + + + #recording-indicator { position: fixed; top: 10px; @@ -7426,29 +7413,29 @@ body.bg-pattern-ascii-fireflies { justify-content: space-between; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); } - + #recording-indicator.hidden { display: none !important; } - + .recording-content { display: flex; align-items: center; gap: 12px; color: white; } - + .recording-icon { color: var(--color-recording); font-size: 20px; animation: pulse 1.5s infinite; } - + .recording-text { font-size: 16px; font-weight: 500; } - + #stop-recording { background: var(--color-recording); color: white; @@ -7460,32 +7447,32 @@ body.bg-pattern-ascii-fireflies { cursor: pointer; transition: background 0.2s ease; } - + #stop-recording:hover { background: var(--color-recording-hover); } - + /* Error state for recording */ #recording-indicator.error { background: rgba(173, 26, 26, 0.9); } - + .recording-error { color: var(--color-recording); font-size: 14px; margin-top: 4px; } - + @media (max-width: 768px) { #recording-indicator { margin: 8px; padding: 10px; } - + .recording-text { font-size: 14px; } - + #stop-recording { padding: 6px 10px; font-size: 12px; diff --git a/static/css/02-compare.css b/static/css/02-compare.css index df2e1e444..ae04ba663 100644 --- a/static/css/02-compare.css +++ b/static/css/02-compare.css @@ -764,7 +764,7 @@ } .compare-probe-start-anyway svg { color: currentColor; } .compare-probe-start-anyway span { font-weight: 700; } - @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } + @keyframes pane-shake { 0%, 100% { transform: translateX(0); } 15% { transform: translateX(-3px) rotate(-0.5deg); } diff --git a/static/css/03-agent-chat.css b/static/css/03-agent-chat.css index ed4f7e58b..0536fe3b6 100644 --- a/static/css/03-agent-chat.css +++ b/static/css/03-agent-chat.css @@ -2085,11 +2085,11 @@ body.fullwidth-chat .chat-history { margin: 8px; padding: 10px; } - + .recording-text { font-size: 14px; } - + .stop-recording-btn { padding: 6px 10px; font-size: 12px; diff --git a/static/css/cookbook-research-memory-settings.css b/static/css/cookbook-research-memory-settings.css index 3bcbb08cd..e8310d049 100644 --- a/static/css/cookbook-research-memory-settings.css +++ b/static/css/cookbook-research-memory-settings.css @@ -1,22 +1,20 @@ /* Cookbook (including What Fits?), deep research, memory, settings and admin. * - * Split out of style.css for maintainability, not for performance: this file - * is loaded eagerly from index.html immediately after style.css, so the rules - * land in the cascade in exactly the order they had inside the single file. - * Nothing here is lazy-loaded. + * This is an eagerly loaded tail segment of the app stylesheet cascade. The + * former static/style.css now lives in the numbered 00-17 fragments; these + * three pre-existing panel files follow those fragments without changing + * their relative order. Nothing here is lazy-loaded. * - * LOAD ORDER IS PART OF THE CONTRACT. The tags in static/index.html and - * the PRECACHE list in static/sw.js must both list the stylesheets in this - * order: - * 1. static/style.css + * LOAD ORDER IS PART OF THE CONTRACT. static/index.html and the PRECACHE list + * in static/sw.js must agree: + * 1. static/css/00-tokens.css through static/css/17-research.css, + * in numeric order * 2. static/css/documents-gallery-editor.css * 3. static/css/email-calendar-notes-tasks.css * 4. static/css/cookbook-research-memory-settings.css * - * Rules were moved verbatim. A rule stays in style.css when its selector group - * covers more than one panel, when the class is shared design language used by - * modules outside this panel, or when moving it would change which of two - * equally specific rules wins. + * Rules in this file retain their existing cascade position. Shared rules + * formerly kept in static/style.css now live in the numbered fragments. */ /* Cookbook already shows its own running/served-status dot @@ -28,10 +26,6 @@ 0%, 100% { transform: scale(1); opacity: 1; } 50% { transform: scale(1.5); opacity: 0.7; } } -@keyframes research-pulse { - 0%, 100% { background: color-mix(in srgb, var(--red) 12%, transparent); } - 50% { background: color-mix(in srgb, var(--red) 22%, transparent); } - } #cookbook-gguf-delete-overlay { background:rgba(0,0,0,0.5); backdrop-filter:blur(4px); diff --git a/static/css/documents-gallery-editor.css b/static/css/documents-gallery-editor.css index 02c446729..cc37362bb 100644 --- a/static/css/documents-gallery-editor.css +++ b/static/css/documents-gallery-editor.css @@ -1,22 +1,20 @@ /* Document library and editor, image gallery, image editor. * - * Split out of style.css for maintainability, not for performance: this file - * is loaded eagerly from index.html immediately after style.css, so the rules - * land in the cascade in exactly the order they had inside the single file. - * Nothing here is lazy-loaded. + * This is an eagerly loaded tail segment of the app stylesheet cascade. The + * former static/style.css now lives in the numbered 00-17 fragments; these + * three pre-existing panel files follow those fragments without changing + * their relative order. Nothing here is lazy-loaded. * - * LOAD ORDER IS PART OF THE CONTRACT. The tags in static/index.html and - * the PRECACHE list in static/sw.js must both list the stylesheets in this - * order: - * 1. static/style.css + * LOAD ORDER IS PART OF THE CONTRACT. static/index.html and the PRECACHE list + * in static/sw.js must agree: + * 1. static/css/00-tokens.css through static/css/17-research.css, + * in numeric order * 2. static/css/documents-gallery-editor.css * 3. static/css/email-calendar-notes-tasks.css * 4. static/css/cookbook-research-memory-settings.css * - * Rules were moved verbatim. A rule stays in style.css when its selector group - * covers more than one panel, when the class is shared design language used by - * modules outside this panel, or when moving it would change which of two - * equally specific rules wins. + * Rules in this file retain their existing cascade position. Shared rules + * formerly kept in static/style.css now live in the numbered fragments. */ /* Document library search-term highlight — clear, high-contrast. */ diff --git a/static/css/email-calendar-notes-tasks.css b/static/css/email-calendar-notes-tasks.css index ccc27f4f5..7a19b6021 100644 --- a/static/css/email-calendar-notes-tasks.css +++ b/static/css/email-calendar-notes-tasks.css @@ -1,22 +1,20 @@ /* Email, calendar, notes and tasks panels. * - * Split out of style.css for maintainability, not for performance: this file - * is loaded eagerly from index.html immediately after style.css, so the rules - * land in the cascade in exactly the order they had inside the single file. - * Nothing here is lazy-loaded. + * This is an eagerly loaded tail segment of the app stylesheet cascade. The + * former static/style.css now lives in the numbered 00-17 fragments; these + * three pre-existing panel files follow those fragments without changing + * their relative order. Nothing here is lazy-loaded. * - * LOAD ORDER IS PART OF THE CONTRACT. The tags in static/index.html and - * the PRECACHE list in static/sw.js must both list the stylesheets in this - * order: - * 1. static/style.css + * LOAD ORDER IS PART OF THE CONTRACT. static/index.html and the PRECACHE list + * in static/sw.js must agree: + * 1. static/css/00-tokens.css through static/css/17-research.css, + * in numeric order * 2. static/css/documents-gallery-editor.css * 3. static/css/email-calendar-notes-tasks.css * 4. static/css/cookbook-research-memory-settings.css * - * Rules were moved verbatim. A rule stays in style.css when its selector group - * covers more than one panel, when the class is shared design language used by - * modules outside this panel, or when moving it would change which of two - * equally specific rules wins. + * Rules in this file retain their existing cascade position. Shared rules + * formerly kept in static/style.css now live in the numbered fragments. */ @keyframes email-notif-breathe { diff --git a/static/js/admin.js b/static/js/admin.js index 3e3463e9e..9a7ac8bf9 100644 --- a/static/js/admin.js +++ b/static/js/admin.js @@ -3142,6 +3142,7 @@ function initMcpForm() { if (transport === 'stdio' && !command) { msg.textContent = 'Command is required for stdio'; msg.className = 'admin-error'; return; } if (transport === 'sse' && !url) { msg.textContent = 'URL is required for SSE'; msg.className = 'admin-error'; return; } try { JSON.parse(env); } catch { msg.textContent = 'Env must be valid JSON'; msg.className = 'admin-error'; return; } + try { JSON.parse(args); } catch { msg.textContent = 'Args must be valid JSON, e.g. ["-y", "pkg"]'; msg.className = 'admin-error'; return; } const fd = new FormData(); fd.append('name', name); fd.append('transport', transport); fd.append('command', command); fd.append('args', args); fd.append('env', env); fd.append('url', url); // If preset has oauthFile config, send credentials for file generation @@ -3162,6 +3163,10 @@ function initMcpForm() { try { const res = await fetch('/api/mcp/servers', { method: 'POST', body: fd, credentials: 'same-origin' }); const data = await res.json(); + if (!res.ok) { + msg.textContent = data.detail || `Failed (${res.status})`; msg.className = 'admin-error'; + return; + } if (data.needs_oauth) { msg.innerHTML = `Added ${esc(name)} — Authorize with Google to connect`; msg.className = 'admin-success'; diff --git a/static/js/settings.js b/static/js/settings.js index d6c13558c..c59b751e1 100644 --- a/static/js/settings.js +++ b/static/js/settings.js @@ -5102,7 +5102,11 @@ async function initUnifiedIntegrations() { fd.append('transport', transport); if (transport === 'stdio') { fd.append('command', el('uf-mcp-cmd').value); - let args = '[]'; try { args = JSON.stringify(JSON.parse(el('uf-mcp-args').value || '[]')); } catch (_) {} + // Unlike env below, an unparseable args value is not silently + // defaulted: it would spawn the subprocess with an empty argv. + let args; + try { args = JSON.stringify(JSON.parse(el('uf-mcp-args').value || '[]')); } + catch (_) { el('uf-mcp-msg').textContent = 'Args must be valid JSON, e.g. ["-y", "pkg"]'; return; } let env = '{}'; try { env = JSON.stringify(JSON.parse(el('uf-mcp-env').value || '{}')); } catch (_) {} fd.append('args', args); fd.append('env', env); @@ -5124,7 +5128,12 @@ async function initUnifiedIntegrations() { } else if (r.ok) { el('uf-mcp-msg').textContent = 'Saved'; formEl.style.display = 'none'; await renderList(); } else { - el('uf-mcp-msg').textContent = `Failed (${r.status})`; + // Surface the server's reason. The Args validation above rejects + // unparseable JSON, but `"x"` and `{}` parse and are refused by + // routes/mcp/mcp_routes.py with a message naming the expected + // shape; a bare status code sends the user looking in the wrong + // place. Matches what admin.js shows for the same endpoint. + el('uf-mcp-msg').textContent = data.detail || `Failed (${r.status})`; } } catch (_) { el('uf-mcp-msg').textContent = 'Failed'; } finally { _setBtnLoading(saveBtn, false, _origLabel); if (cancelBtn) cancelBtn.disabled = false; } diff --git a/static/js/signature.js b/static/js/signature.js index 374bd4c94..526766808 100644 --- a/static/js/signature.js +++ b/static/js/signature.js @@ -316,7 +316,7 @@ class SmoothPad { } function _modal(innerHtml) { - // Match the app's standard .modal pattern (defined in static/style.css). + // Match the app's standard .modal pattern from the main stylesheet cascade. const overlay = document.createElement('div'); overlay.className = 'modal sig-modal-overlay'; overlay.style.cssText = 'pointer-events:auto;background:rgba(0,0,0,0.45);z-index:10100;'; diff --git a/static/login.html b/static/login.html index 17a7d2dfa..e9dfdde82 100644 --- a/static/login.html +++ b/static/login.html @@ -106,7 +106,7 @@