diff --git a/packages/builtin-tool-remote-device/package.json b/packages/builtin-tool-remote-device/package.json index 68f199c1c6..25b68f68c3 100644 --- a/packages/builtin-tool-remote-device/package.json +++ b/packages/builtin-tool-remote-device/package.json @@ -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": { diff --git a/packages/builtin-tool-remote-device/src/client/Render/ActivateDevice/index.tsx b/packages/builtin-tool-remote-device/src/client/Render/ActivateDevice/index.tsx new file mode 100644 index 0000000000..712c3dd608 --- /dev/null +++ b/packages/builtin-tool-remote-device/src/client/Render/ActivateDevice/index.tsx @@ -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>( + ({ pluginState, content }) => { + const device = pluginState?.activatedDevice; + + if (device) return ; + + // 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 ( +
+ + {content} +
+ ); + } + + return null; + }, +); + +ActivateDevice.displayName = 'ActivateDevice'; + +export default ActivateDevice; diff --git a/packages/builtin-tool-remote-device/src/client/Render/DeviceCard/index.tsx b/packages/builtin-tool-remote-device/src/client/Render/DeviceCard/index.tsx new file mode 100644 index 0000000000..5da1c09ed4 --- /dev/null +++ b/packages/builtin-tool-remote-device/src/client/Render/DeviceCard/index.tsx @@ -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(({ device, activated }) => ( + + + + + + {device.hostname} + + {device.platform} · {device.deviceId.slice(0, 12)} + + + {activated ? ( + + + Activated + + ) : ( + device.online && Online + )} + +)); + +DeviceCard.displayName = 'DeviceCard'; + +export default DeviceCard; diff --git a/packages/builtin-tool-remote-device/src/client/Render/ListDevices/index.tsx b/packages/builtin-tool-remote-device/src/client/Render/ListDevices/index.tsx new file mode 100644 index 0000000000..960bbfe643 --- /dev/null +++ b/packages/builtin-tool-remote-device/src/client/Render/ListDevices/index.tsx @@ -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>( + ({ pluginState }) => { + const devices = pluginState?.devices ?? []; + + if (devices.length === 0) { + return
No online devices found.
; + } + + return ( + + {devices.map((device) => ( + + ))} + + ); + }, +); + +ListDevices.displayName = 'ListDevices'; + +export default ListDevices; diff --git a/packages/builtin-tool-remote-device/src/client/Render/index.ts b/packages/builtin-tool-remote-device/src/client/Render/index.ts new file mode 100644 index 0000000000..460d6dd48b --- /dev/null +++ b/packages/builtin-tool-remote-device/src/client/Render/index.ts @@ -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, +}; diff --git a/packages/builtin-tool-remote-device/src/client/index.ts b/packages/builtin-tool-remote-device/src/client/index.ts new file mode 100644 index 0000000000..43ce83541a --- /dev/null +++ b/packages/builtin-tool-remote-device/src/client/index.ts @@ -0,0 +1,3 @@ +export { RemoteDeviceManifest } from '../manifest'; +export * from '../types'; +export { RemoteDeviceRenders } from './Render'; diff --git a/packages/builtin-tool-remote-device/src/types.ts b/packages/builtin-tool-remote-device/src/types.ts index 997fb33564..6c54567f5f 100644 --- a/packages/builtin-tool-remote-device/src/types.ts +++ b/packages/builtin-tool-remote-device/src/types.ts @@ -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 }; +} diff --git a/packages/builtin-tools/src/renders.ts b/packages/builtin-tools/src/renders.ts index 4d7844a777..ca50526c67 100644 --- a/packages/builtin-tools/src/renders.ts +++ b/packages/builtin-tools/src/renders.ts @@ -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> = { [MessageManifest.identifier]: MessageRenders as Record, [NotebookIdentifier]: NotebookRenders, [PageAgentManifest.identifier]: PageAgentRenders as Record, + [RemoteDeviceManifest.identifier]: RemoteDeviceRenders as Record, [SkillStoreManifest.identifier]: SkillStoreRenders as Record, [SkillsManifest.identifier]: SkillsRenders as Record, [TaskManifest.identifier]: TaskRenders as Record, diff --git a/packages/utils/src/client/sanitize.test.ts b/packages/utils/src/client/sanitize.test.ts index dd11283bdc..42341b551c 100644 --- a/packages/utils/src/client/sanitize.test.ts +++ b/packages/utils/src/client/sanitize.test.ts @@ -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 = ``; + + const sanitized = sanitizeSVGContent(malicious); + + expect(sanitized).not.toMatch(/\son[a-z]+\s*=/i); + }); + it('should remove dangerous embed and object tags', () => { const maliciousSvg = ` @@ -93,16 +103,20 @@ describe('sanitizeSVGContent', () => { const sanitized = sanitizeSVGContent(complexSvg); - // Should preserve safe elements and attributes - expect(sanitized).toEqual(` - - - - - - - - - `); + // Should preserve safe structure (assert by property, not exact serialization — + // whitespace/self-closing handling varies across DOM engines). + expect(sanitized).toContain(']+)/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; };