feat(ext): ask user approval for MCP task
This commit is contained in:
1
package-lock.json
generated
1
package-lock.json
generated
@@ -11832,7 +11832,6 @@
|
|||||||
"@radix-ui/react-separator": "^1.1.8",
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-switch": "^1.2.6",
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
|
||||||
"@types/chrome": "^0.1.37",
|
"@types/chrome": "^0.1.37",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.1",
|
"@types/react-dom": "^19.2.1",
|
||||||
|
|||||||
@@ -16,7 +16,6 @@
|
|||||||
"@radix-ui/react-separator": "^1.1.8",
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-switch": "^1.2.6",
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
|
||||||
"@types/chrome": "^0.1.37",
|
"@types/chrome": "^0.1.37",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.1",
|
"@types/react-dom": "^19.2.1",
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import {
|
import {
|
||||||
ChevronDown,
|
|
||||||
Copy,
|
Copy,
|
||||||
CornerUpLeft,
|
CornerUpLeft,
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
|
FoldVertical,
|
||||||
HatGlasses,
|
HatGlasses,
|
||||||
Home,
|
Home,
|
||||||
Loader2,
|
Loader2,
|
||||||
Scale,
|
Scale,
|
||||||
|
UnfoldVertical,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { siGithub } from 'simple-icons'
|
import { siGithub } from 'simple-icons'
|
||||||
@@ -153,6 +154,16 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Hub link */}
|
||||||
|
<a
|
||||||
|
href="/hub.html"
|
||||||
|
target="_blank"
|
||||||
|
className="flex items-center justify-between p-3 rounded-md border bg-muted/50 text-xs font-medium text-muted-foreground hover:text-foreground hover:border-foreground/20 transition-colors"
|
||||||
|
>
|
||||||
|
Manage Page Agent Hub config
|
||||||
|
<CornerUpLeft className="size-3 rotate-180" />
|
||||||
|
</a>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<label className="text-xs text-muted-foreground">Base URL</label>
|
<label className="text-xs text-muted-foreground">Base URL</label>
|
||||||
<Input
|
<Input
|
||||||
@@ -231,10 +242,7 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
|
|||||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground cursor-pointer mt-1 font-bold"
|
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground cursor-pointer mt-1 font-bold"
|
||||||
>
|
>
|
||||||
Advanced
|
Advanced
|
||||||
<ChevronDown
|
{advancedOpen ? <FoldVertical className="size-3" /> : <UnfoldVertical className="size-3" />}
|
||||||
className="size-3 transition-transform"
|
|
||||||
style={{ transform: advancedOpen ? 'rotate(0deg)' : 'rotate(90deg)' }}
|
|
||||||
/>
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{advancedOpen && (
|
{advancedOpen && (
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ function Switch({ className, ...props }: React.ComponentProps<typeof SwitchPrimi
|
|||||||
<SwitchPrimitive.Root
|
<SwitchPrimitive.Root
|
||||||
data-slot="switch"
|
data-slot="switch"
|
||||||
className={cn(
|
className={cn(
|
||||||
'peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
|
'peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 cursor-pointer items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Plug, PlugZap, Square, Unplug } from 'lucide-react'
|
import { FoldVertical, Plug, PlugZap, Square, UnfoldVertical, Unplug } from 'lucide-react'
|
||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
import { useAgent } from '@/agent/useAgent'
|
import { useAgent } from '@/agent/useAgent'
|
||||||
import { ActivityCard, EventCard } from '@/components/cards'
|
import { ActivityCard, EventCard } from '@/components/cards'
|
||||||
import { Logo, MotionOverlay, StatusDot } from '@/components/misc'
|
import { Logo, MotionOverlay, StatusDot } from '@/components/misc'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Switch } from '@/components/ui/switch'
|
||||||
|
|
||||||
import { useHubWs } from './hub-ws'
|
import { useHubWs } from './hub-ws'
|
||||||
|
|
||||||
@@ -25,7 +26,7 @@ export default function App() {
|
|||||||
const wsLabel = {
|
const wsLabel = {
|
||||||
connected: 'Connected',
|
connected: 'Connected',
|
||||||
connecting: 'Connecting…',
|
connecting: 'Connecting…',
|
||||||
disconnected: new URLSearchParams(location.search).get('ws') ? 'Disconnected' : 'Standalone',
|
disconnected: new URLSearchParams(location.search).get('ws') ? 'Disconnected' : 'No connection',
|
||||||
}[wsState]
|
}[wsState]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -34,24 +35,63 @@ export default function App() {
|
|||||||
|
|
||||||
{/* Left — Protocol docs */}
|
{/* Left — Protocol docs */}
|
||||||
<aside className="w-80 shrink-0 border-r flex flex-col bg-muted/20">
|
<aside className="w-80 shrink-0 border-r flex flex-col bg-muted/20">
|
||||||
<div className="flex items-center gap-2 px-5 py-4 border-b">
|
<a
|
||||||
|
href="https://alibaba.github.io/page-agent/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-2 px-5 h-12 border-b hover:bg-muted/30 transition-colors"
|
||||||
|
>
|
||||||
<Logo className="size-5" />
|
<Logo className="size-5" />
|
||||||
<span className="text-sm font-semibold tracking-tight">Page Agent Hub</span>
|
<span className="text-sm font-semibold tracking-tight">Page Agent Hub</span>
|
||||||
|
<span className="text-[9px] font-medium uppercase tracking-wider text-amber-600 bg-amber-500/10 border border-amber-500/30 rounded px-1.5 py-0.5">
|
||||||
|
Beta
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-6">
|
||||||
|
<div className="text-xs text-muted-foreground leading-relaxed space-y-2">
|
||||||
|
<p>
|
||||||
|
Page Agent Hub lets local apps (e.g. MCP servers) control the Page Agent extension via
|
||||||
|
WebSocket.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Check out the official{' '}
|
||||||
|
<a
|
||||||
|
href="https://github.com/alibaba/page-agent/tree/main/packages/mcp"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="underline hover:text-foreground"
|
||||||
|
>
|
||||||
|
MCP server package
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<HubConfig />
|
||||||
|
|
||||||
|
<ProtocolDocsCollapsible />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto px-5 py-4">
|
<div className="border-t px-5 py-3 text-[10px] text-muted-foreground/60 flex items-center justify-between">
|
||||||
<ProtocolDocs />
|
<span className="font-mono">v{__VERSION__}</span>
|
||||||
</div>
|
<span>
|
||||||
|
Built with ♥️ by{' '}
|
||||||
<div className="border-t px-5 py-3 text-[11px] text-muted-foreground/60">
|
<a
|
||||||
Connect via <code className="text-[10px]">hub.html?ws=PORT</code>
|
href="https://github.com/gaomeng1900"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="underline hover:text-foreground"
|
||||||
|
>
|
||||||
|
@Simon
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* Right — Live session */}
|
{/* Right — Live session */}
|
||||||
<main className="flex-1 flex flex-col min-w-0">
|
<main className="flex-1 flex flex-col min-w-0">
|
||||||
{/* Header bar */}
|
<header className="flex items-center justify-between border-b px-5 h-12">
|
||||||
<header className="flex items-center justify-between border-b px-5 py-3">
|
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
<WsIcon className="size-3.5" />
|
<WsIcon className="size-3.5" />
|
||||||
<span>{wsLabel}</span>
|
<span>{wsLabel}</span>
|
||||||
@@ -104,49 +144,108 @@ export default function App() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProtocolDocs() {
|
function HubConfig() {
|
||||||
|
const [allowAll, setAllowAll] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
chrome.storage.local.get('allowAllHubConnection').then((r) => {
|
||||||
|
setAllowAll(r.allowAllHubConnection === true)
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const toggle = (checked: boolean) => {
|
||||||
|
setAllowAll(checked)
|
||||||
|
chrome.storage.local.set({ allowAllHubConnection: checked })
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5 text-xs text-muted-foreground">
|
<div>
|
||||||
<section>
|
<h3 className="text-[11px] font-semibold text-foreground/80 uppercase tracking-wider mb-2">
|
||||||
<h3 className="text-[11px] font-semibold text-foreground/80 uppercase tracking-wider mb-2">
|
Config
|
||||||
Caller → Hub
|
</h3>
|
||||||
</h3>
|
<div className="group/hub relative">
|
||||||
<pre className="bg-muted/50 rounded-md p-3 font-mono text-[10px] leading-relaxed whitespace-pre-wrap">
|
<label
|
||||||
{`{ type: "execute", task: string, config?: object }
|
className={`flex items-center justify-between p-3 rounded-md border cursor-pointer text-xs ${allowAll ? 'bg-amber-500/10 border-amber-500/30 text-amber-600' : 'bg-muted/50 text-muted-foreground'}`}
|
||||||
{ type: "stop" }`}
|
>
|
||||||
</pre>
|
Auto-approve connections
|
||||||
</section>
|
<Switch
|
||||||
|
checked={allowAll}
|
||||||
|
onCheckedChange={toggle}
|
||||||
|
className={allowAll ? 'data-[state=checked]:bg-amber-500' : ''}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
<section>
|
{/* hide with invisible absolute opacity-0*/}
|
||||||
<h3 className="text-[11px] font-semibold text-foreground/80 uppercase tracking-wider mb-2">
|
<div className="group-hover/hub:visible group-hover/hub:opacity-100 transition-opacity duration-150 left-0 right-0 top-full z-10 pt-2">
|
||||||
Hub → Caller
|
<div className="relative p-2.5 rounded-md border border-border bg-background/60 backdrop-blur-md shadow-2xl text-muted-foreground text-xs leading-relaxed">
|
||||||
</h3>
|
<div className="absolute -top-1.5 left-5 size-3 rotate-45 rounded-[1px] border-l border-t border-border bg-background/60 backdrop-blur-md" />
|
||||||
<pre className="bg-muted/50 rounded-md p-3 font-mono text-[10px] leading-relaxed whitespace-pre-wrap">
|
By default, each connection requires your approval before running tasks. <br />
|
||||||
{`{ type: "ready" }
|
Enable this to skip per-session approval.
|
||||||
{ type: "result", success: boolean, data: string }
|
<br />
|
||||||
{ type: "error", message: string }`}
|
<span className="font-semibold">* Use with caution!</span>
|
||||||
</pre>
|
</div>
|
||||||
</section>
|
</div>
|
||||||
|
</div>
|
||||||
<section>
|
</div>
|
||||||
<h3 className="text-[11px] font-semibold text-foreground/80 uppercase tracking-wider mb-2">
|
)
|
||||||
Flow
|
}
|
||||||
</h3>
|
|
||||||
<ol className="list-decimal list-inside space-y-1 text-[11px] leading-relaxed">
|
function ProtocolDocsCollapsible() {
|
||||||
<li>Hub opens WS to caller's server</li>
|
const [open, setOpen] = useState(false)
|
||||||
<li>
|
|
||||||
Sends <code className="text-[10px]">ready</code>
|
return (
|
||||||
</li>
|
<div>
|
||||||
<li>
|
<button
|
||||||
Caller sends <code className="text-[10px]">execute</code> with task
|
type="button"
|
||||||
</li>
|
onClick={() => setOpen(!open)}
|
||||||
<li>Hub runs agent, streams events</li>
|
className="flex items-center gap-1 text-[11px] font-semibold text-foreground/80 uppercase tracking-wider cursor-pointer"
|
||||||
<li>
|
>
|
||||||
Hub sends <code className="text-[10px]">result</code> or{' '}
|
Docs
|
||||||
<code className="text-[10px]">error</code>
|
{open ? <FoldVertical className="size-3" /> : <UnfoldVertical className="size-3" />}
|
||||||
</li>
|
</button>
|
||||||
</ol>
|
|
||||||
</section>
|
{open && (
|
||||||
|
<div className="mt-3 space-y-4 text-xs text-muted-foreground">
|
||||||
|
<p className="text-[10px]">
|
||||||
|
Connect via <code className="text-[10px]">hub.html?ws=PORT</code>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h4 className="text-[11px] font-medium text-foreground/60 mb-1.5">Flow</h4>
|
||||||
|
<ol className="list-decimal list-inside space-y-1 text-[11px] leading-relaxed">
|
||||||
|
<li>Hub opens WS to caller's server</li>
|
||||||
|
<li>
|
||||||
|
Sends <code className="text-[10px]">ready</code>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Caller sends <code className="text-[10px]">execute</code> with task
|
||||||
|
</li>
|
||||||
|
<li>Hub runs agent, streams events</li>
|
||||||
|
<li>
|
||||||
|
Hub sends <code className="text-[10px]">result</code> or{' '}
|
||||||
|
<code className="text-[10px]">error</code>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h4 className="text-[11px] font-medium text-foreground/60 mb-1.5">Caller → Hub</h4>
|
||||||
|
<pre className="bg-muted/50 rounded-md p-3 font-mono text-[10px] leading-relaxed whitespace-pre-wrap">
|
||||||
|
{`{ type: "execute", task: string, config?: object }
|
||||||
|
{ type: "stop" }`}
|
||||||
|
</pre>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h4 className="text-[11px] font-medium text-foreground/60 mb-1.5">Hub → Caller</h4>
|
||||||
|
<pre className="bg-muted/50 rounded-md p-3 font-mono text-[10px] leading-relaxed whitespace-pre-wrap">
|
||||||
|
{`{ type: "ready" }
|
||||||
|
{ type: "result", success: boolean, data: string }
|
||||||
|
{ type: "error", message: string }`}
|
||||||
|
</pre>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ export class HubWs {
|
|||||||
#ws: WebSocket | null = null
|
#ws: WebSocket | null = null
|
||||||
#state: HubWsState = 'disconnected'
|
#state: HubWsState = 'disconnected'
|
||||||
#busy = false
|
#busy = false
|
||||||
|
#approved = false
|
||||||
#handlers: HubWsHandlers
|
#handlers: HubWsHandlers
|
||||||
#port: number
|
#port: number
|
||||||
#onStateChange: (state: HubWsState) => void
|
#onStateChange: (state: HubWsState) => void
|
||||||
@@ -103,6 +104,7 @@ export class HubWs {
|
|||||||
ws.addEventListener('close', () => {
|
ws.addEventListener('close', () => {
|
||||||
this.#ws = null
|
this.#ws = null
|
||||||
this.#busy = false
|
this.#busy = false
|
||||||
|
this.#approved = false
|
||||||
this.#setState('disconnected')
|
this.#setState('disconnected')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -115,6 +117,7 @@ export class HubWs {
|
|||||||
this.#ws?.close()
|
this.#ws?.close()
|
||||||
this.#ws = null
|
this.#ws = null
|
||||||
this.#busy = false
|
this.#busy = false
|
||||||
|
this.#approved = false
|
||||||
this.#setState('disconnected')
|
this.#setState('disconnected')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,7 +133,7 @@ export class HubWs {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#handleMessage(raw: string) {
|
async #handleMessage(raw: string) {
|
||||||
let msg: InboundMessage
|
let msg: InboundMessage
|
||||||
try {
|
try {
|
||||||
msg = JSON.parse(raw)
|
msg = JSON.parse(raw)
|
||||||
@@ -138,6 +141,11 @@ export class HubWs {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!(await this.#checkApproval())) {
|
||||||
|
this.#send({ type: 'error', message: 'User denied the connection request.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
switch (msg.type) {
|
switch (msg.type) {
|
||||||
case 'execute':
|
case 'execute':
|
||||||
this.#handleExecute(msg)
|
this.#handleExecute(msg)
|
||||||
@@ -148,6 +156,22 @@ export class HubWs {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async #checkApproval(): Promise<boolean> {
|
||||||
|
if (this.#approved) return true
|
||||||
|
|
||||||
|
const { allowAllHubConnection } = await chrome.storage.local.get('allowAllHubConnection')
|
||||||
|
if (allowAllHubConnection === true) {
|
||||||
|
this.#approved = true
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = window.confirm(
|
||||||
|
'An external application is requesting to control your browser via Page Agent Ext.\nAllow this session?'
|
||||||
|
)
|
||||||
|
if (ok) this.#approved = true
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
async #handleExecute(msg: ExecuteMessage) {
|
async #handleExecute(msg: ExecuteMessage) {
|
||||||
if (this.#busy) {
|
if (this.#busy) {
|
||||||
this.#send({ type: 'error', message: 'Hub is busy with another task' })
|
this.#send({ type: 'error', message: 'Hub is busy with another task' })
|
||||||
|
|||||||
Reference in New Issue
Block a user