diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/session-info.test.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/session-info.test.ts index eda6b693b0..c8e3f6fad4 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/session-info.test.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/session-info.test.ts @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { ClientSessionState } from '@/app/types' import { createClientSessionState } from '@/lib/chat-runtime' import { + $activeSessionId, $currentCwd, $selectedStoredSessionId, $workspaceCwdOwner, @@ -147,3 +148,75 @@ describe('handleSessionInfoEvent workspace ownership', () => { expect(next).toBe(original) }) }) + +// #93942 scenario B: a mid-conversation model/provider switch rebuilds the +// runtime, which then speaks under a NEW session_id. Without the re-bind the +// pane keeps listening on the dead id and every later event of the same +// conversation fails the isActiveEvent gate until a full resume. +describe('handleSessionInfoEvent rebuilt-runtime re-bind', () => { + function rebuiltRuntimeInfo(storedSessionId: unknown, oldState?: Partial): GatewayEventContext { + const ctx = sessionInfoEvent({ + activeSessionId: 'runtime-old', + cwd: '', + explicitSid: 'runtime-new', + storedSessionId: 'stored-1' + }) + + ctx.payload = { ...ctx.payload, stored_session_id: storedSessionId } as typeof ctx.payload + + if (oldState) { + ctx.deps.sessionStateByRuntimeIdRef.current.set('runtime-old', { + ...createClientSessionState('stored-1'), + ...oldState + }) + } + + return ctx + } + + beforeEach(() => { + $selectedStoredSessionId.set('stored-1') + $activeSessionId.set('runtime-old') + }) + + afterEach(() => { + $selectedStoredSessionId.set(null) + $activeSessionId.set(null) + }) + + it('adopts the rebuilt runtime id when its lineage matches the open conversation', () => { + const ctx = rebuiltRuntimeInfo('stored-1', { busy: false }) + + handleSessionInfoEvent(ctx) + + expect($activeSessionId.get()).toBe('runtime-new') + expect(ctx.deps.activeSessionIdRef.current).toBe('runtime-new') + expect($selectedStoredSessionId.get()).toBe('stored-1') + }) + + it.each([ + ['busy', { busy: true }], + ['awaiting a response', { awaitingResponse: true }], + ['streaming', { streamId: 'stream-1' }] + ] as const)('refuses to hijack the pane while the old runtime is %s', (_label, oldState) => { + const ctx = rebuiltRuntimeInfo('stored-1', oldState) + + handleSessionInfoEvent(ctx) + + expect($activeSessionId.get()).toBe('runtime-old') + expect(ctx.deps.activeSessionIdRef.current).toBe('runtime-old') + }) + + it.each([ + ['an empty', ''], + ['a missing', undefined], + ['a different conversation', 'stored-other'] + ])('does not re-bind on %s stored_session_id', (_label, storedSessionId) => { + const ctx = rebuiltRuntimeInfo(storedSessionId) + + handleSessionInfoEvent(ctx) + + expect($activeSessionId.get()).toBe('runtime-old') + expect(ctx.deps.activeSessionIdRef.current).toBe('runtime-old') + }) +}) diff --git a/tests/plugins/fixtures/kanban_markdown_sanitize_probe.js b/tests/plugins/fixtures/kanban_markdown_sanitize_probe.js new file mode 100644 index 0000000000..66df53fa3a --- /dev/null +++ b/tests/plugins/fixtures/kanban_markdown_sanitize_probe.js @@ -0,0 +1,66 @@ +// Behavioral probe for the dashboard markdown XSS guard: extracts the markdown +// helpers + MarkdownBlock from the shipped bundle (no build step — the bundle IS +// the source) and runs them. Exits 0 and prints "PASS" when +// 1. sanitizeMarkdownHtml strips non-allowlisted tags, event-handler attributes +// and non-http(s)/mailto hrefs from raw HTML; +// 2. MarkdownBlock routes its HTML through the sanitizer before +// dangerouslySetInnerHTML (proved by swapping renderMarkdown for an identity +// function that passes raw HTML straight through); +// 3. ordinary markdown still renders (bold, safe links). +// Run via: node kanban_markdown_sanitize_probe.js +const fs = require("fs"); + +const src = fs.readFileSync(process.argv[2], "utf8"); +const start = src.indexOf("function escapeHtml("); +const blockStart = src.indexOf("function MarkdownBlock(", start); +if (start === -1 || blockStart === -1) { + console.error("markdown helpers / MarkdownBlock not found in bundle"); + process.exit(1); +} +const bodyStart = src.indexOf("{", blockStart); +let depth = 0; +let end = bodyStart; +for (; end < src.length; end++) { + if (src[end] === "{") depth++; + else if (src[end] === "}") { depth--; if (depth === 0) break; } +} +const code = src.slice(start, end + 1); + +const h = (tag, props, ...children) => ({ tag, props, children }); +eval(code); // sloppy-mode direct eval: function declarations land in this scope + +const failures = []; +const check = (cond, msg) => { if (!cond) failures.push(msg); }; +const DANGER = [/ { + for (const re of DANGER) check(!re.test(html), `${label}: ${re} survived in ${JSON.stringify(html)}`); +}; + +const RAW = + '' + + 'y' + + '

