mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 08:37:49 +00:00
fix(security): put every trust decision on a host-owned token surface (#30710)
* fix(security): put every trust dialog on a host-owned token surface Plugin themes write CSS tokens on the document root, and dialogs portal to body, so any trust surface without plugin-security-chrome inherits them; a theme could hide what is being approved. The orca.yaml hook trust dialog, notebook run confirmation, plugin install, remove and rollback dialogs were missing it. DialogContent gains surface="security", which applies the host-owned tokens, and a scrollable prop for the plugin dialogs' existing viewport-height scrolling; every trust dialog uses the surface and a test lists them. The style guide says when to use each. * fix(security): keep inline trust decisions on the host-owned surface The agent command approval card (Allow/Deny) and the browser certificate override sit outside a dialog, so plugin theme tokens still reached them. A SecuritySurface primitive puts its child on the same host-owned token set as DialogContent surface="security"; both use it, and the security surface test now lists inline trust surfaces as well as dialogs.
This commit is contained in:
@@ -156,6 +156,8 @@ Sizes: `default` (36px), `sm` (32px), `xs` (24px), `lg` (40px), plus `icon`, `ic
|
||||
|
||||
Browse `src/renderer/src/components/ui/` for the full list. Most wrap a Radix UI primitive — exceptions are `command` (wraps `cmdk`), `sonner` (wraps `sonner`), and the visual-only wrappers (`badge`, `button-group`, `card`, `input`) which apply tokens and Tailwind utilities directly. Never reimplement headless behavior; extend the existing wrapper.
|
||||
|
||||
Use `DialogContent surface="security"` for every dialog that asks the user to trust, approve, run or remove code, including plugin provenance and approval, so plugin themes cannot change the trust surface's tokens. Use its `scrollable` prop when the whole dialog scrolls. Wrap a trust decision outside a dialog, such as an agent approval card or a certificate override, in `SecuritySurface`.
|
||||
|
||||
### Picking the right primitive
|
||||
|
||||
When a control has multiple plausible primitives, use this fork:
|
||||
|
||||
+105
-97
@@ -1,3 +1,4 @@
|
||||
import { SecuritySurface } from '@/components/ui/security-surface'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Copy, ExternalLink, Globe, Loader2, RefreshCw, ShieldAlert } from 'lucide-react'
|
||||
|
||||
@@ -261,107 +262,114 @@ export function BrowserLoadFailureOverlay({
|
||||
) : null
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 z-20 flex items-center justify-center bg-background px-6">
|
||||
<div aria-live="polite" className="flex max-w-lg flex-col items-center px-8 py-8 text-center">
|
||||
<div className="mb-4 rounded-full border border-border bg-muted p-3 text-muted-foreground">
|
||||
{certificateError ? <ShieldAlert className="size-5" /> : <Globe className="size-5" />}
|
||||
</div>
|
||||
<h2 className="text-base font-semibold text-foreground">
|
||||
{certificateError
|
||||
? translate('browser.loadFailure.connectionNotSecure', "Connection isn't secure")
|
||||
: guestRecoveryError
|
||||
? translate('browser.guestRecovery.title', 'Browser page stopped')
|
||||
: meta.host
|
||||
? translate('browser.loadFailure.cantReachHost', "Can't reach {{value0}}", {
|
||||
value0: meta.host
|
||||
})
|
||||
: translate('browser.loadFailure.cantLoadPage', "Can't load this page")}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{formatLoadFailureDescription(presentationLoadError, meta)}
|
||||
</p>
|
||||
{certificateError && meta.isLocalhostLike ? (
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{translate(
|
||||
'browser.loadFailure.trustedCertificateGuidance',
|
||||
'For local development, use a trusted local certificate when possible.'
|
||||
)}
|
||||
<SecuritySurface>
|
||||
<div className="absolute inset-0 z-20 flex items-center justify-center bg-background px-6">
|
||||
<div
|
||||
aria-live="polite"
|
||||
className="flex max-w-lg flex-col items-center px-8 py-8 text-center"
|
||||
>
|
||||
<div className="mb-4 rounded-full border border-border bg-muted p-3 text-muted-foreground">
|
||||
{certificateError ? <ShieldAlert className="size-5" /> : <Globe className="size-5" />}
|
||||
</div>
|
||||
<h2 className="text-base font-semibold text-foreground">
|
||||
{certificateError
|
||||
? translate('browser.loadFailure.connectionNotSecure', "Connection isn't secure")
|
||||
: guestRecoveryError
|
||||
? translate('browser.guestRecovery.title', 'Browser page stopped')
|
||||
: meta.host
|
||||
? translate('browser.loadFailure.cantReachHost', "Can't reach {{value0}}", {
|
||||
value0: meta.host
|
||||
})
|
||||
: translate('browser.loadFailure.cantLoadPage', "Can't load this page")}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{formatLoadFailureDescription(presentationLoadError, meta)}
|
||||
</p>
|
||||
) : null}
|
||||
{recoveryHint ? <p className="mt-2 text-xs text-muted-foreground">{recoveryHint}</p> : null}
|
||||
{sshRoutedHint ? (
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{translate(
|
||||
'browser.loadFailure.sshRoutedHint',
|
||||
"This page browses through the workspace's SSH host — the host may be disconnected or unable to reach this site."
|
||||
)}
|
||||
{onRecheckSshRoute ? (
|
||||
{certificateError && meta.isLocalhostLike ? (
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{translate(
|
||||
'browser.loadFailure.trustedCertificateGuidance',
|
||||
'For local development, use a trusted local certificate when possible.'
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
{recoveryHint ? (
|
||||
<p className="mt-2 text-xs text-muted-foreground">{recoveryHint}</p>
|
||||
) : null}
|
||||
{sshRoutedHint ? (
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{translate(
|
||||
'browser.loadFailure.sshRoutedHint',
|
||||
"This page browses through the workspace's SSH host — the host may be disconnected or unable to reach this site."
|
||||
)}
|
||||
{onRecheckSshRoute ? (
|
||||
<>
|
||||
{' '}
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="xs"
|
||||
className="h-auto px-0 align-baseline"
|
||||
data-testid="browser-load-failure-recheck-ssh-route"
|
||||
onClick={onRecheckSshRoute}
|
||||
>
|
||||
{translate('browser.loadFailure.recheckSshRoute', 'Run connection check')}
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
) : null}
|
||||
{activeProceedAttempt?.state === 'failed' && activeProceedAttempt.reason ? (
|
||||
<p role="alert" className="mt-3 text-xs text-destructive">
|
||||
{formatCertificateProceedFailure(activeProceedAttempt.reason)}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="mt-5 flex flex-wrap items-center justify-center gap-2">
|
||||
{certificateError ? (
|
||||
<>
|
||||
{' '}
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="xs"
|
||||
className="h-auto px-0 align-baseline"
|
||||
data-testid="browser-load-failure-recheck-ssh-route"
|
||||
onClick={onRecheckSshRoute}
|
||||
>
|
||||
{translate('browser.loadFailure.recheckSshRoute', 'Run connection check')}
|
||||
</Button>
|
||||
{externalButton}
|
||||
{retryButton}
|
||||
{copyButton}
|
||||
{matchingCertificateFailure ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-9 w-48 gap-2 px-3"
|
||||
disabled={actionsDisabled}
|
||||
onClick={proceedCertificate}
|
||||
>
|
||||
{activeProceedAttempt?.state === 'submitting' &&
|
||||
activeProceedAttempt.showConnecting ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
{translate('browser.loadFailure.connecting', 'Connecting…')}
|
||||
</>
|
||||
) : (
|
||||
translate('browser.loadFailure.proceedUnsafe', 'Proceed Anyway (Unsafe)')
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
) : null}
|
||||
{activeProceedAttempt?.state === 'failed' && activeProceedAttempt.reason ? (
|
||||
<p role="alert" className="mt-3 text-xs text-destructive">
|
||||
{formatCertificateProceedFailure(activeProceedAttempt.reason)}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="mt-5 flex flex-wrap items-center justify-center gap-2">
|
||||
{certificateError ? (
|
||||
<>
|
||||
{externalButton}
|
||||
{retryButton}
|
||||
{copyButton}
|
||||
{matchingCertificateFailure ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-9 w-48 gap-2 px-3"
|
||||
disabled={actionsDisabled}
|
||||
onClick={proceedCertificate}
|
||||
>
|
||||
{activeProceedAttempt?.state === 'submitting' &&
|
||||
activeProceedAttempt.showConnecting ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
{translate('browser.loadFailure.connecting', 'Connecting…')}
|
||||
</>
|
||||
) : (
|
||||
translate('browser.loadFailure.proceedUnsafe', 'Proceed Anyway (Unsafe)')
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{httpsRecoveryUrl && !guestRecoveryError ? (
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-9 gap-2 px-3"
|
||||
disabled={actionsDisabled}
|
||||
onClick={() => onTryHttps(httpsRecoveryUrl)}
|
||||
>
|
||||
{translate('browser.loadFailure.tryHttps', 'Try HTTPS')}
|
||||
</Button>
|
||||
) : null}
|
||||
{retryButton}
|
||||
{copyButton}
|
||||
{externalButton}
|
||||
</>
|
||||
)}
|
||||
) : (
|
||||
<>
|
||||
{httpsRecoveryUrl && !guestRecoveryError ? (
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-9 gap-2 px-3"
|
||||
disabled={actionsDisabled}
|
||||
onClick={() => onTryHttps(httpsRecoveryUrl)}
|
||||
>
|
||||
{translate('browser.loadFailure.tryHttps', 'Try HTTPS')}
|
||||
</Button>
|
||||
) : null}
|
||||
{retryButton}
|
||||
{copyButton}
|
||||
{externalButton}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SecuritySurface>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -255,7 +255,7 @@ export default function IpynbViewer({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-md sm:max-w-md" showCloseButton={false}>
|
||||
<DialogContent surface="security" className="max-w-md sm:max-w-md" showCloseButton={false}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-sm">
|
||||
{translate('auto.components.editor.IpynbViewer.9e06ae5d36', 'Run Notebook Code?')}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SecuritySurface } from '@/components/ui/security-surface'
|
||||
import { useRef } from 'react'
|
||||
import { ShieldQuestion, X } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -60,133 +61,136 @@ export function NativeChatApprovalCard({
|
||||
const escape = onCancel ?? (isSubmitting ? undefined : onCollapse)
|
||||
|
||||
return (
|
||||
<div className="min-h-0 shrink overflow-hidden bg-chat-canvas">
|
||||
<div className="mx-auto flex h-full min-h-0 max-h-full w-full max-w-(--chat-content-max-width) px-3 pt-2 pb-1 sm:px-4">
|
||||
<div
|
||||
ref={cardRef}
|
||||
data-native-chat-prompt-card-focus={shouldFocus || undefined}
|
||||
data-native-chat-approval-card="true"
|
||||
role="group"
|
||||
aria-label={approval.title}
|
||||
tabIndex={-1}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape' && !event.nativeEvent.isComposing && escape) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
escape()
|
||||
}
|
||||
}}
|
||||
className="flex min-h-0 w-full flex-1 flex-col gap-2 overflow-hidden rounded-lg border border-input bg-card px-4 py-3 shadow-xs focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<div className="flex shrink-0 items-start gap-2">
|
||||
<ShieldQuestion className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="line-clamp-2 break-words text-sm font-semibold text-foreground">
|
||||
{approval.title}
|
||||
</p>
|
||||
</div>
|
||||
{onCancel ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
aria-label={translate('components.native-chat.approval.cancel', 'Cancel')}
|
||||
className="flex size-6 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
) : onCollapse ? (
|
||||
<NativeChatPromptCollapseToggle
|
||||
expanded
|
||||
disabled={isSubmitting}
|
||||
onToggle={onCollapse}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{hasContext ? (
|
||||
<div
|
||||
data-native-chat-approval-content="true"
|
||||
tabIndex={0}
|
||||
className="min-h-0 max-h-72 shrink space-y-2 overflow-auto text-sm text-muted-foreground scrollbar-sleek focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
||||
>
|
||||
{approval.description ? (
|
||||
<p className="whitespace-pre-wrap break-words">{approval.description}</p>
|
||||
) : null}
|
||||
{approval.decisionReason ? (
|
||||
<p className="whitespace-pre-wrap break-words">
|
||||
<span className="font-medium text-foreground">
|
||||
{translate('components.native-chat.approval.reason', 'Reason')}:{' '}
|
||||
</span>
|
||||
{approval.decisionReason}
|
||||
<SecuritySurface>
|
||||
<div className="min-h-0 shrink overflow-hidden bg-chat-canvas">
|
||||
<div className="mx-auto flex h-full min-h-0 max-h-full w-full max-w-(--chat-content-max-width) px-3 pt-2 pb-1 sm:px-4">
|
||||
<div
|
||||
ref={cardRef}
|
||||
data-native-chat-prompt-card-focus={shouldFocus || undefined}
|
||||
data-native-chat-approval-card="true"
|
||||
role="group"
|
||||
aria-label={approval.title}
|
||||
tabIndex={-1}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape' && !event.nativeEvent.isComposing && escape) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
escape()
|
||||
}
|
||||
}}
|
||||
className="flex min-h-0 w-full flex-1 flex-col gap-2 overflow-hidden rounded-lg border border-input bg-card px-4 py-3 shadow-xs focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<div className="flex shrink-0 items-start gap-2">
|
||||
<ShieldQuestion className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="line-clamp-2 break-words text-sm font-semibold text-foreground">
|
||||
{approval.title}
|
||||
</p>
|
||||
) : null}
|
||||
{neededPath ? (
|
||||
<p className="break-words">
|
||||
<span className="font-medium text-foreground">
|
||||
{translate('components.native-chat.approval.needsAccess', 'Needs access to')}
|
||||
:{' '}
|
||||
</span>
|
||||
<span className="font-mono">{neededPath}</span>
|
||||
</p>
|
||||
) : null}
|
||||
{isPlanApprovalSubject(approval.subject) ? (
|
||||
<div data-native-chat-approval-plan="true">
|
||||
<NativeChatMarkdown
|
||||
content={approval.subject.text}
|
||||
variant="document"
|
||||
className="text-sm text-chat-foreground"
|
||||
renderCodeBlock={NativeChatCodeBlock}
|
||||
{...(onLinkClick ? { onLinkClick } : {})}
|
||||
allowFileUriLinks={allowFileUriLinks}
|
||||
linkifyFilePaths={onLinkClick !== undefined}
|
||||
/>
|
||||
{approval.subject.filePath ? (
|
||||
<p className="mt-2 break-all">
|
||||
<span className="font-medium text-foreground">
|
||||
{translate('components.native-chat.approval.plan.file', 'Plan file')}:{' '}
|
||||
</span>
|
||||
<span className="font-mono">{approval.subject.filePath}</span>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : approval.detail ? (
|
||||
<div
|
||||
data-native-chat-approval-detail="true"
|
||||
data-native-chat-code-content
|
||||
className="whitespace-pre-wrap break-words font-mono text-chat-code"
|
||||
</div>
|
||||
{onCancel ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
aria-label={translate('components.native-chat.approval.cancel', 'Cancel')}
|
||||
className="flex size-6 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{approval.detail}
|
||||
</div>
|
||||
) : null}
|
||||
{newerSubject ? (
|
||||
<p data-native-chat-approval-needs-newer-orca="true" className="break-words">
|
||||
{translate(
|
||||
'components.native-chat.approval.needsNewerOrca',
|
||||
'This request needs a newer version of Orca.'
|
||||
)}
|
||||
</p>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
) : onCollapse ? (
|
||||
<NativeChatPromptCollapseToggle
|
||||
expanded
|
||||
disabled={isSubmitting}
|
||||
onToggle={onCollapse}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div data-native-chat-approval-actions="true" className="flex shrink-0 flex-wrap gap-2">
|
||||
{approval.options.map((opt, i) => (
|
||||
<button
|
||||
key={`${opt.label}-${i}`}
|
||||
type="button"
|
||||
disabled={newerSubject || isSubmitting}
|
||||
onClick={() => onChoose(opt.send)}
|
||||
className={cn(
|
||||
'rounded-md px-4 py-1.5 text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
|
||||
i === 0
|
||||
? 'bg-primary text-primary-foreground hover:bg-primary/90'
|
||||
: 'border border-border bg-background text-foreground hover:bg-accent'
|
||||
)}
|
||||
{hasContext ? (
|
||||
<div
|
||||
data-native-chat-approval-content="true"
|
||||
tabIndex={0}
|
||||
className="min-h-0 max-h-72 shrink space-y-2 overflow-auto text-sm text-muted-foreground scrollbar-sleek focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
{approval.description ? (
|
||||
<p className="whitespace-pre-wrap break-words">{approval.description}</p>
|
||||
) : null}
|
||||
{approval.decisionReason ? (
|
||||
<p className="whitespace-pre-wrap break-words">
|
||||
<span className="font-medium text-foreground">
|
||||
{translate('components.native-chat.approval.reason', 'Reason')}:{' '}
|
||||
</span>
|
||||
{approval.decisionReason}
|
||||
</p>
|
||||
) : null}
|
||||
{neededPath ? (
|
||||
<p className="break-words">
|
||||
<span className="font-medium text-foreground">
|
||||
{translate('components.native-chat.approval.needsAccess', 'Needs access to')}
|
||||
:{' '}
|
||||
</span>
|
||||
<span className="font-mono">{neededPath}</span>
|
||||
</p>
|
||||
) : null}
|
||||
{isPlanApprovalSubject(approval.subject) ? (
|
||||
<div data-native-chat-approval-plan="true">
|
||||
<NativeChatMarkdown
|
||||
content={approval.subject.text}
|
||||
variant="document"
|
||||
className="text-sm text-chat-foreground"
|
||||
renderCodeBlock={NativeChatCodeBlock}
|
||||
{...(onLinkClick ? { onLinkClick } : {})}
|
||||
allowFileUriLinks={allowFileUriLinks}
|
||||
linkifyFilePaths={onLinkClick !== undefined}
|
||||
/>
|
||||
{approval.subject.filePath ? (
|
||||
<p className="mt-2 break-all">
|
||||
<span className="font-medium text-foreground">
|
||||
{translate('components.native-chat.approval.plan.file', 'Plan file')}
|
||||
:{' '}
|
||||
</span>
|
||||
<span className="font-mono">{approval.subject.filePath}</span>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : approval.detail ? (
|
||||
<div
|
||||
data-native-chat-approval-detail="true"
|
||||
data-native-chat-code-content
|
||||
className="whitespace-pre-wrap break-words font-mono text-chat-code"
|
||||
>
|
||||
{approval.detail}
|
||||
</div>
|
||||
) : null}
|
||||
{newerSubject ? (
|
||||
<p data-native-chat-approval-needs-newer-orca="true" className="break-words">
|
||||
{translate(
|
||||
'components.native-chat.approval.needsNewerOrca',
|
||||
'This request needs a newer version of Orca.'
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div data-native-chat-approval-actions="true" className="flex shrink-0 flex-wrap gap-2">
|
||||
{approval.options.map((opt, i) => (
|
||||
<button
|
||||
key={`${opt.label}-${i}`}
|
||||
type="button"
|
||||
disabled={newerSubject || isSubmitting}
|
||||
onClick={() => onChoose(opt.send)}
|
||||
className={cn(
|
||||
'rounded-md px-4 py-1.5 text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
|
||||
i === 0
|
||||
? 'bg-primary text-primary-foreground hover:bg-primary/90'
|
||||
: 'border border-border bg-background text-foreground hover:bg-accent'
|
||||
)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SecuritySurface>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -134,7 +134,9 @@ export function PluginConsentDialog({
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className="plugin-security-chrome max-h-[calc(100vh-3rem)] overflow-y-auto scrollbar-sleek sm:max-w-xl"
|
||||
surface="security"
|
||||
scrollable
|
||||
className="sm:max-w-xl"
|
||||
onOpenAutoFocus={(event) => {
|
||||
// Why: dismissal is the safety-preserving path, so it receives initial focus.
|
||||
event.preventDefault()
|
||||
|
||||
@@ -79,7 +79,7 @@ export function PluginInstallDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(nextOpen) => !installing && onOpenChange(nextOpen)}>
|
||||
<DialogContent className="max-h-[calc(100vh-3rem)] overflow-y-auto scrollbar-sleek sm:max-w-lg">
|
||||
<DialogContent surface="security" scrollable className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{translate('auto.components.settings.PluginInstallDialog.title', 'Install plugin')}
|
||||
|
||||
@@ -152,7 +152,7 @@ export function PluginMarketplacePreviewDialog({
|
||||
: undefined
|
||||
return (
|
||||
<Dialog open={Boolean(preview)} onOpenChange={(open) => !open && !busy && onClose()}>
|
||||
<DialogContent className="plugin-security-chrome max-h-[calc(100vh-3rem)] overflow-y-auto scrollbar-sleek sm:max-w-xl">
|
||||
<DialogContent surface="security" scrollable className="sm:max-w-xl">
|
||||
{preview ? (
|
||||
<>
|
||||
<DialogHeader>
|
||||
|
||||
@@ -121,7 +121,9 @@ export function PluginMarketplaceSourceDialog({
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(nextOpen) => !busyAction && onOpenChange(nextOpen)}>
|
||||
<DialogContent
|
||||
className="plugin-security-chrome max-h-[calc(100vh-3rem)] overflow-y-auto scrollbar-sleek sm:max-w-xl"
|
||||
surface="security"
|
||||
scrollable
|
||||
className="sm:max-w-xl"
|
||||
onOpenAutoFocus={(event) => {
|
||||
event.preventDefault()
|
||||
urlRef.current?.focus()
|
||||
|
||||
@@ -29,6 +29,7 @@ export function PluginRemoveDialog({
|
||||
return (
|
||||
<Dialog open={Boolean(plugin)} onOpenChange={(open) => !open && !busy && onCancel()}>
|
||||
<DialogContent
|
||||
surface="security"
|
||||
onOpenAutoFocus={(event) => {
|
||||
event.preventDefault()
|
||||
cancelRef.current?.focus()
|
||||
|
||||
@@ -31,6 +31,7 @@ export function PluginRollbackDialog({
|
||||
return (
|
||||
<Dialog open={Boolean(plugin)} onOpenChange={(open) => !open && !busy && onCancel()}>
|
||||
<DialogContent
|
||||
surface="security"
|
||||
onOpenAutoFocus={(event) => {
|
||||
event.preventDefault()
|
||||
cancelRef.current?.focus()
|
||||
|
||||
@@ -104,7 +104,7 @@ const OrcaYamlTrustDialog = React.memo(function OrcaYamlTrustDialog() {
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-w-md sm:max-w-md" showCloseButton={false}>
|
||||
<DialogContent surface="security" className="max-w-md sm:max-w-md" showCloseButton={false}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-sm">
|
||||
{previouslyApproved
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { SECURITY_SURFACE_CLASS } from './security-surface'
|
||||
import * as React from 'react'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { Minus, XIcon } from 'lucide-react'
|
||||
@@ -54,7 +55,9 @@ const dialogContentSurfaceVariants = cva('', {
|
||||
'feature-tip':
|
||||
'gap-0 bg-[color-mix(in_srgb,var(--foreground)_8%,var(--background))] p-0 dark:bg-[color-mix(in_srgb,var(--foreground)_16%,var(--background))]',
|
||||
/** Edge to edge, for an embedded panel that brings its own header and padding. */
|
||||
panel: 'gap-0 p-0'
|
||||
panel: 'gap-0 p-0',
|
||||
/** Trust, consent and provenance decisions: host-owned tokens a plugin theme cannot restyle. */
|
||||
security: SECURITY_SURFACE_CLASS
|
||||
}
|
||||
},
|
||||
defaultVariants: { surface: 'default' }
|
||||
@@ -94,6 +97,7 @@ function DialogContent({
|
||||
closeButtonVariant = 'close',
|
||||
surface,
|
||||
size,
|
||||
scrollable = false,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> &
|
||||
VariantProps<typeof dialogContentSurfaceVariants> & {
|
||||
@@ -102,6 +106,8 @@ function DialogContent({
|
||||
closeButtonVariant?: 'close' | 'minimize'
|
||||
/** `compact` is a short confirmation's width, like the close prompts. */
|
||||
size?: 'default' | 'compact'
|
||||
/** The whole dialog scrolls within the viewport instead of growing past it. */
|
||||
scrollable?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
@@ -120,6 +126,7 @@ function DialogContent({
|
||||
className={cn(
|
||||
'fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] grid-cols-[minmax(0,1fr)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border border-black/14 bg-background/96 p-6 text-foreground shadow-[0_20px_60px_rgba(0,0,0,0.28),inset_0_1px_0_rgba(255,255,255,0.08)] backdrop-blur-2xl duration-200 outline-none dark:border-white/14 dark:bg-[rgba(23,23,23,0.96)] dark:shadow-[0_24px_72px_rgba(0,0,0,0.55),inset_0_1px_0_rgba(255,255,255,0.06)] data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg data-[size=compact]:sm:max-w-sm',
|
||||
dialogContentSurfaceVariants({ surface }),
|
||||
scrollable && 'max-h-[calc(100vh-3rem)] overflow-y-auto scrollbar-sleek',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogTitle } from './dialog'
|
||||
import { SecuritySurface } from './security-surface'
|
||||
|
||||
// Every dialog that asks the user to trust, approve, run or remove code. A new one belongs here.
|
||||
const TRUST_DIALOGS = [
|
||||
'sidebar/OrcaYamlTrustDialog.tsx',
|
||||
'editor/IpynbViewer.tsx',
|
||||
'settings/PluginConsentDialog.tsx',
|
||||
'settings/PluginInstallDialog.tsx',
|
||||
'settings/PluginMarketplacePreviewDialog.tsx',
|
||||
'settings/PluginMarketplaceSourceDialog.tsx',
|
||||
'settings/PluginRemoveDialog.tsx',
|
||||
'settings/PluginRollbackDialog.tsx'
|
||||
]
|
||||
|
||||
// Trust decisions made outside a dialog: agent command approvals and certificate overrides.
|
||||
const INLINE_TRUST_SURFACES = [
|
||||
'native-chat/NativeChatApprovalCard.tsx',
|
||||
'browser-pane/navigate/browser-load-failure-overlay.tsx'
|
||||
]
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('security surfaces', () => {
|
||||
it('applies the host-owned token set', () => {
|
||||
render(
|
||||
<Dialog open>
|
||||
<DialogContent surface="security">
|
||||
<DialogTitle>Trust</DialogTitle>
|
||||
<DialogDescription>Review</DialogDescription>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
expect(document.querySelector('[role="dialog"]')?.classList).toContain('plugin-security-chrome')
|
||||
})
|
||||
|
||||
it.each(TRUST_DIALOGS)('%s renders on the security surface', (file) => {
|
||||
const source = readFileSync(join(import.meta.dirname, '..', file), 'utf8')
|
||||
expect(source).toMatch(/<DialogContent\s[^>]*surface="security"/)
|
||||
expect(source).not.toContain('plugin-security-chrome')
|
||||
})
|
||||
|
||||
it('applies the host-owned token set outside dialogs', () => {
|
||||
const { container } = render(
|
||||
<SecuritySurface>
|
||||
<div>Allow</div>
|
||||
</SecuritySurface>
|
||||
)
|
||||
expect(container.firstElementChild?.classList).toContain('plugin-security-chrome')
|
||||
})
|
||||
|
||||
it.each(INLINE_TRUST_SURFACES)('%s renders inside a security surface', (file) => {
|
||||
const source = readFileSync(join(import.meta.dirname, '..', file), 'utf8')
|
||||
expect(source).toMatch(/<SecuritySurface[\s>]/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
import type React from 'react'
|
||||
import * as Slot from 'radix-ui/slot'
|
||||
|
||||
/** Host-owned tokens a plugin theme cannot restyle; see `.plugin-security-chrome` in main.css. */
|
||||
export const SECURITY_SURFACE_CLASS = 'plugin-security-chrome'
|
||||
|
||||
/** Puts its one child element on the host-owned token surface. For trust decisions outside a
|
||||
* dialog (an agent approval, a certificate override); dialogs use `surface="security"`. */
|
||||
export function SecuritySurface({ children }: { children: React.ReactElement }): React.JSX.Element {
|
||||
return (
|
||||
<Slot.Root data-slot="security-surface" className={SECURITY_SURFACE_CLASS}>
|
||||
{children}
|
||||
</Slot.Root>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user