feat(remote-device): add client renders for device tool results (#15437)

*  feat(remote-device): add client renders for listOnlineDevices and activateDevice

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* 🐛 fix(utils): make SVG event-handler stripping engine-independent

DOMPurify's FORBID_ATTR / SVG-profile allowlist path relies on the underlying DOM's
attribute + namespace handling, which differs across engines (jsdom vs happy-dom) and
DOMPurify versions — in some CI environments on* handlers on SVG-namespaced nodes slipped
through. Add a scoped uponSanitizeAttribute hook to drop every on* attribute deterministically,
and assert by security property instead of exact serialization to drop whitespace brittleness.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* 🐛 fix(remote-device): render activation failure content when no device state

activateDevice returns success:false with explanatory content but no error and no state when
the target is offline/unknown. The tool detail view only skips custom rendering when result.error
is set, so the custom renderer's `return null` rendered a blank result. Fall back to the failure
content so the user/model still sees the message.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* 🐛 fix(utils): deterministically scrub SVG on* handlers via post-pass

The DOMPurify uponSanitizeAttribute hook still failed in CI: <script> is removed (tag filtering)
but on* handlers survive, because the attribute-sanitization phase doesn't run for SVG-namespaced
nodes in CI's DOM engine — so the hook never fires. Replace it with an explicit regex scrub on the
serialized output, which strips every on* event-handler attribute independent of the DOM engine.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* 🔒 fix(utils): loop SVG on* scrub until stable to close recombination bypass

A single-pass regex replace can leave a fresh handler behind when removing one splices the
surrounding text back together (` on onclick="x"click="y"` → ` onclick="y"`) — the CodeQL
js/incomplete-multi-character-sanitization case. Repeat the scrub until the string stops changing
so no on*= token can survive. Adds a regression test for the recombination input.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Arvin Xu
2026-06-03 23:59:35 +08:00
committed by GitHub
parent 2a4b6e4974
commit d81e5e703e
10 changed files with 286 additions and 13 deletions
@@ -3,7 +3,8 @@
"version": "1.0.0",
"private": true,
"exports": {
".": "./src/index.ts"
".": "./src/index.ts",
"./client": "./src/client/index.ts"
},
"main": "./src/index.ts",
"dependencies": {
@@ -0,0 +1,54 @@
'use client';
import { type BuiltinRenderProps } from '@lobechat/types';
import { Icon } from '@lobehub/ui';
import { createStaticStyles } from 'antd-style';
import { AlertTriangleIcon } from 'lucide-react';
import { memo } from 'react';
import type { ActivateDeviceParams, ActivateDeviceState } from '../../../types';
import DeviceCard from '../DeviceCard';
const styles = createStaticStyles(({ css, cssVar }) => ({
failure: css`
display: flex;
gap: 8px;
align-items: center;
padding-block: 12px;
padding-inline: 12px;
border: 1px solid ${cssVar.colorWarningBorder};
border-radius: 10px;
font-size: 13px;
color: ${cssVar.colorWarningText};
background: ${cssVar.colorWarningBg};
`,
}));
const ActivateDevice = memo<BuiltinRenderProps<ActivateDeviceParams, ActivateDeviceState, string>>(
({ pluginState, content }) => {
const device = pluginState?.activatedDevice;
if (device) return <DeviceCard activated device={device} />;
// Activation failed without a thrown error (e.g. device offline / unknown), so no state is
// produced. Fall back to the explanatory content the runtime returned instead of rendering
// blank — the tool detail view only skips custom renders when `result.error` is set.
if (typeof content === 'string' && content.length > 0) {
return (
<div className={styles.failure}>
<Icon icon={AlertTriangleIcon} size={14} />
{content}
</div>
);
}
return null;
},
);
ActivateDevice.displayName = 'ActivateDevice';
export default ActivateDevice;
@@ -0,0 +1,101 @@
'use client';
import { Flexbox, Icon } from '@lobehub/ui';
import { createStaticStyles } from 'antd-style';
import { CheckCircle2, MonitorIcon } from 'lucide-react';
import { memo } from 'react';
import type { DeviceAttachment } from '../../../ExecutionRuntime/types';
const styles = createStaticStyles(({ css, cssVar }) => ({
activated: css`
color: ${cssVar.colorSuccess};
background: ${cssVar.colorSuccessBg};
`,
badge: css`
display: inline-flex;
gap: 4px;
align-items: center;
padding-block: 2px;
padding-inline: 8px;
border-radius: 6px;
font-size: 12px;
line-height: 16px;
white-space: nowrap;
`,
card: css`
width: 100%;
padding-block: 10px;
padding-inline: 12px;
border: 1px solid ${cssVar.colorBorderSecondary};
border-radius: 10px;
background: ${cssVar.colorBgContainer};
`,
hostname: css`
overflow: hidden;
font-size: 14px;
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
`,
icon: css`
flex: none;
width: 32px;
height: 32px;
border-radius: 8px;
color: ${cssVar.colorTextSecondary};
background: ${cssVar.colorFillTertiary};
`,
meta: css`
overflow: hidden;
font-family: ${cssVar.fontFamilyCode};
font-size: 12px;
color: ${cssVar.colorTextDescription};
text-overflow: ellipsis;
white-space: nowrap;
`,
online: css`
color: ${cssVar.colorTextSecondary};
background: ${cssVar.colorFillTertiary};
`,
}));
interface DeviceCardProps {
/** Render the activated treatment (check badge) instead of the online badge. */
activated?: boolean;
device: DeviceAttachment;
}
const DeviceCard = memo<DeviceCardProps>(({ device, activated }) => (
<Flexbox horizontal align={'center'} className={styles.card} gap={12}>
<Flexbox align={'center'} className={styles.icon} justify={'center'}>
<Icon icon={MonitorIcon} size={18} />
</Flexbox>
<Flexbox flex={1} gap={2} style={{ minWidth: 0 }}>
<span className={styles.hostname}>{device.hostname}</span>
<span className={styles.meta}>
{device.platform} · {device.deviceId.slice(0, 12)}
</span>
</Flexbox>
{activated ? (
<span className={[styles.badge, styles.activated].join(' ')}>
<Icon icon={CheckCircle2} size={12} />
Activated
</span>
) : (
device.online && <span className={[styles.badge, styles.online].join(' ')}>Online</span>
)}
</Flexbox>
));
DeviceCard.displayName = 'DeviceCard';
export default DeviceCard;
@@ -0,0 +1,45 @@
'use client';
import { type BuiltinRenderProps } from '@lobechat/types';
import { Flexbox } from '@lobehub/ui';
import { createStaticStyles } from 'antd-style';
import { memo } from 'react';
import type { ListOnlineDevicesState } from '../../../types';
import DeviceCard from '../DeviceCard';
const styles = createStaticStyles(({ css, cssVar }) => ({
empty: css`
padding-block: 12px;
padding-inline: 12px;
border: 1px solid ${cssVar.colorBorderSecondary};
border-radius: 10px;
font-size: 13px;
color: ${cssVar.colorTextDescription};
background: ${cssVar.colorBgContainer};
`,
}));
const ListDevices = memo<BuiltinRenderProps<undefined, ListOnlineDevicesState>>(
({ pluginState }) => {
const devices = pluginState?.devices ?? [];
if (devices.length === 0) {
return <div className={styles.empty}>No online devices found.</div>;
}
return (
<Flexbox gap={8} width={'100%'}>
{devices.map((device) => (
<DeviceCard device={device} key={device.deviceId} />
))}
</Flexbox>
);
},
);
ListDevices.displayName = 'ListDevices';
export default ListDevices;
@@ -0,0 +1,8 @@
import { RemoteDeviceApiName } from '../../types';
import ActivateDevice from './ActivateDevice';
import ListDevices from './ListDevices';
export const RemoteDeviceRenders = {
[RemoteDeviceApiName.activateDevice]: ActivateDevice,
[RemoteDeviceApiName.listOnlineDevices]: ListDevices,
};
@@ -0,0 +1,3 @@
export { RemoteDeviceManifest } from '../manifest';
export * from '../types';
export { RemoteDeviceRenders } from './Render';
@@ -1,3 +1,5 @@
import { type DeviceAttachment } from './ExecutionRuntime/types';
export const RemoteDeviceIdentifier = 'lobe-remote-device';
export const RemoteDeviceApiName = {
@@ -7,3 +9,19 @@ export const RemoteDeviceApiName = {
export type RemoteDeviceApiNameType =
(typeof RemoteDeviceApiName)[keyof typeof RemoteDeviceApiName];
/** Plugin state produced by the `listOnlineDevices` API. */
export interface ListOnlineDevicesState {
devices?: DeviceAttachment[];
}
/** Arguments accepted by the `activateDevice` API. */
export interface ActivateDeviceParams {
deviceId: string;
}
/** Plugin state produced by the `activateDevice` API. */
export interface ActivateDeviceState {
activatedDevice?: DeviceAttachment;
metadata?: { activeDeviceId?: string };
}
+5
View File
@@ -27,6 +27,10 @@ import {
import { MemoryManifest, MemoryRenders } from '@lobechat/builtin-tool-memory/client';
import { MessageManifest, MessageRenders } from '@lobechat/builtin-tool-message/client';
import { PageAgentManifest, PageAgentRenders } from '@lobechat/builtin-tool-page-agent/client';
import {
RemoteDeviceManifest,
RemoteDeviceRenders,
} from '@lobechat/builtin-tool-remote-device/client';
import { SkillStoreManifest, SkillStoreRenders } from '@lobechat/builtin-tool-skill-store/client';
import { SkillsManifest, SkillsRenders } from '@lobechat/builtin-tool-skills/client';
import { TaskManifest, TaskRenders } from '@lobechat/builtin-tool-task/client';
@@ -70,6 +74,7 @@ const BuiltinToolsRenders: Record<string, Record<string, BuiltinRender>> = {
[MessageManifest.identifier]: MessageRenders as Record<string, BuiltinRender>,
[NotebookIdentifier]: NotebookRenders,
[PageAgentManifest.identifier]: PageAgentRenders as Record<string, BuiltinRender>,
[RemoteDeviceManifest.identifier]: RemoteDeviceRenders as Record<string, BuiltinRender>,
[SkillStoreManifest.identifier]: SkillStoreRenders as Record<string, BuiltinRender>,
[SkillsManifest.identifier]: SkillsRenders as Record<string, BuiltinRender>,
[TaskManifest.identifier]: TaskRenders as Record<string, BuiltinRender>,
+25 -11
View File
@@ -52,6 +52,16 @@ describe('sanitizeSVGContent', () => {
expect(sanitized).toContain('fill="red"');
});
it('should not leave a recombined event handler after stripping', () => {
// Removing the inner handler in a single pass would splice ` on` + `click="y"` back into a
// fresh ` onclick="y"`; the scrub must repeat until no handler remains.
const malicious = `<svg xmlns="http://www.w3.org/2000/svg"><circle on onclick="x"click="y" /></svg>`;
const sanitized = sanitizeSVGContent(malicious);
expect(sanitized).not.toMatch(/\son[a-z]+\s*=/i);
});
it('should remove dangerous embed and object tags', () => {
const maliciousSvg = `
<svg xmlns="http://www.w3.org/2000/svg">
@@ -93,16 +103,20 @@ describe('sanitizeSVGContent', () => {
const sanitized = sanitizeSVGContent(complexSvg);
// Should preserve safe elements and attributes
expect(sanitized).toEqual(`
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<defs>
<linearGradient id="grad1">
<stop offset="0%" stop-color="red"></stop>
<stop offset="100%" stop-color="blue"></stop>
</linearGradient>
</defs>
<g transform="translate(50,50)">
</g></svg>`);
// Should preserve safe structure (assert by property, not exact serialization —
// whitespace/self-closing handling varies across DOM engines).
expect(sanitized).toContain('<svg');
expect(sanitized).toContain('viewBox="0 0 200 200"');
expect(sanitized).toContain('<linearGradient id="grad1"');
expect(sanitized).toContain('stop-color="red"');
expect(sanitized).toContain('transform="translate(50,50)"');
// Should strip all threats regardless of engine
expect(sanitized).not.toContain('<script');
expect(sanitized).not.toContain('malicious');
expect(sanitized).not.toContain('onclick');
expect(sanitized).not.toContain('onload');
expect(sanitized).not.toContain('hack()');
expect(sanitized).not.toContain('evil()');
});
});
+25 -1
View File
@@ -20,16 +20,40 @@ const FORBID_EVENT_HANDLERS = [
'onunload',
];
/**
* Matches any `on*` event-handler attribute together with its value — ` onclick="…"`,
* ` onload='…'`, or unquoted ` onfoo=bar`. SVG has no safe attribute that starts with `on`,
* so stripping all of them is lossless for legitimate content.
*/
const EVENT_HANDLER_ATTR = /\son[a-z]+\s*=\s*("[^"]*"|'[^']*'|[^\s/>]+)/gi;
/**
* Sanitizes SVG content to prevent XSS attacks while preserving safe SVG elements and attributes
* @param content - The SVG content to sanitize
* @returns Sanitized SVG content safe for rendering
*/
export const sanitizeSVGContent = (content: string): string => {
return DOMPurify.sanitize(content, {
const sanitized = DOMPurify.sanitize(content, {
FORBID_ATTR: FORBID_EVENT_HANDLERS,
FORBID_TAGS: ['embed', 'link', 'object', 'script', 'style'],
KEEP_CONTENT: false,
USE_PROFILES: { svg: true, svgFilters: true },
});
// Defense-in-depth: DOMPurify's attribute-level filtering runs through the underlying DOM's
// attribute + namespace handling, which is inconsistent across engines (jsdom vs happy-dom) and
// DOMPurify versions — in some CI environments `on*` handlers on SVG-namespaced nodes are not
// stripped at all. Scrub them from the serialized output so removal is deterministic everywhere.
//
// Apply repeatedly until the string stabilizes: removing one handler can splice the surrounding
// text into a fresh `on…=` token (e.g. ` on onclick="x"click="y"` → ` onclick="y"`), which a
// single pass would miss.
let scrubbed = sanitized;
let previous: string;
do {
previous = scrubbed;
scrubbed = scrubbed.replaceAll(EVENT_HANDLER_ATTR, '');
} while (scrubbed !== previous);
return scrubbed;
};