mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-01 12:02:05 +00:00
feat(desktop): consent gate for inline embeds (per-embed / per-service)
Embeds reach out to third parties on render, so default to a placeholder that mirrors the tool-approval UX: "Load <service>" (this embed) or "Always allow <service>" (persisted). A desktop-local store ($embedMode ask|always|off + per-service allowlist) gates the fetch with zero gateway round-trip; an Appearance setting controls the global default. Local renderers (mermaid, svg, alerts) are never gated. Addresses review feedback on outbound third-party requests.
This commit is contained in:
parent
da0ed979fa
commit
db6ced4712
9 changed files with 189 additions and 6 deletions
|
|
@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query'
|
|||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { LanguageSwitcher } from '@/components/language-switcher'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { SegmentedControl } from '@/components/ui/segmented-control'
|
||||
import type { DesktopMarketplaceSearchItem } from '@/global'
|
||||
import { useI18n } from '@/i18n'
|
||||
|
|
@ -10,6 +11,7 @@ import { triggerHaptic } from '@/lib/haptics'
|
|||
import { Check, Download, Loader2, Palette, Trash2 } from '@/lib/icons'
|
||||
import { selectableCardClass } from '@/lib/selectable-card'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $embedAllowed, $embedMode, clearEmbedAllowed, type EmbedMode, setEmbedMode } from '@/store/embed-consent'
|
||||
import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/profile'
|
||||
import { $toolViewMode, setToolViewMode } from '@/store/tool-view'
|
||||
import { $translucency, setTranslucency } from '@/store/translucency'
|
||||
|
|
@ -215,6 +217,8 @@ export function AppearanceSettings() {
|
|||
const { t, isSavingLocale } = useI18n()
|
||||
const { themeName, mode, resolvedMode, availableThemes, setTheme, setMode } = useTheme()
|
||||
const toolViewMode = useStore($toolViewMode)
|
||||
const embedMode = useStore($embedMode)
|
||||
const embedAllowed = useStore($embedAllowed)
|
||||
const translucency = useStore($translucency)
|
||||
const profiles = useStore($profiles)
|
||||
const activeProfileKey = normalizeProfileKey(useStore($activeGatewayProfile))
|
||||
|
|
@ -266,6 +270,12 @@ export function AppearanceSettings() {
|
|||
{ id: 'technical', label: a.technical }
|
||||
] as const
|
||||
|
||||
const embedOptions = [
|
||||
{ id: 'ask', label: a.embedsAsk },
|
||||
{ id: 'always', label: a.embedsAlways },
|
||||
{ id: 'off', label: a.embedsOff }
|
||||
] as const satisfies readonly { id: EmbedMode; label: string }[]
|
||||
|
||||
return (
|
||||
<SettingsContent>
|
||||
<div>
|
||||
|
|
@ -425,6 +435,35 @@ export function AppearanceSettings() {
|
|||
description={a.toolViewDesc}
|
||||
title={a.toolViewTitle}
|
||||
/>
|
||||
|
||||
<ListRow
|
||||
action={
|
||||
<div className="flex flex-col items-end gap-1.5">
|
||||
<SegmentedControl
|
||||
onChange={id => {
|
||||
triggerHaptic('selection')
|
||||
setEmbedMode(id)
|
||||
}}
|
||||
options={embedOptions}
|
||||
value={embedMode}
|
||||
/>
|
||||
{embedAllowed.length > 0 && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
triggerHaptic('selection')
|
||||
clearEmbedAllowed()
|
||||
}}
|
||||
size="inline"
|
||||
variant="text"
|
||||
>
|
||||
{a.embedsReset(embedAllowed.length)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
description={a.embedsDesc}
|
||||
title={a.embedsTitle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,55 @@
|
|||
'use client'
|
||||
|
||||
import { type CSSProperties, useState } from 'react'
|
||||
|
||||
import { SplitButton } from '@/components/ui/split-button'
|
||||
import { Play } from '@/lib/icons'
|
||||
import { allowProvider } from '@/store/embed-consent'
|
||||
|
||||
import type { EmbedDescriptor } from './providers/types'
|
||||
|
||||
// Privacy placeholder shown before an embed reaches out to a third party. Sized
|
||||
// to the embed's footprint (no layout shift). The split control mirrors the
|
||||
// commit button: primary "Load" (this embed) with a caret for "Always allow
|
||||
// <service>" (persisted). Global off lives in Appearance settings.
|
||||
export function EmbedFacade({ descriptor, onLoad }: { descriptor: EmbedDescriptor; onLoad: () => void }) {
|
||||
const [choice, setChoice] = useState('once')
|
||||
|
||||
const style: CSSProperties = descriptor.aspectRatio
|
||||
? { aspectRatio: descriptor.aspectRatio }
|
||||
: { height: descriptor.height ?? 320 }
|
||||
|
||||
const actions = [
|
||||
{ id: 'once', label: `Load ${descriptor.label}` },
|
||||
{ id: 'always', label: `Always allow ${descriptor.label}` }
|
||||
]
|
||||
|
||||
return (
|
||||
<span
|
||||
className="flex size-full flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary)/30"
|
||||
style={style}
|
||||
>
|
||||
<SplitButton
|
||||
actions={actions}
|
||||
onTrigger={id => (id === 'always' ? allowProvider(descriptor.provider) : onLoad())}
|
||||
onValueChange={setChoice}
|
||||
primaryIcon={<Play className="size-3 translate-x-px fill-current" />}
|
||||
value={choice}
|
||||
/>
|
||||
<span className="text-[0.6875rem] text-(--ui-text-tertiary)">{hostOf(descriptor)}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function hostOf(descriptor: EmbedDescriptor): string {
|
||||
// x.com posts often arrive as twitter.com links — show the current brand.
|
||||
if (descriptor.provider === 'twitter') {
|
||||
return 'x.com'
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(descriptor.sourceUrl).hostname.replace(/^www\./, '')
|
||||
} catch {
|
||||
return descriptor.label
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,12 @@
|
|||
'use client'
|
||||
|
||||
import { type CSSProperties, lazy, Suspense } from 'react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { type CSSProperties, lazy, Suspense, useState } from 'react'
|
||||
|
||||
import { PrettyLink } from '@/lib/external-link'
|
||||
import { $embedAllowed, $embedMode } from '@/store/embed-consent'
|
||||
|
||||
import { EmbedFacade } from './embed-consent'
|
||||
import { EMBED_MAX_H } from './embed-size'
|
||||
import { EmbedFail } from './fail'
|
||||
import type { EmbedDescriptor } from './providers/types'
|
||||
|
|
@ -39,11 +44,22 @@ function LazyRenderer({ descriptor }: { descriptor: EmbedDescriptor }) {
|
|||
}
|
||||
|
||||
export function UrlEmbed({ descriptor }: { descriptor: EmbedDescriptor }) {
|
||||
const mode = useStore($embedMode)
|
||||
const allowed = useStore($embedAllowed)
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
|
||||
// Privacy gate: don't reach out to the provider until consented. `off` keeps
|
||||
// it a plain link; otherwise the placeholder shows until "Load" (this embed)
|
||||
// or "Always allow" / global `always` permits the fetch.
|
||||
if (mode === 'off') {
|
||||
return <PrettyLink className="wrap-anywhere" href={descriptor.sourceUrl} />
|
||||
}
|
||||
|
||||
const consented = mode === 'always' || loaded || allowed.includes(descriptor.provider)
|
||||
const aspect = descriptor.aspectRatio
|
||||
|
||||
// Ratio embeds cap WIDTH off the ratio so height tops out at the cap while
|
||||
// scaling. Non-ratio embeds own their own height (scale-to-fit / measured /
|
||||
// fixed) — no maxHeight here or it would clip what the renderer just sized.
|
||||
// scaling. Non-ratio embeds own their own height (measured / fixed).
|
||||
const style: CSSProperties = {
|
||||
containIntrinsicSize: `auto ${intrinsicHeight(descriptor)}px`,
|
||||
contentVisibility: 'auto',
|
||||
|
|
@ -55,9 +71,13 @@ export function UrlEmbed({ descriptor }: { descriptor: EmbedDescriptor }) {
|
|||
return (
|
||||
<span className="group/embed my-2 block overflow-hidden rounded-lg" data-slot="aui_embed-card" style={style}>
|
||||
<RichBoundary fallback={<EmbedFail label={descriptor.label} />} resetKey={descriptor.id}>
|
||||
<Suspense fallback={null}>
|
||||
<LazyRenderer descriptor={descriptor} />
|
||||
</Suspense>
|
||||
{consented ? (
|
||||
<Suspense fallback={null}>
|
||||
<LazyRenderer descriptor={descriptor} />
|
||||
</Suspense>
|
||||
) : (
|
||||
<EmbedFacade descriptor={descriptor} onLoad={() => setLoaded(true)} />
|
||||
)}
|
||||
</RichBoundary>
|
||||
</span>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -376,6 +376,13 @@ export const en: Translations = {
|
|||
toolViewDesc: 'Product hides raw tool payloads; Technical shows full input/output.',
|
||||
translucencyTitle: 'Window Translucency',
|
||||
translucencyDesc: 'See your desktop through the whole window. macOS and Windows only.',
|
||||
embedsTitle: 'Inline Embeds',
|
||||
embedsDesc:
|
||||
'Rich previews load from third-party sites (YouTube, X, …). Ask shows a placeholder until you allow each one; Always loads them automatically; Off keeps plain links.',
|
||||
embedsAsk: 'Ask',
|
||||
embedsAlways: 'Always',
|
||||
embedsOff: 'Off',
|
||||
embedsReset: (count: number) => `Reset ${count} allowed ${count === 1 ? 'service' : 'services'}`,
|
||||
product: 'Product',
|
||||
productDesc: 'Human-friendly tool activity with concise summaries.',
|
||||
technical: 'Technical',
|
||||
|
|
|
|||
|
|
@ -286,6 +286,13 @@ export const ja = defineLocale({
|
|||
toolViewDesc: 'プロダクト表示は生のツールペイロードを隠し、テクニカル表示は入出力をすべて表示します。',
|
||||
translucencyTitle: 'ウィンドウの透過',
|
||||
translucencyDesc: 'ウィンドウ全体を透過させてデスクトップを表示します。macOS と Windows のみ。',
|
||||
embedsTitle: 'インライン埋め込み',
|
||||
embedsDesc:
|
||||
'リッチプレビューは第三者サイト(YouTube、X など)から読み込まれます。確認は許可するまでプレースホルダーを表示し、常には自動で読み込み、オフはリンクのままにします。',
|
||||
embedsAsk: '確認',
|
||||
embedsAlways: '常に',
|
||||
embedsOff: 'オフ',
|
||||
embedsReset: (count: number) => `許可した${count}件のサービスをリセット`,
|
||||
product: 'プロダクト',
|
||||
productDesc: '読みやすいツール活動と簡潔な要約を表示します。',
|
||||
technical: 'テクニカル',
|
||||
|
|
|
|||
|
|
@ -301,6 +301,12 @@ export interface Translations {
|
|||
toolViewDesc: string
|
||||
translucencyTitle: string
|
||||
translucencyDesc: string
|
||||
embedsTitle: string
|
||||
embedsDesc: string
|
||||
embedsAsk: string
|
||||
embedsAlways: string
|
||||
embedsOff: string
|
||||
embedsReset: (count: number) => string
|
||||
product: string
|
||||
productDesc: string
|
||||
technical: string
|
||||
|
|
|
|||
|
|
@ -278,6 +278,12 @@ export const zhHant = defineLocale({
|
|||
toolViewDesc: '產品模式會隱藏原始工具 payload;技術模式會顯示完整輸入/輸出。',
|
||||
translucencyTitle: '視窗透明',
|
||||
translucencyDesc: '讓整個視窗透出桌面。僅支援 macOS 與 Windows。',
|
||||
embedsTitle: '內嵌預覽',
|
||||
embedsDesc: '豐富預覽會從第三方網站(YouTube、X 等)載入。詢問會在你允許前顯示佔位符;一律會自動載入;關閉則保留純連結。',
|
||||
embedsAsk: '詢問',
|
||||
embedsAlways: '一律',
|
||||
embedsOff: '關閉',
|
||||
embedsReset: (count: number) => `重設 ${count} 個已允許的服務`,
|
||||
product: '產品',
|
||||
productDesc: '易讀的工具活動與精簡摘要。',
|
||||
technical: '技術',
|
||||
|
|
|
|||
|
|
@ -369,6 +369,12 @@ export const zh: Translations = {
|
|||
toolViewDesc: '产品模式隐藏原始工具数据;技术模式显示完整输入/输出。',
|
||||
translucencyTitle: '窗口透明',
|
||||
translucencyDesc: '让整个窗口透出桌面。仅支持 macOS 和 Windows。',
|
||||
embedsTitle: '内嵌预览',
|
||||
embedsDesc: '富预览会从第三方网站(YouTube、X 等)加载。询问会在你允许前显示占位符;总是会自动加载;关闭则保留纯链接。',
|
||||
embedsAsk: '询问',
|
||||
embedsAlways: '总是',
|
||||
embedsOff: '关闭',
|
||||
embedsReset: (count: number) => `重置 ${count} 个已允许的服务`,
|
||||
product: '产品',
|
||||
productDesc: '易读的工具活动与简洁摘要。',
|
||||
technical: '技术',
|
||||
|
|
|
|||
37
apps/desktop/src/store/embed-consent.ts
Normal file
37
apps/desktop/src/store/embed-consent.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { type Codec, Codecs, persistentAtom } from '@/lib/persisted'
|
||||
|
||||
// Privacy gate for inline embeds. Loading an embed reaches out to a third party
|
||||
// (IP, referrer, cookies), so by default we render a placeholder until the user
|
||||
// consents — per embed ("Load once") or per service ("Always allow YouTube").
|
||||
// Mirrors the tool-approval model, but purely client-side (the renderer is what
|
||||
// makes the request) so it never touches the gateway/config.yaml.
|
||||
export type EmbedMode = 'always' | 'ask' | 'off'
|
||||
|
||||
const MODE_KEY = 'hermes.desktop.embed-mode'
|
||||
const ALLOWED_KEY = 'hermes.desktop.embed-allowed'
|
||||
|
||||
const modeCodec: Codec<EmbedMode> = {
|
||||
decode: raw => (raw === 'always' || raw === 'off' ? raw : 'ask'),
|
||||
encode: value => value
|
||||
}
|
||||
|
||||
/** Global default: ask (placeholder), always (auto-load), off (plain link). */
|
||||
export const $embedMode = persistentAtom<EmbedMode>(MODE_KEY, 'ask', modeCodec)
|
||||
/** Providers granted a standing "always allow" (e.g. `youtube`, `twitter`). */
|
||||
export const $embedAllowed = persistentAtom<string[]>(ALLOWED_KEY, [], Codecs.stringArray)
|
||||
|
||||
export function allowProvider(provider: string) {
|
||||
const current = $embedAllowed.get()
|
||||
|
||||
if (!current.includes(provider)) {
|
||||
$embedAllowed.set([...current, provider])
|
||||
}
|
||||
}
|
||||
|
||||
export function setEmbedMode(mode: EmbedMode) {
|
||||
$embedMode.set(mode)
|
||||
}
|
||||
|
||||
export function clearEmbedAllowed() {
|
||||
$embedAllowed.set([])
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue