web: add inline Adaptive Card rendering in chat#42307
web: add inline Adaptive Card rendering in chat#42307VikrantSingh01 wants to merge 4 commits intoopenclaw:mainfrom
Conversation
Greptile SummaryThis PR adds inline Adaptive Card rendering to the chat UI. A new lightweight Lit-based renderer ( Issues found:
Confidence Score: 2/5
Last reviewed commit: 1f04ba1 |
| if (type === "Action.OpenUrl") { | ||
| const url = typeof a.url === "string" ? a.url : "#"; | ||
| return html`<a | ||
| class="ac-action ac-action--link" | ||
| href="${url}" | ||
| target="_blank" | ||
| rel="noreferrer noopener" | ||
| >${title}</a>`; |
There was a problem hiding this comment.
XSS vulnerability via unsanitized href in Action.OpenUrl
a.url is written directly into an href attribute with no validation. Lit's html tagged template does not strip javascript: URLs from attribute bindings, so a card payload containing "url": "javascript:alert(document.cookie)" becomes a working XSS vector when the user clicks the link.
The existing codebase already exposes openExternalUrlSafe (imported in grouped-render.ts) which validates URLs before opening them. The Action.OpenUrl handler should use a <button> with a click handler (or at minimum validate the scheme before placing it in href) instead of a raw anchor with an unchecked URL.
| if (type === "Action.OpenUrl") { | |
| const url = typeof a.url === "string" ? a.url : "#"; | |
| return html`<a | |
| class="ac-action ac-action--link" | |
| href="${url}" | |
| target="_blank" | |
| rel="noreferrer noopener" | |
| >${title}</a>`; | |
| if (type === "Action.OpenUrl") { | |
| const url = typeof a.url === "string" ? a.url : ""; | |
| if (!url) return nothing; | |
| return html`<button | |
| class="ac-action ac-action--link" | |
| type="button" | |
| @click=${() => openExternalUrlSafe(url)} | |
| >${title}</button>`; | |
| } |
(You will also need to import openExternalUrlSafe from "../open-external-url.ts" at the top of this file.)
Prompt To Fix With AI
This is a comment left during a code review.
Path: ui/src/ui/chat/adaptive-card-render.ts
Line: 219-226
Comment:
**XSS vulnerability via unsanitized `href` in `Action.OpenUrl`**
`a.url` is written directly into an `href` attribute with no validation. Lit's `html` tagged template does **not** strip `javascript:` URLs from attribute bindings, so a card payload containing `"url": "javascript:alert(document.cookie)"` becomes a working XSS vector when the user clicks the link.
The existing codebase already exposes `openExternalUrlSafe` (imported in `grouped-render.ts`) which validates URLs before opening them. The `Action.OpenUrl` handler should use a `<button>` with a click handler (or at minimum validate the scheme before placing it in `href`) instead of a raw anchor with an unchecked URL.
```suggestion
if (type === "Action.OpenUrl") {
const url = typeof a.url === "string" ? a.url : "";
if (!url) return nothing;
return html`<button
class="ac-action ac-action--link"
type="button"
@click=${() => openExternalUrlSafe(url)}
>${title}</button>`;
}
```
(You will also need to import `openExternalUrlSafe` from `"../open-external-url.ts"` at the top of this file.)
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
Fixed in 6f2eaab. Replaced <a href> with <button @click=${() => openExternalUrlSafe(url)}>. Imported openExternalUrlSafe which validates URL schemes (blocks javascript:, etc.).
| export function renderAdaptiveCard( | ||
| parsed: ParsedAdaptiveCard, | ||
| ): TemplateResult { | ||
| const { card } = parsed; |
There was a problem hiding this comment.
templateData is parsed but never consumed
renderAdaptiveCard destructures only { card } from the ParsedAdaptiveCard, so the templateData value extracted by the parser (from <!--adaptive-card-data--> blocks) is completely ignored at render time. Any template variable substitution — the primary reason to send a separate data block — is silently skipped.
If template binding isn't implemented yet, either remove the DATA_RE/STRIP_DATA_RE parsing logic from adaptive-card-parse.ts (and the templateData field from ParsedAdaptiveCard) to avoid dead code, or add a TODO comment so it isn't forgotten.
Prompt To Fix With AI
This is a comment left during a code review.
Path: ui/src/ui/chat/adaptive-card-render.ts
Line: 19-22
Comment:
**`templateData` is parsed but never consumed**
`renderAdaptiveCard` destructures only `{ card }` from the `ParsedAdaptiveCard`, so the `templateData` value extracted by the parser (from `<!--adaptive-card-data-->` blocks) is completely ignored at render time. Any template variable substitution — the primary reason to send a separate data block — is silently skipped.
If template binding isn't implemented yet, either remove the `DATA_RE`/`STRIP_DATA_RE` parsing logic from `adaptive-card-parse.ts` (and the `templateData` field from `ParsedAdaptiveCard`) to avoid dead code, or add a `TODO` comment so it isn't forgotten.
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
Fixed in 6f2eaab. Added TODO comment at the destructure site. Template data parsing kept for forward-compatibility; binding implementation is a follow-up.
| function renderImage(el: Element): TemplateResult { | ||
| const url = typeof el.url === "string" ? el.url : ""; | ||
| const alt = typeof el.altText === "string" ? el.altText : ""; | ||
| const sizeClass = imageSizeClass(el.size); | ||
| if (!url) { | ||
| return nothing as unknown as TemplateResult; | ||
| } | ||
| return html`<img class="ac-image ${sizeClass}" src="${url}" alt="${alt}" />`; | ||
| } |
There was a problem hiding this comment.
Return type lie — nothing cast to TemplateResult
When url is empty the function returns nothing as unknown as TemplateResult, which defeats TypeScript's type checking and could cause subtle rendering bugs if callers branch on the return value. The declared return type should be widened to TemplateResult | typeof nothing to match reality.
| function renderImage(el: Element): TemplateResult { | |
| const url = typeof el.url === "string" ? el.url : ""; | |
| const alt = typeof el.altText === "string" ? el.altText : ""; | |
| const sizeClass = imageSizeClass(el.size); | |
| if (!url) { | |
| return nothing as unknown as TemplateResult; | |
| } | |
| return html`<img class="ac-image ${sizeClass}" src="${url}" alt="${alt}" />`; | |
| } | |
| function renderImage(el: Element): TemplateResult | typeof nothing { | |
| const url = typeof el.url === "string" ? el.url : ""; | |
| const alt = typeof el.altText === "string" ? el.altText : ""; | |
| const sizeClass = imageSizeClass(el.size); | |
| if (!url) { | |
| return nothing; | |
| } | |
| return html`<img class="ac-image ${sizeClass}" src="${url}" alt="${alt}" />`; | |
| } |
Prompt To Fix With AI
This is a comment left during a code review.
Path: ui/src/ui/chat/adaptive-card-render.ts
Line: 140-148
Comment:
**Return type lie — `nothing` cast to `TemplateResult`**
When `url` is empty the function returns `nothing as unknown as TemplateResult`, which defeats TypeScript's type checking and could cause subtle rendering bugs if callers branch on the return value. The declared return type should be widened to `TemplateResult | typeof nothing` to match reality.
```suggestion
function renderImage(el: Element): TemplateResult | typeof nothing {
const url = typeof el.url === "string" ? el.url : "";
const alt = typeof el.altText === "string" ? el.altText : "";
const sizeClass = imageSizeClass(el.size);
if (!url) {
return nothing;
}
return html`<img class="ac-image ${sizeClass}" src="${url}" alt="${alt}" />`;
}
```
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
Fixed in 6f2eaab. Return type changed to TemplateResult | typeof nothing and the unsafe cast removed.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1f04ba1de0
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| const url = typeof a.url === "string" ? a.url : "#"; | ||
| return html`<a | ||
| class="ac-action ac-action--link" | ||
| href="${url}" |
There was a problem hiding this comment.
Reject unsafe protocols in Action.OpenUrl URLs
Action.OpenUrl values are written directly into an anchor href without any protocol allowlist, so a card payload like {"type":"Action.OpenUrl","url":"javascript:..."} will execute script when clicked. This is a security regression in the chat surface because other URL entry points already route through resolveSafeExternalUrl to block non-http(s)/blob schemes; this path should apply the same validation before rendering clickable links.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 6f2eaab. Action.OpenUrl now uses openExternalUrlSafe() which only allows http:, https:, and blob: protocols. No raw href injection.
| return html` | ||
| <div class="ac-card"> | ||
| ${renderElements(card.body)} | ||
| ${card.actions?.length ? renderActions(card.actions as Element[]) : nothing} |
There was a problem hiding this comment.
Validate actions is an array before calling renderActions
The parser only validates card.body, but renderAdaptiveCard assumes any truthy card.actions?.length is mappable. If a malformed card contains "actions":"oops" (or any non-array with a length), renderActions will call .map on a non-array and throw, breaking message rendering. Guard this with Array.isArray(card.actions) before passing it into renderActions.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 6f2eaab. Added Array.isArray(card.actions) guard before calling renderActions.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6f2eaab484
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
New files: - ui/src/ui/chat/adaptive-card-parse.ts (74 lines) — marker extraction - ui/src/ui/chat/adaptive-card-render.ts (272 lines) — lightweight Lit renderer for TextBlock, FactSet, ColumnSet, Container, Image, Table, actions - ui/src/styles/chat/adaptive-cards.css (183 lines) — card styles with ac- prefix, dark/light theme via existing CSS variables Modified: - ui/src/styles/chat.css — import adaptive-cards.css - ui/src/ui/chat/grouped-render.ts — parseAdaptiveCardMarkers() before markdown conversion; renders card inline when found, fallback text as markdown No external dependencies added (no adaptivecards npm package). Zero-dependency lightweight renderer covering the most common AC v1.5 elements.
1. XSS: replace Action.OpenUrl <a> with <button> using openExternalUrlSafe 2. Action.Submit: hide button, add TODO for custom event routing 3. templateData: add TODO for template data binding 4. renderImage: fix return type to TemplateResult | typeof nothing 5. Validate card.actions is an array before rendering 6. Fix no-base-to-string lint errors with typeof guards Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
6f2eaab to
2f38c58
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2f38c5876a
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| const markdown = markdownBase; | ||
|
|
||
| // Detect adaptive card markers before markdown conversion. | ||
| const parsedCard = markdownBase ? parseAdaptiveCardMarkers(markdownBase) : null; |
There was a problem hiding this comment.
Parse adaptive-card markers only for assistant role
The new marker parsing runs on every message because parsedCard is computed from markdownBase without checking role. This means non-assistant content (for example user messages or tool logs that include literal <!--adaptive-card-->... text) is silently transformed into card UI instead of being shown as plain markdown text, which changes message semantics and can surface card UI where it was not intended. Restrict parsing to the trusted role(s) that actually emit these markers.
Useful? React with 👍 / 👎.
| } | ||
|
|
||
| function renderAction(a: Element): TemplateResult | typeof nothing { | ||
| const type = typeof a.type === "string" ? a.type : ""; |
There was a problem hiding this comment.
Validate action entries before accessing action fields
renderActions passes each array element directly to renderAction, and renderAction immediately reads a.type/a.title. Because parsing only checks that actions is an array (not that each entry is a non-null object), a payload like "actions":[null] throws at runtime (Cannot read properties of null) and breaks rendering for the whole message bubble. Add a non-null object guard before dereferencing action fields.
Useful? React with 👍 / 👎.
Add support for: RichTextBlock, CodeBlock, ImageSet, ActionSet,
Carousel, Accordion, TabSet, Rating, ProgressBar, Badge,
CompoundButton, Chart.*, all Input elements (Text/Number/Date/
Time/Toggle/ChoiceSet), Action.Submit, Action.Execute,
Action.ShowCard, Action.ToggleVisibility.
Also adds template data binding (${expression} expansion),
markdown rendering in TextBlock, Container emphasis/bleed styles,
Image person style, and dispatches ac-action custom events for
card action routing.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c24a0bdbee
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| return html`<a class="ac-inline-link ${cls}" href="${url}" @click=${(e: Event) => { | ||
| e.preventDefault(); | ||
| openExternalUrlSafe(url); | ||
| }}>${text}</a>`; |
There was a problem hiding this comment.
Remove raw href from RichTextBlock OpenUrl runs
Even after Action.OpenUrl button clicks were routed through openExternalUrlSafe, this RichTextBlock branch still writes the unvalidated URL into an anchor href. That means non-primary navigations (for example middle-click or context-menu "open link") can bypass the safe opener and use the raw scheme directly, so disallowed protocols are still reachable from card content.
Useful? React with 👍 / 👎.
| const el = card.querySelector(`[data-ac-id="${id}"]`); | ||
| if (el) { |
There was a problem hiding this comment.
Wire ToggleVisibility to actual rendered element IDs
Action.ToggleVisibility looks up targets via [data-ac-id="..."], but this renderer never emits data-ac-id on card elements, so valid toggle actions cannot find any targets and always no-op. The toggle handler needs to query an attribute that is actually rendered (or elements need to expose matching data-ac-id values).
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 08eefaccaf
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| return typeof val === "object" | ||
| ? JSON.stringify(val) | ||
| : String(val as string | number | boolean); |
There was a problem hiding this comment.
Preserve non-string types in template substitutions
bindTemplateData always coerces ${...} results to strings (or JSON strings for objects), so cards that bind numeric/boolean/object fields lose their original types. For example, ProgressBar.value: "${score}" becomes a string and then renders as 0% because renderProgressBar only accepts numbers; similar failures occur for other typed fields. This makes templated cards with non-text bindings render incorrect state even when templateData is valid.
Useful? React with 👍 / 👎.
| const width = typeof el.width === "string" ? el.width : "auto"; | ||
| const flex = width === "stretch" ? "1 1 0%" : width === "auto" ? "0 0 auto" : `0 0 ${width}`; |
There was a problem hiding this comment.
Support numeric Column.width values
renderColumn treats width as a string-only field and falls back to "auto" for non-strings, but Adaptive Cards allows numeric widths for weighted columns. A valid card with widths like 1 and 2 will lose the intended proportional layout because both columns are rendered as auto-width.
Useful? React with 👍 / 👎.
BradGroux
left a comment
There was a problem hiding this comment.
Review: Web UI Adaptive Card Renderer
1,607 lines adding a Lit-based web renderer with zero external dependencies. Covers parsing (adaptive-card-parse.ts), rendering (adaptive-card-render.ts), CSS (adaptive-cards.css), and integration into grouped chat output (grouped-render.ts). The zero-dependency approach is commendable and the Lit custom element pattern is a good fit for the web UI architecture.
Blocker
1. Unsafe URL placement in <a href> allows javascript:/data: scheme exposure
In adaptive-card-render.ts (renderRichTextBlock), unsanitized URLs from card data are placed directly into href attributes:
return html`<a class="ac-inline-link ${cls}" href="${url}" @click=${(e: Event) => {
e.preventDefault();
openExternalUrlSafe(url);
}}>${text}</a>`;While the click handler uses openExternalUrlSafe with preventDefault, the raw href is still injected into the DOM. Users can right-click and "Copy Link Address" or use browser affordances to access javascript:, data:, or other dangerous scheme URLs. The fix is to validate/sanitize the URL before assigning it to href (allowlist https and http schemes), or render as a <span> styled as a link and route all interaction through the safe opener exclusively.
Suggestions
2. Recursive template binding has no depth or size guard
bindTemplateData traverses untrusted JSON recursively with no depth cap or node count limit:
if (obj && typeof obj === "object") {
const result: Record<string, unknown> = {};
for (const [k, v] of Object.entries(obj)) {
result[k] = bindTemplateData(v, data);
}
}Pathological or maliciously crafted card payloads with deep nesting could cause stack overflow or excessive CPU usage. Add a max depth parameter (e.g., 20 levels) and bail out gracefully when exceeded.
3. Parse result is not cached
Every time grouped-render.ts processes a message, the card JSON is re-parsed. For messages with large cards that remain in the visible chat history, caching the parsed result by message ID would reduce redundant work during re-renders.
Nits
4. nothing as unknown as TemplateResult force-casts
Multiple renderer functions use this pattern:
if (actions.length === 0) {
return nothing as unknown as TemplateResult;
}This weakens type safety. Prefer returning TemplateResult | typeof nothing from these functions and let Lit handle the nothing sentinel natively.
5. Malformed JSON fallback path
If the JSON between markers is malformed, the parser returns null and the card is silently dropped. Consider rendering the fallback text (text before the markers) when parse fails, so the user sees something rather than nothing.
Summary
Impressive zero-dependency renderer with comprehensive element coverage. The URL sanitization issue is the only blocker since it is a security concern that could be exploited through crafted card payloads. The recursive binding guard and caching suggestions are hardening improvements worth addressing before merge.
Summary
Adds inline Adaptive Card rendering to the Web UI. When a chat message contains
<!--adaptive-card-->markers, the card is parsed and rendered natively inline in the chat bubble using a lightweight Lit-compatible renderer — covering the full v1.6 element set generated byadaptive-cards-mcp.New files
ui/src/ui/chat/adaptive-card-parse.ts(74 lines) — marker extraction + JSON parsingui/src/ui/chat/adaptive-card-render.ts(~690 lines) — zero-dependency Lit renderer with full v1.6 coverageui/src/styles/chat/adaptive-cards.css(~530 lines) — card styles withac-prefix, dark/light theme via existing CSS variablesModified
ui/src/styles/chat.css— imports adaptive-cards.cssui/src/ui/chat/grouped-render.ts—renderGroupedMessage()callsparseAdaptiveCardMarkers()before markdown conversion; renders card inline when found; integrated with main's collapsed tool cards andparsedCardguard for empty bubble suppressionElement Coverage (v1.6)
ac-actioncustom event dispatch), Action.ShowCard (expandable), Action.ToggleVisibility (DOM toggle)${expression}template expansion againsttemplateData, including$root.prefix supportDesign decisions
adaptivecardsnpm packageac-actionCustomEvent onwindowfor action routing integration<details>for zero-JS expand<details>/<summary>for accessible collapsible sectionsPerformance target
<100ms including DOM insertion.
Ecosystem Context
This PR is part of the Adaptive Cards feature set powered by:
adaptive_cardtool, MCP bridge, channel-aware prompt guidance, fallback text generation, action routingThe plugin emits
<!--adaptive-card-->markers in tool result text. This PR adds the Web UI client-side parser and Lit renderer that consumes those markers with full v1.6 element coverage.Related PRs