z

ok'; + +// 1. The sanitizer itself. +const cleaned = sanitizeMarkdownHtml(RAW); +assertClean(cleaned, "sanitizeMarkdownHtml"); +check(cleaned.includes("

z

"), `allowlisted

lost: ${cleaned}`); +check(cleaned.includes('href="https://ok.example/"'), `safe https href lost: ${cleaned}`); + +// 2. MarkdownBlock must sanitize whatever the renderer produces. +const realRender = renderMarkdown; +renderMarkdown = (s) => s; +const wired = MarkdownBlock({ source: RAW }).props.dangerouslySetInnerHTML.__html; +assertClean(wired, "MarkdownBlock(raw renderer)"); +renderMarkdown = realRender; + +// 3. Ordinary markdown still renders through the real renderer. +const md = MarkdownBlock({ source: "**bold** and [link](https://example.com)" }).props.dangerouslySetInnerHTML.__html; +check(md.includes("bold"), `bold lost: ${md}`); +check(md.includes('href="https://example.com"'), `link lost: ${md}`); + +if (failures.length) { + console.error(failures.join("\n")); + process.exit(1); +} +console.log("PASS"); diff --git a/tests/plugins/test_kanban_dashboard_plugin.py b/tests/plugins/test_kanban_dashboard_plugin.py index 0fb37f46ae..b0c77f02fb 100644 --- a/tests/plugins/test_kanban_dashboard_plugin.py +++ b/tests/plugins/test_kanban_dashboard_plugin.py @@ -179,6 +179,26 @@ def test_tenant_filter(client): assert total == 1 +def test_dashboard_markdown_html_is_sanitized_before_render(): + """Task markdown reaches ``dangerouslySetInnerHTML``, so the rendered HTML must + pass through ``sanitizeMarkdownHtml`` (tag allowlist, no event handlers, only + http(s)/mailto hrefs). Runs the real bundle functions under node — including a + raw-HTML renderer swap that proves MarkdownBlock applies the sanitizer — rather + than substring-matching the bundle text. + """ + node = shutil.which("node") + if not node: + pytest.skip("node not available") + bundle = Path(__file__).resolve().parents[2] / "plugins" / "kanban" / "dashboard" / "dist" / "index.js" + probe = Path(__file__).parent / "fixtures" / "kanban_markdown_sanitize_probe.js" + result = subprocess.run( + [node, str(probe), str(bundle)], + capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=30, + ) + assert result.returncode == 0, f"stdout={result.stdout!r} stderr={result.stderr!r}" + assert "PASS" in result.stdout + + # --------------------------------------------------------------------------- diff --git a/tests/providers/test_meta_ai_profile.py b/tests/providers/test_meta_ai_profile.py index 81f6884b1e..51cb9e5178 100644 --- a/tests/providers/test_meta_ai_profile.py +++ b/tests/providers/test_meta_ai_profile.py @@ -17,6 +17,13 @@ def _profile(): class TestMetaAIProfile: + def test_images_ride_user_turns_not_tool_results(self): + """Muse accepts images on user turns but 400s on image parts inside + tool-result envelopes (#101668): vision must stay on while tool-message + vision stays off, so tool screenshots are routed as text/user turns.""" + p = _profile() + assert p.supports_vision is True + assert p.supports_vision_tool_messages is False def test_live_catalog_filters_non_chat_models(self, monkeypatch): p = _profile()