test: restore dashboard, provider and Desktop re-bind guards dropped by #120071

- test_kanban_dashboard_plugin::test_dashboard_markdown_html_is_sanitized_before_render:
  runs the real bundle's sanitizer and MarkdownBlock under node; script/event
  handler/javascript: HTML never reaches dangerouslySetInnerHTML (security).
- test_meta_ai_profile::test_images_ride_user_turns_not_tool_results: vision on,
  tool-message vision off, so screenshots never 400 in tool results (#101668).
- session-info.test.ts "rebuilt-runtime re-bind" (7 cases, replaces the regex
  test tests/desktop/test_bots_chat_stream_rekey.py): the pane adopts a rebuilt
  runtime id only when lineage matches and the old runtime is idle (#93942, #94417).
This commit is contained in:
teknium1
2026-09-23 04:39:52 -07:00
committed by Teknium
parent 48340244dc
commit 53daae7e2f
4 changed files with 166 additions and 0 deletions

View File

@@ -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<ClientSessionState>): 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')
})
})

View File

@@ -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 <path-to-bundle>
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 = [/<img/i, /<script/i, /<iframe/i, /<svg/i, /\son\w+\s*=/i, /javascript:/i];
const assertClean = (html, label) => {
for (const re of DANGER) check(!re.test(html), `${label}: ${re} survived in ${JSON.stringify(html)}`);
};
const RAW =
'<img src=x onerror=alert(1)><script>alert(1)</script><iframe src="https://evil"></iframe>' +
'<svg onload=alert(1)></svg><a href="javascript:alert(1)" onclick="steal()">y</a>' +
'<p onmouseover="steal()">z</p><a href="https://ok.example/">ok</a>';
// 1. The sanitizer itself.
const cleaned = sanitizeMarkdownHtml(RAW);
assertClean(cleaned, "sanitizeMarkdownHtml");
check(cleaned.includes("<p>z</p>"), `allowlisted <p> 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("<strong>bold</strong>"), `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");

View File

@@ -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
# ---------------------------------------------------------------------------

View File

@@ -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()