diff --git a/agentex-ui/DOCKER.md b/agentex-ui/DOCKER.md index bdfbe4e9..ff9c2b57 100644 --- a/agentex-ui/DOCKER.md +++ b/agentex-ui/DOCKER.md @@ -91,13 +91,13 @@ The application runs with the following default environment variables: - `PORT=3000` - `HOSTNAME=0.0.0.0` -To configure public runtime variables, pass them when running the container: +To configure runtime variables, pass them when running the container: ```bash # Explicit env flags docker run --rm -p 3000:3000 \ - -e NEXT_PUBLIC_AGENTEX_API_BASE_URL=http://localhost:5003 \ - -e NEXT_PUBLIC_SGP_APP_URL=https://egp.dashboard.scale.com \ + -e AGENTEX_API_URL=http://localhost:5003 \ + -e NEXT_PUBLIC_SGP_APP_URL=https://app.example.com \ agentex-ui:latest # Or via an env file diff --git a/agentex-ui/README.md b/agentex-ui/README.md index 8583cf1a..9eb53860 100644 --- a/agentex-ui/README.md +++ b/agentex-ui/README.md @@ -96,8 +96,8 @@ cp example.env.development .env.development Edit `.env.development` with your configuration: ```bash -# Backend API endpoint -NEXT_PUBLIC_AGENTEX_API_BASE_URL=http://localhost:5003 +# Backend API endpoint — server-only upstream for the /api/agentex BFF proxy +AGENTEX_API_URL=http://localhost:5003 ``` ### 3. Install Dependencies diff --git a/agentex-ui/app/api/_lib/bff.ts b/agentex-ui/app/api/_lib/bff.ts new file mode 100644 index 00000000..dd5f7d4e --- /dev/null +++ b/agentex-ui/app/api/_lib/bff.ts @@ -0,0 +1,42 @@ +/** Server-only platform API base for the BFF routes. Prefers SGP_API_URL, else the app URL. */ +export const SGP_BASE_URL = + process.env.SGP_API_URL ?? + (process.env.NEXT_PUBLIC_SGP_APP_URL + ? `${process.env.NEXT_PUBLIC_SGP_APP_URL}/api` + : undefined); + +/** Return the Cookie header with the named cookie removed (null if nothing remains). */ +function stripCookie(header: string | null, name: string): string | null { + if (!header) return null; + const kept = header + .split(';') + .map(c => c.trim()) + .filter(c => { + const eq = c.indexOf('='); + return (eq === -1 ? c : c.slice(0, eq)) !== name; + }); + return kept.length ? kept.join('; ') : null; +} + +/** + * Attach credentials to an upstream request's `headers` in place, so none reach client JS: + * forward `x-selected-account-id` (the upstream authorizes the account), drop any + * client-sent `authorization`, and forward cookies for the upstream's own auth — minus the + * account-scoped `_jwt` (access-profile) cookie. Cookie-auth backends prioritize `_jwt` over + * `x-selected-account-id`, so leaving it would pin the account to the one the user linked in + * with and ignore the selected account; identity still comes from `_identityJwt`. + */ +export async function applyBffCredentials( + req: Request, + headers: Headers +): Promise { + const accountId = req.headers.get('x-selected-account-id'); + if (accountId) headers.set('x-selected-account-id', accountId); + else headers.delete('x-selected-account-id'); + + headers.delete('authorization'); + + const cookie = stripCookie(req.headers.get('cookie'), '_jwt'); + if (cookie) headers.set('cookie', cookie); + else headers.delete('cookie'); +} diff --git a/agentex-ui/app/api/agentex/[...path]/route.ts b/agentex-ui/app/api/agentex/[...path]/route.ts new file mode 100644 index 00000000..e0579dd5 --- /dev/null +++ b/agentex-ui/app/api/agentex/[...path]/route.ts @@ -0,0 +1,67 @@ +import { applyBffCredentials } from '@/app/api/_lib/bff'; + +/** + * Same-origin BFF proxy for the Agentex API, so the upstream URL and credentials never + * reach client JS. applyBffCredentials attaches credentials server-side. + */ +export const dynamic = 'force-dynamic'; + +const UPSTREAM = ( + process.env.AGENTEX_API_URL ?? 'http://localhost:5003' +).replace(/\/$/, ''); + +// Hop-by-hop headers to drop. Credential headers (cookie/authorization) are handled by +// applyBffCredentials, not here. +const STRIP_REQ = ['host', 'connection', 'content-length']; +const STRIP_RES = [ + 'content-encoding', + 'content-length', + 'transfer-encoding', + 'connection', +]; + +async function proxy( + req: Request, + ctx: { params: Promise<{ path?: string[] }> } +): Promise { + const { path = [] } = await ctx.params; + const search = new URL(req.url).search; + const target = `${UPSTREAM}/${path.join('/')}${search}`; + + const headers = new Headers(req.headers); + for (const h of STRIP_REQ) headers.delete(h); + await applyBffCredentials(req, headers); + + const method = req.method.toUpperCase(); + const hasBody = method !== 'GET' && method !== 'HEAD'; + const upstream = await fetch(target, { + method, + headers, + body: hasBody ? req.body : undefined, + redirect: 'manual', + // @ts-expect-error `duplex` is required to stream a request body (undici) + duplex: 'half', + }); + + // Pass the upstream body through unbuffered so SSE / streaming responses work. + const resHeaders = new Headers(upstream.headers); + for (const h of STRIP_RES) resHeaders.delete(h); + // Don't leak an upstream (internal) redirect target to the browser. + if (upstream.status >= 300 && upstream.status < 400) { + resHeaders.delete('location'); + } + return new Response(upstream.body, { + status: upstream.status, + headers: resHeaders, + }); +} + +export { + proxy as DELETE, + proxy as GET, + proxy as HEAD, + proxy as OPTIONS, + proxy as PATCH, + proxy as POST, + proxy as PUT, +}; diff --git a/agentex-ui/app/api/feedback/route.ts b/agentex-ui/app/api/feedback/route.ts index f4a2eb30..d7be738f 100644 --- a/agentex-ui/app/api/feedback/route.ts +++ b/agentex-ui/app/api/feedback/route.ts @@ -1,5 +1,7 @@ import { NextResponse } from 'next/server'; +import { applyBffCredentials, SGP_BASE_URL } from '@/app/api/_lib/bff'; + type FeedbackRequestBody = { traceId: string; messageId: string; @@ -13,33 +15,10 @@ type FeedbackRequestBody = { agentAcpType?: string; }; -const SGP_BASE_URL = - process.env.NEXT_PUBLIC_SGP_API_URL ?? - (process.env.NEXT_PUBLIC_SGP_APP_URL - ? `${process.env.NEXT_PUBLIC_SGP_APP_URL}/api` - : undefined); - -function getSGPHeaders(request: Request): Record { - const headers: Record = { - 'Content-Type': 'application/json', - }; - const forwarded = [ - 'cookie', - 'authorization', - 'x-api-key', - 'x-selected-account-id', - ]; - for (const key of forwarded) { - const value = request.headers.get(key); - if (value) headers[key] = value; - } - return headers; -} - async function sgpPost( path: string, body: unknown, - headers: Record + headers: Headers ): Promise { const res = await fetch(`${SGP_BASE_URL}${path}`, { method: 'POST', @@ -57,8 +36,7 @@ export async function POST(request: Request) { if (!SGP_BASE_URL) { return NextResponse.json( { - error: - 'SGP feedback is not configured. Set NEXT_PUBLIC_SGP_API_URL or NEXT_PUBLIC_SGP_APP_URL.', + error: 'SGP feedback is not configured. Set SGP_API_URL.', }, { status: 503 } ); @@ -100,7 +78,9 @@ export async function POST(request: Request) { ); } - const sgpHeaders = getSGPHeaders(request); + // Credentials attached server-side (see applyBffCredentials). + const sgpHeaders = new Headers({ 'Content-Type': 'application/json' }); + await applyBffCredentials(request, sgpHeaders); const now = new Date().toISOString(); try { diff --git a/agentex-ui/app/api/user-info/route.ts b/agentex-ui/app/api/user-info/route.ts new file mode 100644 index 00000000..9e69d50b --- /dev/null +++ b/agentex-ui/app/api/user-info/route.ts @@ -0,0 +1,27 @@ +import { NextResponse } from 'next/server'; + +import { applyBffCredentials, SGP_BASE_URL } from '@/app/api/_lib/bff'; + +/** + * Scoped BFF proxy for the caller's accounts (access_profiles), used to bootstrap/switch + * the selected account. Only this path is exposed — not a catch-all — so the browser can't + * reach arbitrary platform endpoints with the server-attached credentials. + */ +export const dynamic = 'force-dynamic'; + +export async function GET(request: Request): Promise { + if (!SGP_BASE_URL) { + return NextResponse.json( + { error: 'SGP is not configured. Set SGP_API_URL.' }, + { status: 503 } + ); + } + + const headers = new Headers({ accept: 'application/json' }); + await applyBffCredentials(request, headers); + const upstream = await fetch(`${SGP_BASE_URL}/user-info`, { headers }); + return new Response(upstream.body, { + status: upstream.status, + headers: { 'content-type': 'application/json' }, + }); +} diff --git a/agentex-ui/app/page.tsx b/agentex-ui/app/page.tsx index b4d991ec..de8bffb3 100644 --- a/agentex-ui/app/page.tsx +++ b/agentex-ui/app/page.tsx @@ -7,23 +7,13 @@ export default async function RootPage() { await connection(); const sgpAppURL = process.env.NEXT_PUBLIC_SGP_APP_URL ?? ''; - const agentexAPIBaseURL = - process.env.NEXT_PUBLIC_AGENTEX_API_BASE_URL ?? 'http://localhost:5003'; - - if (!agentexAPIBaseURL) { - return ( -
-

