Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions web/src/components/CodeBlock.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,9 @@
{/if}
</button>
</div>
<pre class="code-block" data-testid="code-block-pre">{#each (highlightedLines.length ? highlightedLines : codeLines) as html, i (i)}<span class="line"><span class="line-num">{i + 1}</span><!-- eslint-disable-next-line svelte/no-at-html-tags --><span class="line-code">{@html html}</span>
</span>{/each}</pre>
<pre class="code-block" data-testid="code-block-pre">{#if highlightedLines.length}{#each highlightedLines as html, i (i)}<span class="line"><span class="line-num">{i + 1}</span><!-- eslint-disable-next-line svelte/no-at-html-tags Shiki output is already escaped/sanitized; raw code never reaches {@html} --><span class="line-code">{@html html}</span>
</span>{/each}{:else}{#each codeLines as line, i (i)}<span class="line"><span class="line-num">{i + 1}</span><span class="line-code">{line}</span>
</span>{/each}{/if}</pre>
</div>

<style>
Expand Down
4 changes: 2 additions & 2 deletions web/src/components/MessageInput.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -1335,8 +1335,8 @@
class="btn-send"
title={overLimit ? 'Message too long — split or convert to artifact' : 'Send message'}
onclick={sendMessage}
disabled={overLimit || !inputValue.trim()}
aria-disabled={overLimit || !inputValue.trim()}
disabled={overLimit || (!inputValue.trim() && !blockMode?.body?.trim())}
aria-disabled={overLimit || (!inputValue.trim() && !blockMode?.body?.trim())}
data-testid="send-button"
>
<SendHorizontal size={16} />
Expand Down
8 changes: 5 additions & 3 deletions web/src/lib/dm-parser.js
Original file line number Diff line number Diff line change
Expand Up @@ -88,11 +88,13 @@ export function parseDM(input, participants, senderKey) {
while (pos < afterTrigger.length) {
// Token must start with `@`.
if (afterTrigger.charCodeAt(pos) !== 0x40 /* @ */) break;
// Read `@name` greedily. Name char class: [A-Za-z0-9_.-].
// Read `@name` greedily. Name char class: [A-Za-z0-9_-] — matches the
// server-authoritative grammar (NAME_PATTERN `^[\w-]{1,64}$` in
// src/claude_comms/mention.py). A dot is not a valid name char.
let nameEnd = pos + 1;
while (
nameEnd < afterTrigger.length
&& /[\w.-]/.test(afterTrigger[nameEnd])
&& /[\w-]/.test(afterTrigger[nameEnd])
) {
nameEnd++;
}
Expand Down Expand Up @@ -169,7 +171,7 @@ export function parseDM(input, participants, senderKey) {
while (p2 < afterTrigger.length) {
if (afterTrigger.charCodeAt(p2) !== 0x40) break;
let ne = p2 + 1;
while (ne < afterTrigger.length && /[\w.-]/.test(afterTrigger[ne])) ne++;
while (ne < afterTrigger.length && /[\w-]/.test(afterTrigger[ne])) ne++;
if (ne === p2 + 1) break;
const nm = afterTrigger.slice(p2 + 1, ne);
const k = nameToKey.get(nm);
Expand Down
14 changes: 8 additions & 6 deletions web/src/lib/mentions.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,15 @@
// of an `@\w+` prefix that is NOT covered by a confirmed token.

/**
* Characters allowed inside a mention name. Hyphens and dots are common in
* participant names (e.g. `claude-test`, `phil.exe`). Everything else
* terminates the active suggestion query.
* Characters allowed inside a mention name. Mirrors the server-authoritative
* grammar (`NAME_PATTERN = ^[\w-]{1,64}$` in `src/claude_comms/mention.py`):
* word characters and hyphens only (e.g. `claude-test`). A dot is NOT a valid
* name character server-side, so it terminates the active suggestion query.
*
* Kept synchronized with the parsing regex below.
* Kept synchronized with the parsing regex below and with the render-side
* `parseMentions` in `utils.js`.
*/
const MENTION_NAME_CHARS = /[\w.-]/;
const MENTION_NAME_CHARS = /[\w-]/;

/**
* Broadcast mention tokens that expand to "everyone currently present" in the
Expand Down Expand Up @@ -72,7 +74,7 @@ const BROADCAST_CANDIDATES = [
* name. Used during edit reconciliation to detect a live suggestion at the
* cursor.
*/
const TRAILING_AT_PREFIX = /@([\w.-]*)$/;
const TRAILING_AT_PREFIX = /@([\w-]*)$/;

/**
* Compute the edit range introduced by a text change. Given the old and
Expand Down
51 changes: 34 additions & 17 deletions web/src/lib/mqtt-store.svelte.js
Original file line number Diff line number Diff line change
Expand Up @@ -2297,7 +2297,13 @@ export class MqttChatStore {
};

const topic = TOPIC_PREFIX + '/conv/' + this.activeChannel + '/messages';
const payload = JSON.stringify(msg);
// Re-serialize without our internal `status` field — it's local-echo
// metadata only, not part of the wire format. Leaking `status:'sending'`
// makes other web clients render a permanent "Sending…" spinner (mirrors
// retryMessage's strip).
const wire = { ...msg };
delete wire.status;
const payload = JSON.stringify(wire);

// Local echo: add message immediately so it appears even without broker
this.#handleChatMessage(this.activeChannel, msg);
Expand Down Expand Up @@ -2818,7 +2824,12 @@ export class MqttChatStore {
};

const topic = TOPIC_PREFIX + '/conv/' + targetChannelId + '/messages';
const payload = JSON.stringify(msg);
// Strip the internal `status` local-echo field before publishing — see
// sendMessage / retryMessage. Otherwise remote viewers see a stuck
// "Sending…" spinner on the forwarded bubble.
const wire = { ...msg };
delete wire.status;
const payload = JSON.stringify(wire);

// Local echo: add the forwarded message immediately so it appears
// in the target channel without waiting for the broker round-trip.
Expand Down Expand Up @@ -4541,6 +4552,12 @@ export class MqttChatStore {
const result = mcpCall('comms_check', {
key: this.userProfile.key,
conversation: channelId,
// Advance the SERVER read cursor — without ``mark_seen`` the call
// is a pure read and the next ``comms_check`` (reconnect /
// visibility-regain) would resurrect the unread + mention dot the
// user just cleared. See ``tool_comms_check`` (mark_seen default
// False) in mcp_tools.py.
mark_seen: true,
});
if (result && typeof result.catch === 'function') {
result.catch(() => { /* best-effort; local state already correct */ });
Expand Down Expand Up @@ -5154,8 +5171,13 @@ export class MqttChatStore {
this.messages = this.messages.slice(-MAX_MESSAGES);
}

// Update unread count if not active channel
if (channel !== this.activeChannel) {
// Update unread count if not active channel. Guard against self-authored
// messages (e.g. forwardMessage local-echoes into a non-active target
// channel): the sender must never inflate their OWN unread badge.
if (
channel !== this.activeChannel &&
msg.sender?.key !== this.userProfile?.key
) {
const ch = this.channelsById[channel];
if (ch) {
ch.unread++;
Expand Down Expand Up @@ -5513,20 +5535,15 @@ export class MqttChatStore {

// Apply to all known connections of this participant. The server already
// applied to all of them in mcp_tools.tool_comms_status_set; the wire
// event just announces the change. If we have no connections (rare race
// before the participant's presence has arrived), stash on a pending
// field that the next presence event can roll into a real connection.
// event just announces the change. When we have no connections yet (rare
// race before the participant's presence has arrived) there is nothing to
// update — the activity is re-announced by the next status event.
const conns = target.connections || {};
const connKeys = Object.keys(conns);
if (connKeys.length === 0) {
target._pendingActivity = newActivity;
} else {
for (const ck of connKeys) {
if (newActivity) {
conns[ck].activity = newActivity;
} else {
delete conns[ck].activity;
}
for (const ck of Object.keys(conns)) {
if (newActivity) {
conns[ck].activity = newActivity;
} else {
delete conns[ck].activity;
}
}

Expand Down
2 changes: 1 addition & 1 deletion web/src/lib/notifications.svelte.js
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ export function shouldNotifyForPolicy(policy, ctx) {
Array.isArray(p.highlightWords) &&
p.highlightWords.length > 0 &&
body.length > 0 &&
p.highlightWords.some((w) => typeof w === 'string' && w.length > 0 && body.includes(w));
p.highlightWords.some((w) => typeof w === 'string' && w.length > 0 && body.includes(w.toLowerCase()));
const msgIsMention = isFormalMention || isHighlightHit;

let shouldNotify;
Expand Down
27 changes: 0 additions & 27 deletions web/src/lib/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -102,33 +102,6 @@ export function parseMentions(body) {
return segments.filter(s => s.value.length > 0);
}

/**
* Parse inline code from message body text.
* Returns segments of {type: 'text'|'code', value: string}.
* @param {string} text
* @returns {Array<{type: string, value: string}>}
*/
export function parseInlineCode(text) {
const codeRegex = /`([^`]+)`/g;
const segments = [];
let lastIndex = 0;
let match;

while ((match = codeRegex.exec(text)) !== null) {
if (match.index > lastIndex) {
segments.push({ type: 'text', value: text.slice(lastIndex, match.index) });
}
segments.push({ type: 'code', value: match[1] });
lastIndex = match.index + match[0].length;
}

if (lastIndex < text.length) {
segments.push({ type: 'text', value: text.slice(lastIndex) });
}

return segments;
}

/**
* Color palette for participant avatars.
* Each entry: [gradientStart, gradientEnd, textColor]
Expand Down
43 changes: 42 additions & 1 deletion web/tests/code-block-shiki.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,11 @@
// classes / inline styles come back for a given source + lang) live in
// `lib/markdown.js` regardless of the view layer.

import { describe, it, expect, beforeAll } from 'vitest';
import { describe, it, expect, beforeAll, afterEach } from 'vitest';
import { render, cleanup } from '@testing-library/svelte';
import { tick } from 'svelte';
import { highlightCode, escapeHtml } from '../src/lib/markdown.js';
import CodeBlock from '../src/components/CodeBlock.svelte';

// Shiki's CSS-variable theme emits inline `style="color:var(--shiki-token-…)"`
// spans. Presence of a `--shiki-` CSS variable reference in the output is the
Expand Down Expand Up @@ -210,6 +213,44 @@ describe('CodeBlock render-token race guard (Pattern A simulation)', () => {
});
});

describe('CodeBlock — pre-highlight fallback is escaped (XSS regression)', () => {
afterEach(cleanup);

// Before the async Shiki highlight resolves, `highlightedLines` is still
// `[]`, so the component renders the raw `code` lines. That fallback MUST
// be rendered as escaped text (a Svelte text node), never via `{@html}` —
// otherwise a chat code block containing `<img onerror=...>` would inject
// a live element during the (always-present) pre-highlight window.
it('renders raw code as literal text — never injects an element — before Shiki resolves', async () => {
const malicious = '<img src=x onerror="globalThis.__codeblock_xss = 1">';
delete globalThis.__codeblock_xss;

const { container } = render(CodeBlock, {
props: { code: malicious, language: 'text' },
});

const pre = container.querySelector('[data-testid="code-block-pre"]');
expect(pre).toBeTruthy();

// Synchronous (pre-highlight) window: the fallback branch is active.
// No <img> element was injected; the markup is present as literal text.
expect(pre.querySelector('img')).toBeNull();
expect(pre.textContent).toContain(malicious);
expect(globalThis.__codeblock_xss).toBeUndefined();

// Let the async Shiki path settle so it doesn't update after teardown.
// The resolved Shiki output is itself escaped (defence in depth), so
// still no live element and the onerror never fires.
await tick();
await Promise.resolve();
await tick();

expect(container.querySelector('[data-testid="code-block-pre"] img')).toBeNull();
expect(globalThis.__codeblock_xss).toBeUndefined();
delete globalThis.__codeblock_xss;
});
});

describe('escapeHtml — fallback safety sanity check', () => {
it('escapes all five HTML-sensitive characters', () => {
expect(escapeHtml('<div class="a">&\'</div>')).toBe(
Expand Down
49 changes: 49 additions & 0 deletions web/tests/composer-backtick.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,55 @@ describe('MessageInput backtick block-entry (v2)', () => {
expect(getBlockTextarea(container)).toBeNull();
});

it('Send button is ENABLED for a code-block-only message (blockMode.body counts)', async () => {
const { store, container, ta } = setup();
// Enter block mode via Trigger B; the fence is stripped so inputValue
// becomes empty and the message body lives ONLY in blockMode.body.
await setValue(ta, '```');
await fireEvent.keyDown(ta, { key: 'Enter', shiftKey: true });
await tick();
await Promise.resolve();
await tick();

expect(ta.value).toBe('');

const block = getBlockTextarea(container);
block.value = 'block only body';
await fireEvent.input(block, { target: block });
await tick();

const sendBtn = container.querySelector('[data-testid="send-button"]');
// A real browser refuses to dispatch a click on a disabled button, so
// the disabled STATE (not the synthetic click) is the load-bearing
// assertion: the button must be enabled when only blockMode.body has
// content, otherwise code-block-only messages are unsendable via mouse.
expect(sendBtn.disabled).toBe(false);
expect(sendBtn.getAttribute('aria-disabled')).toBe('false');

// And the (now-reachable) click path still sends the synthesized block.
await fireEvent.click(sendBtn);
await tick();
await Promise.resolve();
await tick();
expect(store.sendMessage).toHaveBeenCalled();
const sent = store.sendMessage.mock.calls[0][0];
expect(sent).toContain('```');
expect(sent).toContain('block only body');
});

it('Send button stays DISABLED when both inline value and block body are empty', async () => {
const { container, ta } = setup();
await setValue(ta, '```');
await fireEvent.keyDown(ta, { key: 'Enter', shiftKey: true });
await tick();
await Promise.resolve();
await tick();

// Block open but body empty, inline empty → nothing sendable.
const sendBtn = container.querySelector('[data-testid="send-button"]');
expect(sendBtn.disabled).toBe(true);
});

it('ArrowRight at end of body exits block (commits synthesized) and returns to inline', async () => {
const { container, ta } = setup();
await setValue(ta, '```');
Expand Down
10 changes: 10 additions & 0 deletions web/tests/dm-parser.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,16 @@ describe('parseDM — §10 matrix', () => {
expect(result.body).toBe('@ember hi @sage');
});

test('name grammar is [\\w-] (no dot) — `/dm @ember. hi` resolves `ember`, dot starts body', () => {
// Server authority: NAME_PATTERN = ^[\w-]{1,64}$. The dot is NOT a valid
// name char, so `@ember.` peels recipient `ember` and the `.` falls into
// the body. (Under the old `[\w.-]` grammar this errored as an unknown
// recipient `@ember.`.)
const result = parseDM('/dm @ember. hi', participants, PHIL_KEY);
expect(result.error).toBeNull();
expect(result.recipients).toEqual([EMBER_KEY]);
});

test('test_reject_unknown_recipient — case 5: `/dm @notanyone hi`', () => {
const result = parseDM('/dm @notanyone hi', participants, PHIL_KEY);
expect(result.error).toBeTruthy();
Expand Down
13 changes: 13 additions & 0 deletions web/tests/mentions.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,19 @@ describe('parseMentions — active suggestion detection', () => {
const r = parseMentions('@cl ', [], '@cl', 3, 4);
expect(r.activeSuggestion).toBeNull();
});

it('a dot terminates the query — name grammar is [\\w-], matching the server', () => {
// Server authority: NAME_PATTERN = ^[\w-]{1,64}$ (no dot). `@bob.` must
// stop at `bob`, with the trailing `.` ending the active suggestion.
const r = parseMentions('@bob.', [], '@bob', 4, 5);
expect(r.activeSuggestion).toBeNull();
});

it('a dot mid-name terminates the suggestion at the pre-dot prefix', () => {
// Typing the dot in `@bob.` ends the query; `bob.x` is not one name.
const r = parseMentions('@bob.x', [], '@bob.', 5, 6);
expect(r.activeSuggestion).toBeNull();
});
});

describe('parseMentions — token offset shifting', () => {
Expand Down
20 changes: 20 additions & 0 deletions web/tests/mqtt-store-forward-queue.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,9 @@ describe('MqttChatStore — Polish P6 forwardMessage pending sends', () => {
expect(wire.conv).toBe('target-channel');
expect(wire.body).toBe('direct');
expect(wire.forwarded_from).toBe('src-direct');
// The internal local-echo `status` field must NOT leak onto the wire —
// otherwise remote viewers render a permanent "Sending…" spinner.
expect(wire.status).toBeUndefined();
});

it('publish-callback error during drain marks the forward as failed (retryable)', () => {
Expand Down Expand Up @@ -246,5 +249,22 @@ describe('MqttChatStore — Polish P6 forwardMessage pending sends', () => {
expect(firstPayload.forwarded_from).toBeUndefined();
expect(secondPayload.body).toBe('forward while offline');
expect(secondPayload.forwarded_from).toBe('src-mix');
// Neither path may leak the internal `status` local-echo field.
expect(firstPayload.status).toBeUndefined();
expect(secondPayload.status).toBeUndefined();
});

it('forwarding to a non-active channel does NOT inflate the sender\'s own unread', () => {
// The forwarded local-echo is self-authored and lands in a non-active
// channel; the sender must never bump their OWN unread badge.
store.activeChannel = 'home-channel';
store.channelsById = {
'target-channel': { id: 'target-channel', unread: 0, unreadHasMention: false },
};

store.forwardMessage(srcMessage({ id: 'src-self', body: 'fwd to elsewhere' }), 'target-channel');

expect(store.channelsById['target-channel'].unread).toBe(0);
expect(store.channelsById['target-channel'].unreadHasMention).toBe(false);
});
});
Loading
Loading