From c9840eb00b4436c91f8afba757dc5d685ffde838 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sat, 8 Aug 2026 20:48:47 -0700 Subject: [PATCH] [eric] hardening: legacy pre-collapse tool ids canonicalize to core, and the docked mini gets a load listener plus a 1s belt so a silent layout shift can't strand it --- backend/apps/tools_lib/tools_lib.py | 7 ++++--- backend/tests/test_tool_policy_slot.py | 12 ++++++++++++ .../src/app/pages/Dashboard/cards/BrowserCard.tsx | 8 ++++++++ 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/backend/apps/tools_lib/tools_lib.py b/backend/apps/tools_lib/tools_lib.py index 2453242e..e9680279 100644 --- a/backend/apps/tools_lib/tools_lib.py +++ b/backend/apps/tools_lib/tools_lib.py @@ -208,12 +208,13 @@ def resolve_policy_slot(tool_name: str, tools: list[ToolDefinition]) -> PolicySl differently. That divergence was the bug behind 'Always approve' acting like a one-time accept: writes landed under the raw mcp__server__action name while the gate read the parsed inner action, so the next call never saw the policy.""" + # 1.7.5 sessions/logs may still carry the pre-collapse server names; they are the same builtin tools, so any legacy prefix canonicalizes to core before slot resolution. + tool_name = re.sub( + r"^mcp__openswarm-(?:browser-agent|invoke-agent|spawn-agent|skill|ui|schedule|web|mcp-meta|settings-meta|apps)__", + "mcp__openswarm-core__", tool_name) bm = re.match(r"mcp__openswarm-core__(.+)", tool_name) if bm: return PolicySlot("builtin", bm.group(1), None) - im = re.match(r"mcp__openswarm-core__(.+)", tool_name) - if im: - return PolicySlot("builtin", im.group(1), None) m = re.match(r"mcp__([^_]+(?:-[^_]+)*)__(.+)", tool_name) if m: server_slug, action = m.group(1), m.group(2) diff --git a/backend/tests/test_tool_policy_slot.py b/backend/tests/test_tool_policy_slot.py index 133c16c9..e33d0a91 100644 --- a/backend/tests/test_tool_policy_slot.py +++ b/backend/tests/test_tool_policy_slot.py @@ -136,3 +136,15 @@ def test_mcp_policy_survives_a_real_tool_file_reload(tmp_path, monkeypatch): rslot = tl.resolve_policy_slot(name, tools2) got = next(t for t in tools2 if t.id == rslot.key) assert got.tool_permissions.get(rslot.action) == "always_allow" + + +def test_legacy_precollapse_ids_canonicalize_to_core(): + # A 1.7.5 session/log can still name the old per-server ids; they must land in the SAME builtin slot as their openswarm-core successors, or a remembered approval silently stops matching after the upgrade. + for legacy, inner in ( + ("mcp__openswarm-browser-agent__BrowserAgent", "BrowserAgent"), + ("mcp__openswarm-web__WebSearch", "WebSearch"), + ("mcp__openswarm-settings-meta__SettingsWrite", "SettingsWrite"), + ("mcp__openswarm-schedule__ScheduleWorkflow", "ScheduleWorkflow"), + ): + assert resolve_policy_slot(legacy, []) == resolve_policy_slot(f"mcp__openswarm-core__{inner}", []) + assert resolve_policy_slot(legacy, []).key == inner diff --git a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx index 0e2704f4..259cf411 100644 --- a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx @@ -271,8 +271,11 @@ const BrowserCard: React.FC = ({ ro.observe(slot); if (slot.parentElement) ro.observe(slot.parentElement); scrollHost?.removeEventListener('scroll', onScroll); + scrollHost?.removeEventListener('load', measure, true); scrollHost = slot.closest('[data-chat-transcript]'); scrollHost?.addEventListener('scroll', onScroll, { passive: true }); + // An image/iframe finishing load ABOVE the slot shifts it with no scroll, resize, or mutation; load doesn't bubble but it does capture. + scrollHost?.addEventListener('load', measure, true); // Streaming appends move/remount the slot without resizing or scrolling anything observed; the mutation observer is what keeps the mini glued during a live turn. mo.disconnect(); if (scrollHost) mo.observe(scrollHost, { childList: true, subtree: true }); @@ -317,6 +320,9 @@ const BrowserCard: React.FC = ({ if ((e as CustomEvent).detail?.id === dockedTo) measure(); }; window.addEventListener('openswarm:browser-slot-mounted', onSlotMounted); + // A 1s belt bounds any move-without-event gap nobody has named yet. Only alive while a mini is + // docked, so it costs one layout read per second during active docking instead of 6.7/sec forever. + const belt = window.setInterval(measure, 1000); return () => { ro.disconnect(); mo.disconnect(); @@ -324,9 +330,11 @@ const BrowserCard: React.FC = ({ window.removeEventListener('openswarm:canvas-pan-changed', measure); document.removeEventListener('visibilitychange', measure); window.removeEventListener('openswarm:browser-slot-mounted', onSlotMounted); + scrollHost?.removeEventListener('load', measure, true); scrollHost?.removeEventListener('scroll', onScroll); if (scrollRaf) cancelAnimationFrame(scrollRaf); timers.forEach((tm) => window.clearTimeout(tm)); + window.clearInterval(belt); }; // dockParentCard x/y/w/h are re-measure triggers: the slot's client rect moves with the chat card. }, [dockedTo, dockParentExpanded, dockParentTiled, dockParentCard?.x, dockParentCard?.y, dockParentCard?.width, dockParentCard?.height, getCanvasState, dockParentCard]);