Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions frontend/src/app/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,17 @@ const createAppRouter = () =>
}
}
},
{
path: 'extension-auth',
async lazy() {
const { Component } = await import(
'@/app/routes/extension-auth'
)
return {
Component: () => <Component />
}
}
},
{
path: ':sessionId',
async lazy() {
Expand Down
220 changes: 220 additions & 0 deletions frontend/src/app/routes/extension-auth.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useSearchParams } from 'react-router'

import { ACCESS_TOKEN } from '@/constants/auth'
import { Button } from '@/components/ui/button'
import { useAuth } from '@/contexts/auth-context'

type Status = 'loading' | 'needs-consent' | 'sending' | 'done' | 'error'

const EXTENSION_MESSAGE_TYPE = 'ii-extension-auth'

export function ExtensionAuthPage() {
const [searchParams] = useSearchParams()
const { user, isAuthenticated, isLoading } = useAuth()
const [status, setStatus] = useState<Status>('loading')
const [error, setError] = useState<string | null>(null)

const extId = searchParams.get('ext_id') || ''
const nonce = searchParams.get('nonce') || ''
const browserVendor = searchParams.get('browser') || ''

// The extension sends its own origin so the frontend can postMessage back
// specifically to the extension context listening in a content script.
// We keep it opaque (the extension is responsible for scoping).
const returnUrl = useMemo(() => {
// Extensions in Chromium use chrome-extension://<id>/... in `window.location.href`
// but the extension's content script injected into THIS page will listen
// on window.postMessage, so we always postMessage to our own origin.
return window.location.origin
}, [])

const postTokenToExtension = useCallback(
(token: string) => {
// postMessage bounces through a content script the extension injects
// into this frontend origin. Both the content script and this page
// are same-origin, so `window.postMessage` with targetOrigin=own
// origin is the safe path. The content script verifies the nonce
// matches what the extension sent, then relays to the background.
window.postMessage(
{
type: EXTENSION_MESSAGE_TYPE,
nonce,
ext_id: extId,
payload: {
access_token: token,
token_type: 'bearer',
},
},
returnUrl,
)
},
[nonce, extId, returnUrl],
)

// Validate required params.
useEffect(() => {
if (!extId || !nonce) {
setError(
'Missing extension parameters. Please start the sign-in again from the extension.',
)
setStatus('error')
}
}, [extId, nonce])

// Redirect to login if not authenticated.
useEffect(() => {
if (status === 'error') return
if (isLoading) return
if (isAuthenticated) {
setStatus('needs-consent')
return
}

const here = `${window.location.pathname}${window.location.search}`
const loginUrl = `/login?return_to=${encodeURIComponent(here)}`
window.location.replace(loginUrl)
}, [isLoading, isAuthenticated, status])

const handleAllow = useCallback(() => {
const token = localStorage.getItem(ACCESS_TOKEN)
if (!token) {
setError('No access token found in this browser. Please sign in again.')
setStatus('error')
return
}

setStatus('sending')
postTokenToExtension(token)

// Poll for the extension's ack so the user sees "Done" after the
// content script confirms delivery. If no ack in 2s, still show done —
// the extension content script is responsible for closing this tab.
const timer = window.setTimeout(() => setStatus('done'), 1500)
const onAck = (event: MessageEvent) => {
if (event.origin !== window.location.origin) return
const data = event.data as { type?: string; nonce?: string }
if (data?.type !== 'ii-extension-auth-ack' || data?.nonce !== nonce) return
window.clearTimeout(timer)
window.removeEventListener('message', onAck)
setStatus('done')
}
window.addEventListener('message', onAck)
}, [postTokenToExtension, nonce])

const handleDeny = useCallback(() => {
window.postMessage(
{ type: `${EXTENSION_MESSAGE_TYPE}-denied`, nonce, ext_id: extId },
returnUrl,
)
window.setTimeout(() => window.close(), 300)
}, [nonce, extId, returnUrl])

if (status === 'loading' || isLoading) {
return <CenterBox><Spinner /></CenterBox>
}

if (status === 'error') {
return (
<CenterBox>
<h1 className="text-xl font-semibold mb-2">Sign-in error</h1>
<p className="text-sm text-pewter dark:text-grey-4">{error}</p>
</CenterBox>
)
}

if (status === 'done') {
return (
<CenterBox>
<h1 className="text-xl font-semibold mb-2">You can close this tab</h1>
<p className="text-sm text-pewter dark:text-grey-4">
The II-Agent extension is now signed in.
</p>
</CenterBox>
)
}

const displayName = user?.first_name
? `${user.first_name} ${user.last_name ?? ''}`.trim()
: user?.email || 'your account'

return (
<div className="flex items-center justify-center min-h-screen px-4">
<div className="bg-white dark:bg-firefly rounded-2xl shadow-xl p-8 max-w-md w-full">
<div className="flex items-center justify-center gap-4 mb-8">
<img
src="/images/logo-only.png"
alt="II-Agent"
className="size-12 rounded-xl"
/>
<span className="text-gray-400 text-2xl">···</span>
<div className="size-12 rounded-xl bg-sky-blue-4/20 dark:bg-sky-blue/10 flex items-center justify-center">
<span className="text-2xl">🧩</span>
</div>
</div>

<h1 className="text-xl font-semibold text-center text-gray-900 dark:text-white mb-2">
Allow the II-Agent {browserVendor ? browserVendor : 'browser'} extension to sign in?
</h1>

<p className="text-center text-pewter dark:text-grey-4 mb-6">
Signed in as <span className="font-medium">{displayName}</span>
</p>

<div className="mb-6 space-y-2">
<Line text="Use the extension as your signed-in account" />
<Line text="Access chat sessions you create in the extension" />
<Line text="You can sign out from the extension at any time" />
</div>

<div className="flex gap-3">
<Button
variant="outline"
className="flex-1"
onClick={handleDeny}
disabled={status === 'sending'}
>
Deny
</Button>
<Button
className="flex-1 bg-sky-blue text-black"
onClick={handleAllow}
disabled={status === 'sending'}
>
{status === 'sending' ? 'Sending…' : 'Allow'}
</Button>
</div>

<p className="text-[11px] text-center text-pewter dark:text-grey-4 mt-6">
The extension ID requesting access is{' '}
<code className="font-mono break-all">{extId}</code>.
</p>
</div>
</div>
)
}

function CenterBox({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center justify-center min-h-screen px-4 text-center">
<div className="max-w-md">{children}</div>
</div>
)
}

function Spinner() {
return (
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900 dark:border-white mx-auto" />
)
}

function Line({ text }: { text: string }) {
return (
<div className="flex items-center gap-3 text-sm text-gray-700 dark:text-gray-300">
<span className="text-green-500">✓</span>
<span>{text}</span>
</div>
)
}

export const Component = ExtensionAuthPage
23 changes: 20 additions & 3 deletions frontend/src/app/routes/login.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useGoogleLogin } from '@react-oauth/google'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { Link, useNavigate } from 'react-router'
import { Link, useNavigate, useSearchParams } from 'react-router'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { zodResolver } from '@hookform/resolvers/zod'
Expand Down Expand Up @@ -30,10 +30,27 @@ type IiAuthPayload = {
export function LoginPage() {
const { t } = useTranslation()
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const { loginWithAuthCode } = useAuth()
const dispatch = useAppDispatch()
const isSage = useIsSageTheme()

// `return_to` is a relative path on this origin we should send the user
// back to after a successful login. Used by /extension-auth and
// /oauth-consent so protected flows survive a login bounce.
const safeReturnTo = useMemo(() => {
const raw = searchParams.get('return_to')
if (!raw) return '/'
try {
// Block absolute URLs / protocol-relative paths — only allow
// in-app paths to avoid open-redirect abuse.
if (!raw.startsWith('/') || raw.startsWith('//')) return '/'
return raw
} catch {
return '/'
}
}, [searchParams])

const FormSchema = useMemo(
() =>
z.object({
Expand Down Expand Up @@ -64,7 +81,7 @@ export function LoginPage() {
onSuccess: async (codeResponse) => {
try {
await loginWithAuthCode(codeResponse.code)
navigate('/')
navigate(safeReturnTo)
} catch (error: unknown) {
const apiError = error as {
response: { data: { detail: string } }
Expand Down Expand Up @@ -121,7 +138,7 @@ export function LoginPage() {
dispatch(fetchWishlist())
dispatch(fetchPins())

navigate('/')
navigate(safeReturnTo)
} catch (error) {
console.error('Failed to finalize II login:', error)
authHandledRef.current = false
Expand Down
1 change: 1 addition & 0 deletions src/ii_agent/agents/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ class AgentType(StrEnum):
FAST_RESEARCH = "fast_research"
RESEARCH_TO_WEBSITE = "research_to_website"
MOBILE_APP = "mobile_app"
BROWSER_EXTENSION = "browser_extension"


__all__ = ["AgentType"]
7 changes: 7 additions & 0 deletions src/ii_agent/clients/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
"""Per-client agent factories and shared building blocks.

Each subpackage under ``clients/`` (e.g. ``browser_extension``) wires the
standard ii-agent runtime to a specific external client. The modules
sitting directly under this package are generic helpers reused across all
clients — keep client-specific quirks inside the subpackage.
"""
22 changes: 22 additions & 0 deletions src/ii_agent/clients/browser_extension/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
"""Browser-extension client for the ii-browser Chrome extension.

Sub-package of :mod:`ii_agent.clients` so it ships with the standard
distribution. The pieces this package owns are:

* :mod:`ii_agent.clients.browser_extension.factory` — builds an
:class:`IIAgent` with extension-side overrides for system prompt and
tool/skill subset, delegating capability assembly to the shared
:mod:`ii_agent.clients.proxy_capabilities` helpers.

The Pydantic content schemas (``BrowserExtensionCommandContent`` /
``BrowserExtensionContinueRunContent``) live in
:mod:`ii_agent.realtime.schemas` next to every other ``CommandContent``
variant. The Socket.IO handlers live in
:mod:`ii_agent.realtime.handlers.browser_extension_query` and
:mod:`ii_agent.realtime.handlers.browser_extension_continue_run`, and are
registered by ``ii_agent.realtime.handlers.factory.CommandHandlerFactory``.
"""

from ii_agent.clients.browser_extension.factory import browser_extension_agent_factory

__all__ = ["browser_extension_agent_factory"]
46 changes: 46 additions & 0 deletions src/ii_agent/clients/browser_extension/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"""Browser-extension defaults.

The wire payload's ``core_tools`` / ``core_skills`` / ``connector`` IS
the user's request and is taken as-is. These ``BROWSER_EXTENSION_*``
sets are the **fallback defaults** the factory uses only when the
request leaves the matching key empty/null. They keep a sane baseline
for silent requests; populate them deliberately as needed.
"""

from __future__ import annotations

#: Fallback for ``requested_capabilities.core_tools`` (names from
#: :data:`TOOL_CLASS_MAP`) when the request doesn't specify any.
BROWSER_EXTENSION_DEFAULT_CORE_TOOLS: set[str] = set()

#: Fallback for ``requested_capabilities.core_skills`` (names from the
#: user's persisted ``SkillTool`` registry) when the request doesn't
#: specify any.
BROWSER_EXTENSION_DEFAULT_CORE_SKILLS: set[str] = set()

#: Fallback for ``requested_capabilities.connector`` (e.g. ``"github"``,
#: ``"google_drive"``) when the request doesn't specify one. The
#: connector still requires a wired ``connector_tool`` to instantiate.
BROWSER_EXTENSION_DEFAULT_CONNECTORS: set[str] = set()

#: Fallback system prompt — used only when the request doesn't ship its own.
DEFAULT_SYSTEM_PROMPT = (
"You are an in-browser AI assistant in the ii-browser Chrome extension. "
"Interact with the current page and selected text via available tools. "
"Call tools to read or act on page data; never guess missing information. "
"After each tool call, stop and wait for the result before continuing."
)

#: Heading for the client-defined skill catalog appended to the system prompt.
CLIENT_SKILL_HEADING = "Skills available in the ii-browser extension runtime:"

#: Tag used by the shared capability helpers when emitting warnings.
LOG_PREFIX = "browser_extension"

# Recognised keys inside ``requested_capabilities.client_prompt`` for the
# browser-extension client. Kept here (and not in :mod:`proxy_capabilities`)
# because the proxy layer is intentionally agnostic about which prompt
# fragments any particular client ships — each client subpackage decides
# what it understands.
CLIENT_PROMPT_MODE_KEY = "mode"
CLIENT_PROMPT_HEADING = "Capability mode for this turn:"
Loading