diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx
index 78e1c9044d7..7d6d245b26c 100644
--- a/apps/desktop/src/app/chat/composer/index.tsx
+++ b/apps/desktop/src/app/chat/composer/index.tsx
@@ -38,7 +38,7 @@ import { useComposerGlassTweaks } from './hooks/use-composer-glass-tweaks'
import { useSlashCompletions } from './hooks/use-slash-completions'
import { useVoiceConversation } from './hooks/use-voice-conversation'
import { useVoiceRecorder } from './hooks/use-voice-recorder'
-import { composerHtml, composerPlainText, escapeHtml, placeCaretEnd, refChipHtml, RICH_INPUT_SLOT } from './rich-editor'
+import { composerPlainText, placeCaretEnd, refChipElement, renderComposerContents, RICH_INPUT_SLOT } from './rich-editor'
import { SkinSlashPopover } from './skin-slash-popover'
import { ComposerTriggerPopover } from './trigger-popover'
import type { ChatBarProps } from './types'
@@ -229,7 +229,7 @@ export function ChatBar({
const editor = editorRef.current
if (editor && document.activeElement !== editor && composerPlainText(editor) !== draft) {
- editor.innerHTML = composerHtml(draft)
+ renderComposerContents(editor, draft)
}
}, [draft])
@@ -346,9 +346,7 @@ export function ChatBar({
refs.forEach((ref, index) => {
const match = ref.match(/^@([^:]+):(.+)$/)
- const holder = document.createElement('span')
- holder.innerHTML = match ? refChipHtml(match[1], match[2]) : escapeHtml(ref)
- fragment.appendChild(holder.firstChild || document.createTextNode(ref))
+ fragment.appendChild(match ? refChipElement(match[1], match[2]) : document.createTextNode(ref))
if (index < refs.length - 1) {
fragment.appendChild(document.createTextNode(' '))
@@ -376,7 +374,7 @@ export function ChatBar({
selection?.addRange(nextRange)
} else {
const current = composerPlainText(editor)
- editor.innerHTML = composerHtml(`${current}${current && !/\s$/.test(current) ? ' ' : ''}${inline} `)
+ renderComposerContents(editor, `${current}${current && !/\s$/.test(current) ? ' ' : ''}${inline} `)
placeCaretEnd(editor)
}
@@ -544,7 +542,7 @@ export function ChatBar({
// No usable caret range — replace from the end of the draft instead.
if (!sel || !range || node?.nodeType !== Node.TEXT_NODE || offset < trigger.tokenLength) {
const current = composerPlainText(editor)
- editor.innerHTML = composerHtml(`${current.slice(0, Math.max(0, current.length - trigger.tokenLength))}${text}`)
+ renderComposerContents(editor, `${current.slice(0, Math.max(0, current.length - trigger.tokenLength))}${text}`)
placeCaretEnd(editor)
return finish()
@@ -556,24 +554,19 @@ export function ChatBar({
replaceRange.deleteContents()
if (directive) {
- const holder = document.createElement('span')
- holder.innerHTML = refChipHtml(directive[1], directive[2])
- const chip = holder.firstChild
+ const chip = refChipElement(directive[1], directive[2])
+ const space = document.createTextNode(' ')
+ const fragment = document.createDocumentFragment()
+ fragment.append(chip, space)
+ replaceRange.insertNode(fragment)
- if (chip) {
- const space = document.createTextNode(' ')
- const fragment = document.createDocumentFragment()
- fragment.append(chip, space)
- replaceRange.insertNode(fragment)
+ const caret = document.createRange()
+ caret.setStart(space, 1)
+ caret.collapse(true)
+ sel.removeAllRanges()
+ sel.addRange(caret)
- const caret = document.createRange()
- caret.setStart(space, 1)
- caret.collapse(true)
- sel.removeAllRanges()
- sel.addRange(caret)
-
- return finish()
- }
+ return finish()
}
document.execCommand('insertText', false, text)
@@ -749,7 +742,7 @@ export function ChatBar({
draftRef.current = ''
if (editorRef.current) {
- editorRef.current.innerHTML = ''
+ editorRef.current.replaceChildren()
}
}
diff --git a/apps/desktop/src/app/chat/composer/rich-editor.test.ts b/apps/desktop/src/app/chat/composer/rich-editor.test.ts
new file mode 100644
index 00000000000..c04e19a048b
--- /dev/null
+++ b/apps/desktop/src/app/chat/composer/rich-editor.test.ts
@@ -0,0 +1,18 @@
+import { describe, expect, it } from 'vitest'
+
+import { composerPlainText, renderComposerContents, RICH_INPUT_SLOT } from './rich-editor'
+
+describe('renderComposerContents', () => {
+ it('renders refs and raw text without interpreting user text as HTML', () => {
+ const editor = document.createElement('div')
+ editor.dataset.slot = RICH_INPUT_SLOT
+
+ renderComposerContents(editor, '@file:`
` raw')
+
+ expect(editor.querySelector('img')).toBeNull()
+ expect(editor.querySelector('b')).toBeNull()
+ expect(editor.textContent).toContain('
')
+ expect(editor.textContent).toContain('raw')
+ expect(composerPlainText(editor)).toBe('@file:`
` raw')
+ })
+})
diff --git a/apps/desktop/src/app/chat/composer/rich-editor.ts b/apps/desktop/src/app/chat/composer/rich-editor.ts
index 80951a25195..f88c2fbd611 100644
--- a/apps/desktop/src/app/chat/composer/rich-editor.ts
+++ b/apps/desktop/src/app/chat/composer/rich-editor.ts
@@ -6,7 +6,12 @@
* fence — without that, typing after a chip would get re-absorbed on the next
* plain-text round-trip.
*/
-import { DIRECTIVE_CHIP_CLASS, directiveIconSvg, formatRefValue } from '@/components/assistant-ui/directive-text'
+import {
+ DIRECTIVE_CHIP_CLASS,
+ directiveIconElement,
+ directiveIconSvg,
+ formatRefValue
+} from '@/components/assistant-ui/directive-text'
export const RICH_INPUT_SLOT = 'composer-rich-input'
@@ -51,7 +56,59 @@ export function refChipHtml(kind: string, rawValue: string) {
const id = unquoteRef(rawValue)
const text = `@${kind}:${quoteRefValue(id)}`
- return `${directiveIconSvg(kind)}${escapeHtml(refLabel(id))}`
+ return `${directiveIconSvg(kind)}${escapeHtml(refLabel(id))}`
+}
+
+export function refChipElement(kind: string, rawValue: string) {
+ const id = unquoteRef(rawValue)
+ const text = `@${kind}:${quoteRefValue(id)}`
+ const chip = document.createElement('span')
+ const label = document.createElement('span')
+
+ chip.contentEditable = 'false'
+ chip.dataset.refText = text
+ chip.dataset.refId = id
+ chip.dataset.refKind = kind
+ chip.className = DIRECTIVE_CHIP_CLASS
+ label.className = 'truncate'
+ label.textContent = refLabel(id)
+ chip.append(directiveIconElement(kind), label)
+
+ return chip
+}
+
+function appendTextWithBreaks(target: DocumentFragment | HTMLElement, text: string) {
+ const lines = text.split('\n')
+
+ lines.forEach((line, index) => {
+ if (index > 0) {
+ target.append(document.createElement('br'))
+ }
+
+ if (line) {
+ target.append(document.createTextNode(line))
+ }
+ })
+}
+
+export function appendComposerContents(target: DocumentFragment | HTMLElement, text: string) {
+ let cursor = 0
+
+ REF_RE.lastIndex = 0
+
+ for (const match of text.matchAll(REF_RE)) {
+ const index = match.index ?? 0
+ appendTextWithBreaks(target, text.slice(cursor, index))
+ target.append(refChipElement(match[1] || 'file', match[2] || ''))
+ cursor = index + match[0].length
+ }
+
+ appendTextWithBreaks(target, text.slice(cursor))
+}
+
+export function renderComposerContents(target: HTMLElement, text: string) {
+ target.replaceChildren()
+ appendComposerContents(target, text)
}
/** Serialize a draft string into chip-HTML for the contenteditable surface. */
diff --git a/apps/desktop/src/app/settings/helpers.test.ts b/apps/desktop/src/app/settings/helpers.test.ts
new file mode 100644
index 00000000000..87ff47bb25e
--- /dev/null
+++ b/apps/desktop/src/app/settings/helpers.test.ts
@@ -0,0 +1,23 @@
+import { describe, expect, it } from 'vitest'
+
+import type { HermesConfigRecord } from '@/types/hermes'
+
+import { getNested, setNested } from './helpers'
+
+describe('settings helpers', () => {
+ it('reads and writes nested config paths', () => {
+ const config: HermesConfigRecord = { display: { theme: 'mono' } }
+ const next = setNested(config, 'display.theme', 'slate')
+
+ expect(getNested(next, 'display.theme')).toBe('slate')
+ expect(getNested(config, 'display.theme')).toBe('mono')
+ })
+
+ it('rejects prototype-polluting config paths', () => {
+ const config: HermesConfigRecord = {}
+
+ expect(() => setNested(config, '__proto__.polluted', true)).toThrow('Unsafe config path')
+ expect(() => setNested(config, 'constructor.prototype.polluted', true)).toThrow('Unsafe config path')
+ expect(({} as Record).polluted).toBeUndefined()
+ })
+})
diff --git a/apps/desktop/src/app/settings/helpers.ts b/apps/desktop/src/app/settings/helpers.ts
index 93ff18b6b80..7c17a3b2c31 100644
--- a/apps/desktop/src/app/settings/helpers.ts
+++ b/apps/desktop/src/app/settings/helpers.ts
@@ -23,10 +23,22 @@ export const providerGroup = (key: string) => PROVIDER_GROUPS.find(g => key.star
export const providerPriority = (name: string) => PROVIDER_GROUPS.find(g => g.name === name)?.priority ?? 99
+const POLLUTING_PATH_PARTS = new Set(['__proto__', 'constructor', 'prototype'])
+
+function configPathParts(path: string): string[] {
+ const parts = path.split('.')
+
+ if (parts.some(part => !part || POLLUTING_PATH_PARTS.has(part))) {
+ throw new Error(`Unsafe config path: ${path}`)
+ }
+
+ return parts
+}
+
export function getNested(obj: HermesConfigRecord, path: string): unknown {
let cur: unknown = obj
- for (const part of path.split('.')) {
+ for (const part of configPathParts(path)) {
if (cur == null || typeof cur !== 'object') {
return undefined
}
@@ -39,7 +51,7 @@ export function getNested(obj: HermesConfigRecord, path: string): unknown {
export function setNested(obj: HermesConfigRecord, path: string, value: unknown): HermesConfigRecord {
const clone = structuredClone(obj)
- const parts = path.split('.')
+ const parts = configPathParts(path)
let cur: Record = clone
for (let i = 0; i < parts.length - 1; i += 1) {
diff --git a/apps/desktop/src/components/assistant-ui/directive-text.tsx b/apps/desktop/src/components/assistant-ui/directive-text.tsx
index 57e47d2c6e5..91c04f06ce7 100644
--- a/apps/desktop/src/components/assistant-ui/directive-text.tsx
+++ b/apps/desktop/src/components/assistant-ui/directive-text.tsx
@@ -58,6 +58,26 @@ export function directiveIconSvg(type: string) {
return ``
}
+export function directiveIconElement(type: string) {
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
+ svg.setAttribute('class', ICON_CLASS)
+ svg.setAttribute('fill', 'none')
+ svg.setAttribute('stroke', 'currentColor')
+ svg.setAttribute('stroke-linecap', 'round')
+ svg.setAttribute('stroke-linejoin', 'round')
+ svg.setAttribute('stroke-width', '2')
+ svg.setAttribute('viewBox', '0 0 24 24')
+ svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg')
+
+ for (const d of iconPathsFor(type)) {
+ const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
+ path.setAttribute('d', d)
+ svg.append(path)
+ }
+
+ return svg
+}
+
const DirectiveIcon: FC<{ type: string }> = ({ type }) => (