Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
af3c192
feat(pkg-r): add UI snapshot encode/decode for bookmark state
cpsievert Jul 24, 2026
b36db5f
feat(pkg-r): restore chat UI from browser snapshot, fall back to turns
cpsievert Jul 24, 2026
76943e5
feat(pkg-r): capture and restore displayed UI through chat_restore() …
cpsievert Jul 24, 2026
b5d1c3c
fix(pkg-r): trigger response bookmark on client message echo, not str…
cpsievert Jul 24, 2026
ad9a34a
docs(pkg-r): document faithful UI restoration in chat_restore()
cpsievert Jul 24, 2026
efe7606
`air format` (GitHub Actions)
cpsievert Jul 24, 2026
ec897ab
fix(pkg-r): decompress bookmark state with explicit gzip type
cpsievert Jul 24, 2026
83a4024
fix(pkg-r): decode UI snapshot defensively, drop dead bookmark-on-res…
cpsievert Jul 24, 2026
03b1b57
fix(pkg-r): guard response-bookmark trigger against startup UI replay
cpsievert Jul 25, 2026
faaad76
security(pkg-r): trust only html dependencies the server sent
cpsievert Aug 4, 2026
b39e159
fix(pkg-r): gate the response bookmark on user submission alone
cpsievert Aug 4, 2026
8035913
refactor(pkg-r): extract the shared gzip+base64 bookmark codec
cpsievert Aug 4, 2026
c87ee11
fix(pkg-r): validate decoded UI snapshots before replaying them
cpsievert Aug 4, 2026
f0b97e0
security: escape shinychat's raw-HTML element names in markdown content
cpsievert Aug 4, 2026
18a513f
fix(pkg-r): label chat_ui() htmltools messages as html content
cpsievert Aug 4, 2026
4dcd88f
fix(pkg-py): label chat_ui() tag messages as html content
cpsievert Aug 4, 2026
4c48418
fix: keep page-markup messages out of the reported transcript
cpsievert Aug 4, 2026
dca3263
docs(pkg-r): note that chat_ui(messages =) is not persisted
cpsievert Aug 4, 2026
7054510
security(pkg-r): record the html content the server sends
cpsievert Aug 4, 2026
4417dae
fix(pkg-r): use exact field matching in html content trust recording
cpsievert Aug 4, 2026
1254b52
security(pkg-r): trust only html content the server sent
cpsievert Aug 4, 2026
e196439
docs: note the html content trust boundary and chat_ui fixes
cpsievert Aug 4, 2026
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
118 changes: 59 additions & 59 deletions js/dist/shinychat.js

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions js/dist/shinychat.js.map

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions js/src/chat/chat-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ function parseInitialMessages(container: HTMLElement): ChatMessageData[] {
streaming: false,
icon,
blocks: [{ type: "content", content, contentType }],
fromMarkup: true,
})
})

