From cee9dc9d160976d529d1a753babf1198ea31d500 Mon Sep 17 00:00:00 2001 From: Erich Woo Date: Wed, 8 Jul 2026 14:31:43 -0400 Subject: [PATCH 1/9] feat(agentex-ui): account picker via same-origin BFF proxy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Route the SDK through a same-origin BFF (`/api/agentex`) instead of calling the agentex API directly from the browser, and add an account switcher. - BFF proxy + shared `applyBffCredentials` forward the request cookies and an `x-selected-account-id` header to the upstream (agentex + platform APIs). - The selected account is driven by the `account_id` query param — no cookie — and injected on every SDK request via a synchronous ref. - `/api/user-info` fetches the caller's accounts; the picker bootstraps to the first account when the param is missing/stale (the API needs one to resolve a principal). Single account renders as static context; collapsed rail shows an icon-only variant. - Gated on the platform API being configured (`SGP_API_URL` / `NEXT_PUBLIC_SGP_APP_URL`); no-op otherwise. Env: `NEXT_PUBLIC_AGENTEX_API_BASE_URL` → server-only `AGENTEX_API_URL`. Co-Authored-By: Claude Opus 4.8 --- agentex-ui/DOCKER.md | 6 +- agentex-ui/README.md | 4 +- agentex-ui/app/api/_lib/bff.ts | 30 ++++ agentex-ui/app/api/agentex/[...path]/route.ts | 67 ++++++++ agentex-ui/app/api/feedback/route.ts | 35 +--- agentex-ui/app/api/user-info/route.ts | 28 +++ agentex-ui/app/page.tsx | 20 +-- .../account-picker/account-picker.tsx | 161 ++++++++++++++++++ .../components/providers/agentex-provider.tsx | 87 ++++++++-- .../task-sidebar/task-sidebar-footer.tsx | 45 +++-- .../task-sidebar/task-sidebar-header.tsx | 2 + .../components/task-sidebar/task-sidebar.tsx | 37 ++-- agentex-ui/components/ui/select.tsx | 4 +- agentex-ui/example.env.development | 4 +- agentex-ui/hooks/use-feedback.ts | 8 +- agentex-ui/hooks/use-user-info.ts | 38 +++++ 16 files changed, 482 insertions(+), 94 deletions(-) create mode 100644 agentex-ui/app/api/_lib/bff.ts create mode 100644 agentex-ui/app/api/agentex/[...path]/route.ts create mode 100644 agentex-ui/app/api/user-info/route.ts create mode 100644 agentex-ui/components/account-picker/account-picker.tsx create mode 100644 agentex-ui/hooks/use-user-info.ts 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..15e6694e --- /dev/null +++ b/agentex-ui/app/api/_lib/bff.ts @@ -0,0 +1,30 @@ +/** + * Server-only platform API upstream, shared by the platform-backed BFF routes + * (/api/feedback, /api/user-info). SGP_API_URL preferred; falls back to the dashboard + * app origin's /api. + */ +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); + +/** + * Apply BFF credentials to an outgoing upstream request's `headers`, in place, so no + * credential reaches client JS. Shared by every /api/* proxy (agentex, platform): + * - `x-selected-account-id` from the client (sourced from the account_id query param); + * forwarded as-is — the upstream authorizes the principal's access to the account. + * - the request cookies are forwarded for the upstream's own cookie auth. + */ +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'); + + const cookie = req.headers.get('cookie'); + 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..bd19d55b --- /dev/null +++ b/agentex-ui/app/api/agentex/[...path]/route.ts @@ -0,0 +1,67 @@ +import { applyBffCredentials } from '@/app/api/_lib/bff'; + +/** + * BFF proxy for the Agentex API. The browser ALWAYS calls this same-origin route, so + * the backend origin + credentials never touch client JS. Credentials (access token + * or forwarded cookies, plus x-selected-account-id) are applied server-side by + * applyBffCredentials — see app/api/_lib/bff.ts. + */ +export const dynamic = 'force-dynamic'; + +// Server-only upstream — the client only ever calls /api/agentex, so the backend URL +// stays out of the browser bundle. +const UPSTREAM = ( + process.env.AGENTEX_API_URL ?? 'http://localhost:5003' +).replace(/\/$/, ''); + +// Hop-by-hop / spoofable request headers to drop before forwarding. `cookie` and +// `authorization` are managed by applyBffCredentials, not stripped 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); + 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..00d2d320 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,10 @@ export async function POST(request: Request) { ); } - const sgpHeaders = getSGPHeaders(request); + // Same server-side credentials the agentex proxy forwards (access token or cookies, + // plus x-selected-account-id) — the client never sends them. + 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..45728645 --- /dev/null +++ b/agentex-ui/app/api/user-info/route.ts @@ -0,0 +1,28 @@ +import { NextResponse } from 'next/server'; + +import { applyBffCredentials, SGP_BASE_URL } from '@/app/api/_lib/bff'; + +/** + * BFF proxy for the one SGP endpoint agentex-ui needs client-side: the caller's + * accounts (access_profiles), used to bootstrap / switch the selected account. Scoped + * to just this path — deliberately NOT a catch-all SGP proxy — so the browser can't + * reach arbitrary SGP 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..191d3d45 --- /dev/null +++ b/agentex-ui/components/account-picker/account-picker.tsx @@ -0,0 +1,161 @@ +'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 { Skeleton } from '@/components/ui/skeleton'; +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; + + // Refetch account-scoped data (agents, tasks, …) on an account change — but NOT + // user-info: the account list itself doesn't change when you switch accounts. + const refetchAccountScoped = useCallback( + () => + queryClient.invalidateQueries({ + predicate: q => q.queryKey[0] !== userInfoKey[0], + }), + [queryClient] + ); + + // 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 refetchAccountScoped(); + }, [profiles, selectedId, setSelectedAccountId, refetchAccountScoped]); + + if (!accountsEnabled) return null; + // Reserve the picker's height while accounts load so New Chat doesn't jump when it + // appears. Once loaded with no accounts, render nothing. + if (isLoading) { + return ( + + ); + } + if (profiles.length === 0) return null; + + const select = (id: string) => { + if (id === selectedId) return; + setSelectedAccountId(id); + void refetchAccountScoped(); + }; + + const current = profiles.find(p => p.account.id === selectedId); + + if (collapsed) { + // Single account → static icon; multiple → an icon-only trigger whose dropdown pops + // out beside the collapsed rail (Radix positions it to stay in view). + if (profiles.length === 1) { + return ( +
+ +
+ ); + } + return ( + + ); + } + + // A single account needs no switcher — show it as static context. + if (profiles.length === 1) { + return ( +
+ + {current?.account.name} +
+ ); + } + + return ( + + ); +} diff --git a/agentex-ui/components/providers/agentex-provider.tsx b/agentex-ui/components/providers/agentex-provider.tsx index 4d3481ed..fcc074ac 100644 --- a/agentex-ui/components/providers/agentex-provider.tsx +++ b/agentex-ui/components/providers/agentex-provider.tsx @@ -1,40 +1,101 @@ '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; + // Whether the platform API is configured, so the account picker can fetch/switch accounts + accountsEnabled: boolean; + // Selected account id (from the `account_id` query param) + a setter that mirrors it + // to the URL and updates the header the SDK sends. + 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 + * Main provider. The SDK ALWAYS targets the same-origin BFF proxy (`/api/agentex`), which + * forwards credentials server-side. The selected account travels as the + * `x-selected-account-id` header, sourced from the `account_id` query param — no cookie + * involved. */ 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's per-request header. Seeded from the URL and kept in + // sync with it; setSelectedAccountId also sets it synchronously so a switch's refetch doesn't + // race the (async) URL navigation. + 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 }, replace); + }, + [updateParams] ); + const agentexClient = useMemo(() => { + // The SDK builds request URLs with `new URL()`, so the base must be ABSOLUTE. On the + // server `window` is absent, but no request fires during the initial server render + // (react-query fetches on the client); the client render recomputes an absolute URL. + 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..55f47394 100644 --- a/agentex-ui/components/task-sidebar/task-sidebar-footer.tsx +++ b/agentex-ui/components/task-sidebar/task-sidebar-footer.tsx @@ -1,32 +1,49 @@ -import { useCallback } from 'react'; - import { MessageSquare } from 'lucide-react'; +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-header.tsx b/agentex-ui/components/task-sidebar/task-sidebar-header.tsx index dd5b2081..7bb55d21 100644 --- a/agentex-ui/components/task-sidebar/task-sidebar-header.tsx +++ b/agentex-ui/components/task-sidebar/task-sidebar-header.tsx @@ -3,6 +3,7 @@ import { useRouter } from 'next/navigation'; import { PanelLeftClose, MessageSquarePlus } from 'lucide-react'; +import { AccountPicker } from '@/components/account-picker/account-picker'; import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; @@ -42,6 +43,7 @@ export function TaskSidebarHeader({ aria-label="Close Task Sidebar" />
+ ( -
- - -
+ <> +
+ + + +
+ + )} > # 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..18d7de5a --- /dev/null +++ b/agentex-ui/hooks/use-user-info.ts @@ -0,0 +1,38 @@ +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; + +/** + * Fetches the caller's accounts (access_profiles) via the scoped `/api/user-info` BFF + * proxy, to bootstrap / switch the selected account. `enabled` gates the fetch (off when + * the platform API isn't configured). + */ +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 — fetch once, don't refetch on remount + // (e.g. the account_id navigation) or focus. + staleTime: Infinity, + refetchOnWindowFocus: false, + }); +} From d97bc978b666ad89a2da3f5aee9e5da7e3bd1a91 Mon Sep 17 00:00:00 2001 From: Erich Woo Date: Wed, 8 Jul 2026 15:18:04 -0400 Subject: [PATCH 2/9] fix(agentex-ui): harden BFF proxy + move account picker to sidebar footer Address Greptile review + UX design feedback. - BFF: drop any client-supplied `Authorization` in applyBffCredentials so a client can't inject its own bearer token to the upstream (P1); strip `Location` from upstream 3xx responses so internal redirect targets don't leak to the browser (P2). - UX: move the account picker out of the sidebar header / collapsed top rail into the footer, directly below Give Feedback (top of the sidebar now untouched). Co-Authored-By: Claude Opus 4.8 --- agentex-ui/app/api/_lib/bff.ts | 6 +++++- agentex-ui/app/api/agentex/[...path]/route.ts | 4 ++++ agentex-ui/components/task-sidebar/task-sidebar-footer.tsx | 3 +++ agentex-ui/components/task-sidebar/task-sidebar-header.tsx | 2 -- agentex-ui/components/task-sidebar/task-sidebar.tsx | 2 -- 5 files changed, 12 insertions(+), 5 deletions(-) diff --git a/agentex-ui/app/api/_lib/bff.ts b/agentex-ui/app/api/_lib/bff.ts index 15e6694e..60e4cf38 100644 --- a/agentex-ui/app/api/_lib/bff.ts +++ b/agentex-ui/app/api/_lib/bff.ts @@ -14,7 +14,8 @@ export const SGP_BASE_URL = * credential reaches client JS. Shared by every /api/* proxy (agentex, platform): * - `x-selected-account-id` from the client (sourced from the account_id query param); * forwarded as-is — the upstream authorizes the principal's access to the account. - * - the request cookies are forwarded for the upstream's own cookie auth. + * - any client-supplied `authorization` is dropped so a client can't inject its own + * bearer token; the request cookies are forwarded for the upstream's cookie auth. */ export async function applyBffCredentials( req: Request, @@ -24,6 +25,9 @@ export async function applyBffCredentials( if (accountId) headers.set('x-selected-account-id', accountId); else headers.delete('x-selected-account-id'); + // Credentials are server-managed: never trust a client-sent Authorization header. + headers.delete('authorization'); + const cookie = req.headers.get('cookie'); 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 index bd19d55b..2726c084 100644 --- a/agentex-ui/app/api/agentex/[...path]/route.ts +++ b/agentex-ui/app/api/agentex/[...path]/route.ts @@ -50,6 +50,10 @@ async function proxy( // 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, diff --git a/agentex-ui/components/task-sidebar/task-sidebar-footer.tsx b/agentex-ui/components/task-sidebar/task-sidebar-footer.tsx index 55f47394..89b57d14 100644 --- a/agentex-ui/components/task-sidebar/task-sidebar-footer.tsx +++ b/agentex-ui/components/task-sidebar/task-sidebar-footer.tsx @@ -1,5 +1,6 @@ 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'; @@ -29,6 +30,7 @@ export function TaskSidebarFooter({ className="text-foreground" aria-label="Give Feedback" /> + ); } @@ -45,6 +47,7 @@ export function TaskSidebarFooter({ Give Feedback
+ ); } diff --git a/agentex-ui/components/task-sidebar/task-sidebar-header.tsx b/agentex-ui/components/task-sidebar/task-sidebar-header.tsx index 7bb55d21..dd5b2081 100644 --- a/agentex-ui/components/task-sidebar/task-sidebar-header.tsx +++ b/agentex-ui/components/task-sidebar/task-sidebar-header.tsx @@ -3,7 +3,6 @@ import { useRouter } from 'next/navigation'; import { PanelLeftClose, MessageSquarePlus } from 'lucide-react'; -import { AccountPicker } from '@/components/account-picker/account-picker'; import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; @@ -43,7 +42,6 @@ export function TaskSidebarHeader({ aria-label="Close Task Sidebar" /> - - Date: Wed, 8 Jul 2026 15:39:08 -0400 Subject: [PATCH 3/9] fix(agentex-ui): show disabled empty account picker while loading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the account picker's loading skeleton with a disabled, empty picker (building icon only) — a plain skeleton block read as odd once the picker moved into the footer between Give Feedback and Log out. Keeps the row stable and reads as "loading account selector". Co-Authored-By: Claude Opus 4.8 --- .../account-picker/account-picker.tsx | 31 +++++++++++++------ 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/agentex-ui/components/account-picker/account-picker.tsx b/agentex-ui/components/account-picker/account-picker.tsx index 191d3d45..c3f6f10e 100644 --- a/agentex-ui/components/account-picker/account-picker.tsx +++ b/agentex-ui/components/account-picker/account-picker.tsx @@ -13,7 +13,6 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { Skeleton } from '@/components/ui/skeleton'; import { useUserInfo, userInfoKey } from '@/hooks/use-user-info'; import { cn } from '@/lib/utils'; @@ -63,16 +62,30 @@ export function AccountPicker({ }, [profiles, selectedId, setSelectedAccountId, refetchAccountScoped]); if (!accountsEnabled) return null; - // Reserve the picker's height while accounts load so New Chat doesn't jump when it - // appears. Once loaded with no accounts, render nothing. + // While accounts load, show a disabled, empty picker (building icon only) rather than + // a bare skeleton block — keeps the footer row stable and reads as "account selector, + // loading". Once loaded with no accounts, render nothing. if (isLoading) { + if (collapsed) { + return ( +
+ +
+ ); + } return ( - + ); } if (profiles.length === 0) return null; From 0b756ecf30173ac6afd1b184ca75a26865932fe0 Mon Sep 17 00:00:00 2001 From: Erich Woo Date: Wed, 8 Jul 2026 15:58:54 -0400 Subject: [PATCH 4/9] refactor(agentex-ui): DRY account picker; reset open task on account switch - Account picker: extract a shared `iconTile` (loading + single-account collapsed tiles) and a shared `options` element (the SelectContent list previously duplicated across the collapsed and expanded switchers); single change handler `onAccountChange`. - Provider: `setSelectedAccountId` now clears `task_id` on an explicit switch (the open task is account-scoped and 404s under a new account). This preserves the reset that previously lived in agentex-ui-root's validation effect, which #347 reworked. Co-Authored-By: Claude Opus 4.8 --- .../account-picker/account-picker.tsx | 99 ++++++++++--------- .../components/providers/agentex-provider.tsx | 10 +- 2 files changed, 59 insertions(+), 50 deletions(-) diff --git a/agentex-ui/components/account-picker/account-picker.tsx b/agentex-ui/components/account-picker/account-picker.tsx index c3f6f10e..ec64b010 100644 --- a/agentex-ui/components/account-picker/account-picker.tsx +++ b/agentex-ui/components/account-picker/account-picker.tsx @@ -46,6 +46,14 @@ export function AccountPicker({ }), [queryClient] ); + const onAccountChange = useCallback( + (id: string) => { + if (id === selectedId) return; + setSelectedAccountId(id); + void refetchAccountScoped(); + }, + [selectedId, setSelectedAccountId, refetchAccountScoped] + ); // 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. @@ -62,20 +70,29 @@ export function AccountPicker({ }, [profiles, selectedId, setSelectedAccountId, refetchAccountScoped]); if (!accountsEnabled) return null; - // While accounts load, show a disabled, empty picker (building icon only) rather than - // a bare skeleton block — keeps the footer row stable and reads as "account selector, - // loading". Once loaded with no accounts, render nothing. + + // A size-9 icon tile — the collapsed-rail footprint. Shared by the loading placeholder + // (muted) and the single-account display (solid, with the name as a tooltip). + const iconTile = (opts?: { muted?: boolean; title?: string | undefined }) => ( +
+ +
+ ); + + // While accounts load, show a disabled, empty picker (icon only) instead of a skeleton + // block — keeps the row stable and reads as a loading account selector. if (isLoading) { - if (collapsed) { - return ( -
- -
- ); - } - return ( + return collapsed ? ( + iconTile({ muted: true }) + ) : ( + ); } - // A single account needs no switcher — show it as static context. - if (profiles.length === 1) { + // A single account needs no switcher — show it as static context, matching the New Chat + // button's size + spacing (size-5 icon, p-2, medium weight). + if (single) { return (
+ ); } diff --git a/agentex-ui/components/providers/agentex-provider.tsx b/agentex-ui/components/providers/agentex-provider.tsx index fcc074ac..7d2c157b 100644 --- a/agentex-ui/components/providers/agentex-provider.tsx +++ b/agentex-ui/components/providers/agentex-provider.tsx @@ -58,7 +58,15 @@ export function AgentexProvider({ const setSelectedAccountId = useCallback( (id: string, replace = false) => { selectedAccountIdRef.current = id; - updateParams({ [SearchParamKey.SGP_ACCOUNT_ID]: id }, replace); + updateParams( + { + [SearchParamKey.SGP_ACCOUNT_ID]: id, + // An explicit switch (not the initial 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] ); From 5f243a0221f6ea635f33089889743e6f227a9997 Mon Sep 17 00:00:00 2001 From: Erich Woo Date: Wed, 8 Jul 2026 15:59:16 -0400 Subject: [PATCH 5/9] docs(agentex-ui): clarify applyBffCredentials owns credential headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The STRIP_REQ comment previously read as if `authorization` went unmanaged. Spell out that applyBffCredentials deletes any client-supplied `cookie`/`authorization` and sets the server-managed values, so the stripping applies to every /api/* proxy — addressing the review note without moving credential logic out of the shared helper. Co-Authored-By: Claude Opus 4.8 --- agentex-ui/app/api/agentex/[...path]/route.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/agentex-ui/app/api/agentex/[...path]/route.ts b/agentex-ui/app/api/agentex/[...path]/route.ts index 2726c084..95ce14f7 100644 --- a/agentex-ui/app/api/agentex/[...path]/route.ts +++ b/agentex-ui/app/api/agentex/[...path]/route.ts @@ -14,8 +14,10 @@ const UPSTREAM = ( process.env.AGENTEX_API_URL ?? 'http://localhost:5003' ).replace(/\/$/, ''); -// Hop-by-hop / spoofable request headers to drop before forwarding. `cookie` and -// `authorization` are managed by applyBffCredentials, not stripped here. +// Hop-by-hop request headers to drop before forwarding. Credential headers (`cookie`, +// `authorization`) are intentionally NOT listed here: applyBffCredentials owns them — it +// deletes any client-supplied values and sets the server-managed ones — so the same +// stripping applies to every /api/* proxy, not just this route. const STRIP_REQ = ['host', 'connection', 'content-length']; const STRIP_RES = [ 'content-encoding', From 6b8fb8f2baf72aa6f1b7e1a1062d416e493d446b Mon Sep 17 00:00:00 2001 From: Erich Woo Date: Wed, 8 Jul 2026 16:19:37 -0400 Subject: [PATCH 6/9] fix(agentex-ui): reset account-scoped queries on switch to avoid stale flash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switching accounts closes the open task (task_id cleared), which flips PrimaryContent from ChatView to HomeView immediately. HomeView rendered the agents list from the still-cached previous account (invalidateQueries keeps data during the refetch), so the new account briefly flashed the old account's agents before recalibrating. Use resetQueries instead: the previous account's agents/tasks are dropped on switch, so HomeView shows a loading state until the new account's data lands — never stale data. Co-Authored-By: Claude Opus 4.8 --- .../account-picker/account-picker.tsx | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/agentex-ui/components/account-picker/account-picker.tsx b/agentex-ui/components/account-picker/account-picker.tsx index ec64b010..705b5db0 100644 --- a/agentex-ui/components/account-picker/account-picker.tsx +++ b/agentex-ui/components/account-picker/account-picker.tsx @@ -37,11 +37,13 @@ export function AccountPicker({ const profiles = useMemo(() => data?.access_profiles ?? [], [data]); const selectedId = selectedAccountId ?? undefined; - // Refetch account-scoped data (agents, tasks, …) on an account change — but NOT - // user-info: the account list itself doesn't change when you switch accounts. - const refetchAccountScoped = useCallback( + // Reset (not just invalidate) account-scoped data on a switch. invalidate keeps the + // previous data cached during the refetch, so the new account would briefly render the + // old account's agents (HomeView) before recalibrating; reset clears it so we show a + // loading state instead. user-info is preserved — the account list doesn't change. + const resetAccountScoped = useCallback( () => - queryClient.invalidateQueries({ + queryClient.resetQueries({ predicate: q => q.queryKey[0] !== userInfoKey[0], }), [queryClient] @@ -50,9 +52,9 @@ export function AccountPicker({ (id: string) => { if (id === selectedId) return; setSelectedAccountId(id); - void refetchAccountScoped(); + void resetAccountScoped(); }, - [selectedId, setSelectedAccountId, refetchAccountScoped] + [selectedId, setSelectedAccountId, resetAccountScoped] ); // Default to the first account when the URL has no valid account_id (fixes the @@ -66,8 +68,8 @@ export function AccountPicker({ const first = profiles[0]; if (!first) return; setSelectedAccountId(first.account.id, true); - void refetchAccountScoped(); - }, [profiles, selectedId, setSelectedAccountId, refetchAccountScoped]); + void resetAccountScoped(); + }, [profiles, selectedId, setSelectedAccountId, resetAccountScoped]); if (!accountsEnabled) return null; From 10022fea24b6aac591259cad6308149054ca3bb0 Mon Sep 17 00:00:00 2001 From: Erich Woo Date: Wed, 8 Jul 2026 17:06:57 -0400 Subject: [PATCH 7/9] docs(agentex-ui): tighten comments to the non-obvious Trim redundant/verbose comments across the account-picker BFF layer (drop lines that restate the code or repeat a docstring); no behavior change. Co-Authored-By: Claude Opus 4.8 --- agentex-ui/app/api/_lib/bff.ts | 16 +++--------- agentex-ui/app/api/agentex/[...path]/route.ts | 14 +++------- agentex-ui/app/api/feedback/route.ts | 3 +-- agentex-ui/app/api/user-info/route.ts | 7 +++-- .../account-picker/account-picker.tsx | 20 ++++++-------- .../components/providers/agentex-provider.tsx | 26 ++++++++----------- agentex-ui/hooks/use-user-info.ts | 10 +++---- 7 files changed, 34 insertions(+), 62 deletions(-) diff --git a/agentex-ui/app/api/_lib/bff.ts b/agentex-ui/app/api/_lib/bff.ts index 60e4cf38..4d266d7e 100644 --- a/agentex-ui/app/api/_lib/bff.ts +++ b/agentex-ui/app/api/_lib/bff.ts @@ -1,8 +1,4 @@ -/** - * Server-only platform API upstream, shared by the platform-backed BFF routes - * (/api/feedback, /api/user-info). SGP_API_URL preferred; falls back to the dashboard - * app origin's /api. - */ +/** 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 @@ -10,12 +6,9 @@ export const SGP_BASE_URL = : undefined); /** - * Apply BFF credentials to an outgoing upstream request's `headers`, in place, so no - * credential reaches client JS. Shared by every /api/* proxy (agentex, platform): - * - `x-selected-account-id` from the client (sourced from the account_id query param); - * forwarded as-is — the upstream authorizes the principal's access to the account. - * - any client-supplied `authorization` is dropped so a client can't inject its own - * bearer token; the request cookies are forwarded for the upstream's cookie auth. + * 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. */ export async function applyBffCredentials( req: Request, @@ -25,7 +18,6 @@ export async function applyBffCredentials( if (accountId) headers.set('x-selected-account-id', accountId); else headers.delete('x-selected-account-id'); - // Credentials are server-managed: never trust a client-sent Authorization header. headers.delete('authorization'); const cookie = req.headers.get('cookie'); diff --git a/agentex-ui/app/api/agentex/[...path]/route.ts b/agentex-ui/app/api/agentex/[...path]/route.ts index 95ce14f7..e0579dd5 100644 --- a/agentex-ui/app/api/agentex/[...path]/route.ts +++ b/agentex-ui/app/api/agentex/[...path]/route.ts @@ -1,23 +1,17 @@ import { applyBffCredentials } from '@/app/api/_lib/bff'; /** - * BFF proxy for the Agentex API. The browser ALWAYS calls this same-origin route, so - * the backend origin + credentials never touch client JS. Credentials (access token - * or forwarded cookies, plus x-selected-account-id) are applied server-side by - * applyBffCredentials — see app/api/_lib/bff.ts. + * 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'; -// Server-only upstream — the client only ever calls /api/agentex, so the backend URL -// stays out of the browser bundle. const UPSTREAM = ( process.env.AGENTEX_API_URL ?? 'http://localhost:5003' ).replace(/\/$/, ''); -// Hop-by-hop request headers to drop before forwarding. Credential headers (`cookie`, -// `authorization`) are intentionally NOT listed here: applyBffCredentials owns them — it -// deletes any client-supplied values and sets the server-managed ones — so the same -// stripping applies to every /api/* proxy, not just this route. +// 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', diff --git a/agentex-ui/app/api/feedback/route.ts b/agentex-ui/app/api/feedback/route.ts index 00d2d320..d7be738f 100644 --- a/agentex-ui/app/api/feedback/route.ts +++ b/agentex-ui/app/api/feedback/route.ts @@ -78,8 +78,7 @@ export async function POST(request: Request) { ); } - // Same server-side credentials the agentex proxy forwards (access token or cookies, - // plus x-selected-account-id) — the client never sends them. + // Credentials attached server-side (see applyBffCredentials). const sgpHeaders = new Headers({ 'Content-Type': 'application/json' }); await applyBffCredentials(request, sgpHeaders); const now = new Date().toISOString(); diff --git a/agentex-ui/app/api/user-info/route.ts b/agentex-ui/app/api/user-info/route.ts index 45728645..9e69d50b 100644 --- a/agentex-ui/app/api/user-info/route.ts +++ b/agentex-ui/app/api/user-info/route.ts @@ -3,10 +3,9 @@ import { NextResponse } from 'next/server'; import { applyBffCredentials, SGP_BASE_URL } from '@/app/api/_lib/bff'; /** - * BFF proxy for the one SGP endpoint agentex-ui needs client-side: the caller's - * accounts (access_profiles), used to bootstrap / switch the selected account. Scoped - * to just this path — deliberately NOT a catch-all SGP proxy — so the browser can't - * reach arbitrary SGP endpoints with the server-attached credentials. + * 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'; diff --git a/agentex-ui/components/account-picker/account-picker.tsx b/agentex-ui/components/account-picker/account-picker.tsx index 705b5db0..102d1c20 100644 --- a/agentex-ui/components/account-picker/account-picker.tsx +++ b/agentex-ui/components/account-picker/account-picker.tsx @@ -37,10 +37,9 @@ export function AccountPicker({ const profiles = useMemo(() => data?.access_profiles ?? [], [data]); const selectedId = selectedAccountId ?? undefined; - // Reset (not just invalidate) account-scoped data on a switch. invalidate keeps the - // previous data cached during the refetch, so the new account would briefly render the - // old account's agents (HomeView) before recalibrating; reset clears it so we show a - // loading state instead. user-info is preserved — the account list doesn't change. + // 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({ @@ -73,8 +72,8 @@ export function AccountPicker({ if (!accountsEnabled) return null; - // A size-9 icon tile — the collapsed-rail footprint. Shared by the loading placeholder - // (muted) and the single-account display (solid, with the name as a tooltip). + // 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 }) => (
); - // While accounts load, show a disabled, empty picker (icon only) instead of a skeleton - // block — keeps the row stable and reads as a loading account selector. + // Loading: a disabled, empty picker (icon only) rather than a skeleton — keeps the row stable. if (isLoading) { return collapsed ? ( iconTile({ muted: true }) @@ -123,8 +121,7 @@ export function AccountPicker({ ); if (collapsed) { - // Single account → static icon; multiple → an icon-only trigger whose dropdown pops - // out beside the collapsed rail (Radix keeps it in view). + // Single → static icon; multiple → icon-only trigger (dropdown pops out beside the rail). if (single) return iconTile({ title: current?.account.name }); return (