Missing some configs

-
{JSON.stringify({ sgpAppURL, agentexAPIBaseURL }, null, 2)}
-
- ); - } + // The account picker needs the platform API (accounts come from /api/user-info). + const accountsEnabled = !!( + process.env.SGP_API_URL ?? process.env.NEXT_PUBLIC_SGP_APP_URL + ); return ( - + ); diff --git a/agentex-ui/components/account-picker/account-picker.tsx b/agentex-ui/components/account-picker/account-picker.tsx new file mode 100644 index 00000000..102d1c20 --- /dev/null +++ b/agentex-ui/components/account-picker/account-picker.tsx @@ -0,0 +1,173 @@ +'use client'; + +import { useCallback, useEffect, useMemo } from 'react'; + +import { useQueryClient } from '@tanstack/react-query'; +import { Building2 } from 'lucide-react'; + +import { useAgentexClient } from '@/components/providers'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { useUserInfo, userInfoKey } from '@/hooks/use-user-info'; +import { cn } from '@/lib/utils'; + +export type AccountPickerProps = { + className?: string; + collapsed?: boolean; +}; + +/** + * Current-account selector, driven by the `account_id` query param (the BFF turns it into + * the `x-selected-account-id` header). Bootstraps to the first account when the param is + * missing/stale — the API can't resolve a principal without it. + */ +export function AccountPicker({ + className, + collapsed = false, +}: AccountPickerProps) { + const { accountsEnabled, selectedAccountId, setSelectedAccountId } = + useAgentexClient(); + const queryClient = useQueryClient(); + const { data, isLoading } = useUserInfo(accountsEnabled); + const profiles = useMemo(() => data?.access_profiles ?? [], [data]); + const selectedId = selectedAccountId ?? undefined; + + // Reset (not invalidate) account-scoped data on switch: invalidate keeps the old data + // cached during refetch, briefly showing the previous account's agents. user-info is + // preserved — the account list doesn't change on switch. + const resetAccountScoped = useCallback( + () => + queryClient.resetQueries({ + predicate: q => q.queryKey[0] !== userInfoKey[0], + }), + [queryClient] + ); + const onAccountChange = useCallback( + (id: string) => { + if (id === selectedId) return; + setSelectedAccountId(id); + void resetAccountScoped(); + }, + [selectedId, setSelectedAccountId, resetAccountScoped] + ); + + // Default to the first account when the URL has no valid account_id (fixes the + // "no account → 401" first load). `replace` so it doesn't add a history entry. + useEffect(() => { + if (profiles.length === 0) return; + const valid = + selectedId !== undefined && + profiles.some(p => p.account.id === selectedId); + if (valid) return; + const first = profiles[0]; + if (!first) return; + setSelectedAccountId(first.account.id, true); + void resetAccountScoped(); + }, [profiles, selectedId, setSelectedAccountId, resetAccountScoped]); + + if (!accountsEnabled) return null; + + // Size-9 collapsed-rail tile — shared by the loading placeholder (muted) and the + // single-account display (solid). + const iconTile = (opts?: { muted?: boolean; title?: string | undefined }) => ( +
+ +
+ ); + + // Loading: a disabled, empty picker (icon only) rather than a skeleton — keeps the row stable. + if (isLoading) { + return collapsed ? ( + iconTile({ muted: true }) + ) : ( + + ); + } + + if (profiles.length === 0) return null; + + const current = profiles.find(p => p.account.id === selectedId); + const single = profiles.length === 1; + const options = ( + + {profiles.map(p => ( + + {p.account.name} + + ))} + + ); + + if (collapsed) { + // Single → static icon; multiple → icon-only trigger (dropdown pops out beside the rail). + if (single) return iconTile({ title: current?.account.name }); + return ( + + ); + } + + // Single account: no switcher, just static context (matches the New Chat button). + if (single) { + return ( +
+ + {current?.account.name} +
+ ); + } + + return ( + + ); +} diff --git a/agentex-ui/components/agentex-ui-root.tsx b/agentex-ui/components/agentex-ui-root.tsx index e027b974..42e1e548 100644 --- a/agentex-ui/components/agentex-ui-root.tsx +++ b/agentex-ui/components/agentex-ui-root.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { ToastContainer } from 'react-toastify'; @@ -17,7 +17,8 @@ import { } from '@/hooks/use-safe-search-params'; export function AgentexUIRoot() { - const { agentName, taskID, updateParams } = useSafeSearchParams(); + const { agentName, taskID, sgpAccountID, updateParams } = + useSafeSearchParams(); const [isTracesSidebarOpen, setIsTracesSidebarOpen] = useState(false); const { agentexClient } = useAgentexClient(); const { data: agents = [], isFetching: isAgentsFetching } = @@ -32,6 +33,7 @@ export function AgentexUIRoot() { const [localAgentName, setLocalAgentName] = useLocalStorageState< string | undefined >('lastSelectedAgent', undefined); + const accountRef = useRef(sgpAccountID); // Wait until neither query is fetching before validating. We gate on `isFetching` (not // `isLoading`, which is false once a query has cached data) so we never validate against @@ -39,6 +41,15 @@ export function AgentexUIRoot() { // doesn't block the localStorage-restore path when there's no agent_name. Deps are // intentionally narrowed so we re-validate on fetch settle / agent_name change. useEffect(() => { + // An account switch resets to the home grid: the selected agent is account-scoped, + // so a same-named agent in the new account must not stay selected. + if (accountRef.current !== sgpAccountID) { + accountRef.current = sgpAccountID; + setLocalAgentName(undefined); + if (agentName) updateParams({ [SearchParamKey.AGENT_NAME]: null }); + return; + } + if (isAgentsFetching || isAgentByNameFetching) return; // Accept an agent found in the (paginated) list OR resolved directly by name, so a valid @@ -61,7 +72,13 @@ export function AgentexUIRoot() { updateParams({ [SearchParamKey.AGENT_NAME]: localAgentName }); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isAgentsFetching, isAgentByNameFetching, isAgentByNameError, agentName]); + }, [ + sgpAccountID, + isAgentsFetching, + isAgentByNameFetching, + isAgentByNameError, + agentName, + ]); const handleSelectTask = useCallback( (taskId: string | null) => { diff --git a/agentex-ui/components/providers/agentex-provider.tsx b/agentex-ui/components/providers/agentex-provider.tsx index 4d3481ed..5a6d4a14 100644 --- a/agentex-ui/components/providers/agentex-provider.tsx +++ b/agentex-ui/components/providers/agentex-provider.tsx @@ -1,40 +1,105 @@ 'use client'; -import { createContext, useContext, useMemo, type ReactNode } from 'react'; +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + type ReactNode, +} from 'react'; import AgentexSDK from 'agentex'; +import { + SearchParamKey, + useSafeSearchParams, +} from '@/hooks/use-safe-search-params'; + interface AgentexContextValue { agentexClient: AgentexSDK; sgpAppURL: string; + // Platform API configured → the account picker can fetch/switch accounts. + accountsEnabled: boolean; + // Selected account (from the `account_id` param) + a setter that mirrors it to the URL. + selectedAccountId: string | null; + setSelectedAccountId: (id: string, replace?: boolean) => void; } const AgentexContext = createContext(null); /** - * Main provider for Agentex application - * Provides the Agentex SDK client and app configuration to all child components + * The SDK always targets the same-origin BFF (`/api/agentex`), which attaches credentials + * server-side. The selected account rides as `x-selected-account-id`, from the `account_id` + * query param. */ export function AgentexProvider({ children, - agentexAPIBaseURL, sgpAppURL, + accountsEnabled, }: { children: ReactNode; - agentexAPIBaseURL: string; sgpAppURL: string; + accountsEnabled: boolean; }) { - const agentexClient = useMemo( - () => - new AgentexSDK({ - baseURL: agentexAPIBaseURL, - fetchOptions: { credentials: 'include' }, - }), - [agentexAPIBaseURL] + const { sgpAccountID, updateParams } = useSafeSearchParams(); + + // Synchronous source for the SDK header: setSelectedAccountId sets it before the (async) + // URL navigation, so a switch's refetch doesn't race it. + const selectedAccountIdRef = useRef(sgpAccountID); + useEffect(() => { + selectedAccountIdRef.current = sgpAccountID; + }, [sgpAccountID]); + + const setSelectedAccountId = useCallback( + (id: string, replace = false) => { + selectedAccountIdRef.current = id; + updateParams( + { + [SearchParamKey.SGP_ACCOUNT_ID]: id, + // An explicit switch (not bootstrap, which passes replace) drops the open task: + // it's account-scoped and won't resolve under the new account. + ...(replace ? {} : { [SearchParamKey.TASK_ID]: null }), + }, + replace + ); + }, + [updateParams] ); + const agentexClient = useMemo(() => { + // The SDK builds URLs with `new URL()`, so the base must be absolute. `window` is absent + // on the server, but no request fires during SSR; the client render recomputes it. + const baseURL = + typeof window !== 'undefined' + ? `${window.location.origin}/api/agentex` + : '/api/agentex'; + + return new AgentexSDK({ + baseURL, + fetchOptions: { credentials: 'include' }, + // Attach the selected account on every request (read from the ref — always current). + fetch: (input, init) => { + const headers = new Headers(init?.headers); + if (selectedAccountIdRef.current) { + headers.set('x-selected-account-id', selectedAccountIdRef.current); + } + return fetch(input, { ...init, headers }); + }, + }); + }, []); + return ( - + {children} ); diff --git a/agentex-ui/components/task-sidebar/task-sidebar-footer.tsx b/agentex-ui/components/task-sidebar/task-sidebar-footer.tsx index 17d15eaf..89b57d14 100644 --- a/agentex-ui/components/task-sidebar/task-sidebar-footer.tsx +++ b/agentex-ui/components/task-sidebar/task-sidebar-footer.tsx @@ -1,33 +1,53 @@ -import { useCallback } from 'react'; - import { MessageSquare } from 'lucide-react'; +import { AccountPicker } from '@/components/account-picker/account-picker'; +import { IconButton } from '@/components/ui/icon-button'; import { ResizableSidebar } from '@/components/ui/resizable-sidebar'; import { cn } from '@/lib/utils'; +const FEEDBACK_URL = 'https://github.com/scaleapi/scale-agentex/issues/new'; + +function openFeedback() { + window.open(FEEDBACK_URL, '_blank', 'noopener,noreferrer'); +} + export type TaskSidebarFooterProps = { className?: string; + collapsed?: boolean; }; -export function TaskSidebarFooter({ className }: TaskSidebarFooterProps) { - const handleFeedback = useCallback(() => { - window.open( - 'https://github.com/scaleapi/scale-agentex/issues/new', - '_blank', - 'noopener,noreferrer' +export function TaskSidebarFooter({ + className, + collapsed = false, +}: TaskSidebarFooterProps) { + if (collapsed) { + return ( +
+ + +
); - }, []); + } return ( -
+
- - Give Feedback +
+ + Give Feedback +
+
); } diff --git a/agentex-ui/components/task-sidebar/task-sidebar.tsx b/agentex-ui/components/task-sidebar/task-sidebar.tsx index 6ea1b1ff..a37a2502 100644 --- a/agentex-ui/components/task-sidebar/task-sidebar.tsx +++ b/agentex-ui/components/task-sidebar/task-sidebar.tsx @@ -34,22 +34,25 @@ export function TaskSidebar() { className="flex h-full flex-col gap-2 pt-4" isCollapsed={isCollapsed} renderCollapsed={() => ( -
- - -
+ <> +
+ + +
+ + )} > # optional: /api/feedback & /api/user-info → SGP API +# NEXT_PUBLIC_SGP_APP_URL= # optional: links to SGP traces diff --git a/agentex-ui/hooks/use-feedback.ts b/agentex-ui/hooks/use-feedback.ts index 63cfa79e..d25a0e9a 100644 --- a/agentex-ui/hooks/use-feedback.ts +++ b/agentex-ui/hooks/use-feedback.ts @@ -1,6 +1,7 @@ import { useMutation } from '@tanstack/react-query'; import { toast } from '@/components/ui/toast'; +import { useSafeSearchParams } from '@/hooks/use-safe-search-params'; type SubmitFeedbackParams = { traceId: string; @@ -21,13 +22,18 @@ type FeedbackResponse = { }; export function useFeedback() { + const { sgpAccountID } = useSafeSearchParams(); return useMutation({ mutationFn: async ( params: SubmitFeedbackParams ): Promise => { const response = await fetch('/api/feedback', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + // Selected account (same source as the SDK); the BFF forwards it. + ...(sgpAccountID ? { 'x-selected-account-id': sgpAccountID } : {}), + }, body: JSON.stringify(params), }); diff --git a/agentex-ui/hooks/use-user-info.ts b/agentex-ui/hooks/use-user-info.ts new file mode 100644 index 00000000..336e9987 --- /dev/null +++ b/agentex-ui/hooks/use-user-info.ts @@ -0,0 +1,34 @@ +import { useQuery } from '@tanstack/react-query'; + +/** A member account the caller can access (from the platform's /user-info). */ +export type AccessProfile = { + id: string; + role?: string; + account: { + id: string; + name: string; + organization_id?: string | null; + status?: string; + }; +}; + +type UserInfo = { access_profiles: AccessProfile[] }; + +export const userInfoKey = ['user-info'] as const; + +/** Caller's accounts (access_profiles) via /api/user-info; `enabled` off when unconfigured. */ +export function useUserInfo(enabled: boolean) { + return useQuery({ + queryKey: userInfoKey, + enabled, + queryFn: async (): Promise => { + const res = await fetch('/api/user-info', { credentials: 'include' }); + if (!res.ok) throw new Error(`user-info: ${res.status}`); + return res.json(); + }, + // The account list is stable for the session — don't refetch on the account_id + // navigation or window focus. + staleTime: Infinity, + refetchOnWindowFocus: false, + }); +}