Expand Down
9 changes: 8 additions & 1 deletion js/src/chat/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,13 @@ export interface ChatMessageData {
fenceMarker?: string
/** True when the stream was cancelled by the user before it completed. */
cancelled?: boolean
/**
* True for messages parsed out of the server-rendered page markup
* (`chat_ui(messages =)`). Excluded from the reported snapshot: the markup
* re-renders them on every page load, so persisting them duplicates them on
* restore, and the server has no record of having sent them.
*/
fromMarkup?: boolean
/** Sibling navigation metadata (index within a set of edited variants, total variants). */
siblings?: { index: number; total: number }
}
Expand Down Expand Up @@ -1106,7 +1113,7 @@ function blockToSegment(block: MessageBlock): SnapshotSegment {

export function buildMessagesSnapshot(state: ChatState): SnapshotMessage[] {
return state.messages
.filter((m) => !m.isPlaceholder && !m.streaming)
.filter((m) => !m.isPlaceholder && !m.streaming && !m.fromMarkup)
.map((m) => {
const msg: SnapshotMessage = {
role: m.role,
Expand Down
8 changes: 7 additions & 1 deletion js/src/markdown/MarkdownContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
import { CopyableCodeBlock } from "./components/CopyableCodeBlock"
import { BootstrapTable } from "./components/BootstrapTable"
import { RawHTML } from "../chat/RawHTML"
import { escapeReservedElements } from "./reservedElements"

const baseAssistantComponents: Record<string, ComponentType<unknown>> = {
pre: CopyableCodeBlock as ComponentType<unknown>,
Expand Down Expand Up @@ -64,13 +65,18 @@ export function MarkdownContent({
)

// Stage 1 (expensive): parse markdown string → HAST. Cached by content+processor.
//
// Only the html branch may produce shinychat's raw-HTML elements; escaping
// them on the markdown branch keeps model output away from innerHTML. This
// has to happen here rather than server-side, because a streamed tag name can
// be split across chunks and is only whole once the client has reassembled it.
const hast = useMemo(
() =>
isText
? null
: isHtml
? parseHtml(content, processor)
: parseMarkdown(content, processor),
: parseMarkdown(escapeReservedElements(content), processor),
[content, isText, isHtml, processor],
)

Expand Down
40 changes: 40 additions & 0 deletions js/src/markdown/reservedElements.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/**
* Element names that take content out of React and into raw HTML.
*
* `shinychat-raw-html` is assigned to `innerHTML` by `RawHTML`; the two tool
* elements carry attributes (`icon`, `footer`, `tool-name`, `value` with
* `value-type="html"`) that reach `dangerouslySetInnerHTML`. Every other
* component in the tag maps renders through React and is inert.
*
* The server only ever emits these from `split_html_islands()` and the
* tool-card tagifier, which run when an app passes htmltools/Shiny UI rather
* than a string — and that content is always labelled `content_type: "html"`.
* So in markdown-parsed content these names are never legitimate, and content
* that names them there is model output trying to reach a raw-HTML sink.
*/
export const RESERVED_ELEMENTS = [
"shinychat-raw-html",
"shiny-tool-request",
"shiny-tool-result",
] as const

// Case-insensitive because parse5 lowercases tag names, so `<SHINYCHAT-RAW-HTML>`
// would otherwise reach the sink. The lookahead requires a tag-name boundary so
// that longer names starting with a reserved one (`<shiny-tool-resultant>`) are
// left alone.
const RESERVED_ELEMENT_RE = new RegExp(
`<(/?)(${RESERVED_ELEMENTS.join("|")})(?=[\\s/>]|$)`,
"gi",
)

/**
* Neutralize shinychat's raw-HTML element names so they render as visible text.
*
* Applied to markdown-parsed content only. Note that a reserved name inside a
* code fence is escaped too, so it displays as `&lt;shinychat-raw-html>` rather
* than `<shinychat-raw-html>`; fence-aware escaping would mean trusting fence
* detection to decide what is safe, which is the wrong thing to depend on.
*/
export function escapeReservedElements(content: string): string {
return content.replace(RESERVED_ELEMENT_RE, "&lt;$1$2")
}
25 changes: 13 additions & 12 deletions js/tests/chat/ToolBridge.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ describe("Tool component bridge rendering", () => {
{
content:
'<shiny-tool-request data-shinychat-react request-id="req-1" tool-name="get_weather" tool-title="Get Weather" arguments=\'{"city":"NYC"}\'></shiny-tool-request>',
content_type: "markdown",
content_type: "html",
},
],
},
Expand Down Expand Up @@ -88,7 +88,7 @@ describe("Tool component bridge rendering", () => {
{
content:
'<shiny-tool-request data-shinychat-react request-id="req-2" tool-name="get_weather" arguments="{}"></shiny-tool-request>',
content_type: "markdown",
content_type: "html",
},
],
},
Expand All @@ -113,7 +113,7 @@ describe("Tool component bridge rendering", () => {
{
content:
'<shiny-tool-result data-shinychat-react request-id="req-2" tool-name="get_weather" status="success" value="Sunny, 72°F" value-type="text"></shiny-tool-result>',
content_type: "markdown",
content_type: "html",
},
],
},
Expand Down Expand Up @@ -156,7 +156,7 @@ describe("Tool component bridge rendering", () => {
{
content:
'<shiny-tool-request data-shinychat-react request-id="req-inline-hide" tool-name="get_weather" arguments="{}"></shiny-tool-request>',
content_type: "markdown",
content_type: "html",
},
],
},
Expand All @@ -174,7 +174,7 @@ describe("Tool component bridge rendering", () => {
{
content:
'<shiny-tool-result data-shinychat-react request-id="req-inline-hide" tool-name="get_weather" status="success" value="Sunny, 72°F" value-type="text"></shiny-tool-result>',
content_type: "markdown",
content_type: "html",
},
],
},
Expand Down Expand Up @@ -215,7 +215,7 @@ describe("Tool component bridge rendering", () => {
{
content:
'<shiny-tool-request data-shinychat-react request-id="req-stream-hide" tool-name="get_weather" arguments="{}"></shiny-tool-request>',
content_type: "markdown",
content_type: "html",
},
],
},
Expand All @@ -240,6 +240,7 @@ describe("Tool component bridge rendering", () => {
content:
'<shiny-tool-result data-shinychat-react request-id="req-stream-hide" tool-name="get_weather" status="success" value="Done" value-type="text"></shiny-tool-result>',
operation: "replace",
content_type: "html",
})
})

Expand Down Expand Up @@ -281,7 +282,7 @@ describe("Tool component bridge rendering", () => {
{
content:
'<shiny-tool-request data-shinychat-react request-id="req-3" tool-name="search" arguments="{}"></shiny-tool-request>',
content_type: "markdown",
content_type: "html",
},
],
},
Expand Down Expand Up @@ -333,7 +334,7 @@ describe("Tool component bridge rendering", () => {
type: "content",
content:
'<shiny-tool-request data-shinychat-react request-id="req-preloaded" tool-name="search" arguments="{}"></shiny-tool-request>',
contentType: "markdown",
contentType: "html",
},
],
},
Expand All @@ -348,7 +349,7 @@ describe("Tool component bridge rendering", () => {
type: "content",
content:
'<shiny-tool-result data-shinychat-react request-id="req-preloaded" tool-name="search" status="success" value="Done" value-type="text"></shiny-tool-result>',
contentType: "markdown",
contentType: "html",
},
],
},
Expand Down Expand Up @@ -391,7 +392,7 @@ describe("Tool component bridge rendering", () => {
segments: [
{
content: `<shiny-tool-result data-shinychat-react request-id="req-icon" tool-name="list_files" tool-title="List Files" status="success" value="file1.txt" value-type="text" icon="${folderIcon.replace(/"/g, "&quot;")}"></shiny-tool-result>`,
content_type: "markdown",
content_type: "html",
},
],
},
Expand Down Expand Up @@ -433,7 +434,7 @@ describe("Tool component bridge rendering", () => {
{
content:
'<shiny-tool-result data-shinychat-react request-id="req-no-icon" tool-name="get_weather" status="success" value="Sunny" value-type="text"></shiny-tool-result>',
content_type: "markdown",
content_type: "html",
},
],
},
Expand Down Expand Up @@ -476,7 +477,7 @@ describe("Tool component bridge rendering", () => {
{
content:
'<shiny-tool-result data-shinychat-react request-id="req-empty" tool-name="get_weather" status="success" value="" value-type="text" show-request full-screen expanded></shiny-tool-result>',
content_type: "markdown",
content_type: "html",
},
],
},
Expand Down
35 changes: 35 additions & 0 deletions js/tests/chat/chat-entry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -366,4 +366,39 @@ describe("browser token delivery", () => {
expect(tokenCallsAfter).toHaveLength(1)
expect(typeof tokenCallsAfter[0]![1]).toBe("string")
})

it("keeps page-markup messages out of the reported snapshot", async () => {
const host = document.createElement("shiny-chat-container")
host.setAttribute("id", "snapshot-scope")
host.innerHTML = `
<shiny-chat-messages>
<shiny-chat-message
data-role="assistant"
content-type="html"
content="&lt;shinychat-raw-html&gt;&lt;div&gt;STATIC&lt;/div&gt;&lt;/shinychat-raw-html&gt;"
></shiny-chat-message>
</shiny-chat-messages>
<shiny-chat-input placeholder="p"></shiny-chat-input>
`

await act(async () => {
document.body.appendChild(host)
})

await waitFor(() => {
expect(host.querySelector('[role="textbox"]')).not.toBeNull()
})

const setInputValue = window.Shiny!.setInputValue as ReturnType<
typeof vi.fn
>
const snapshotCalls = setInputValue.mock.calls.filter(
([name]) => name === "snapshot-scope_messages:shinychat.messages",
)

expect(snapshotCalls.length).toBeGreaterThan(0)
for (const [, value] of snapshotCalls) {
expect(value).toEqual([])
}
})
})
22 changes: 22 additions & 0 deletions js/tests/chat/state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1834,4 +1834,26 @@ describe("buildMessagesSnapshot", () => {
])
expect(snap[0]!.htmlDeps).toEqual([dep])
})

it("excludes messages parsed from page markup", () => {
const s = makeState({
messages: [
makeAssistantMsg({
id: "from-markup",
content: "STATIC",
fromMarkup: true,
}),
makeAssistantMsg({ id: "from-server", content: "SENT" }),
],
})

const snap = buildMessagesSnapshot(s)

expect(snap).toEqual([
{
role: "assistant",
segments: [{ content: "SENT", content_type: "markdown" }],
},
])
})
})
68 changes: 68 additions & 0 deletions js/tests/chat/streamedReservedElements.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { describe, it, expect, beforeEach } from "vitest"
import { render, act } from "@testing-library/react"
import { ChatApp } from "../../src/chat/ChatApp"
import {
createMockTransport,
createMockShinyLifecycle,
installShinyWindowStub,
} from "../helpers/mocks"

beforeEach(() => {
installShinyWindowStub()
})

function renderChat(transport: ReturnType<typeof createMockTransport>) {
return render(
<ChatApp
transport={transport}
shinyLifecycle={createMockShinyLifecycle()}
elementId="test-chat"
inputId="test-input"
uploadAccept={["image/png"]}
maxUploadSize={30000000}
/>,
)
}

// A streamed tag name arrives split across chunks. Escaping on the server
// would inspect each chunk separately and miss it; the browser reassembles the
// block before parsing, which is why the escape lives on the client.
describe("a reserved element streamed across chunk boundaries", () => {
it("stays inert when the tag name is split mid-stream", () => {
const transport = createMockTransport()
const { container } = renderChat(transport)

act(() => {
transport.fire("test-chat", {
type: "chunk_start",
message: {
role: "assistant",
segments: [{ content: "", content_type: "markdown" }],
},
})
})

for (const piece of [
"<shinychat-raw",
"-html><img src=x onerror",
"=alert(1)></shinychat-raw-html>",
]) {
act(() => {
transport.fire("test-chat", {
type: "chunk",
content: piece,
operation: "append",
content_type: "markdown",
})
})
}

act(() => {
transport.fire("test-chat", { type: "chunk_end" })
})

expect(container.querySelector("[onerror]")).toBeNull()
expect(container.innerHTML).not.toContain("onerror")
expect(container.textContent).toContain("shinychat-raw-html")
})
})
8 changes: 6 additions & 2 deletions js/tests/markdown/MarkdownContent.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,12 @@ describe("MarkdownContent (pure)", () => {
const content =
'<shiny-tool-result request-id="req-1" tool-name="get_weather" status="success" value="Sunny" value-type="text"></shiny-tool-result>'

// contentType "html" is what the server sends for tool cards: they are
// built as htmltools tags, and tag content is always labelled "html".
// In markdown content these element names are escaped (see
// reservedElementsRendering.test.tsx).
const { container } = render(
<MarkdownContent content={content} contentType="markdown" />,
<MarkdownContent content={content} contentType="html" />,
)

expect(container.querySelector("shiny-tool-result")).not.toBeNull()
Expand All @@ -99,7 +103,7 @@ describe("MarkdownContent (pure)", () => {
const { container } = render(
<MarkdownContent
content={content}
contentType="markdown"
contentType="html"
tagToComponentMap={chatTagToComponentMap}
/>,
)
Expand Down
Loading