diff --git a/examples/sso_frontend_on_agentkit/.dockerignore b/examples/sso_frontend_on_agentkit/.dockerignore new file mode 100644 index 00000000..02a634c3 --- /dev/null +++ b/examples/sso_frontend_on_agentkit/.dockerignore @@ -0,0 +1,28 @@ +# AgentKit configuration +agentkit.yaml +agentkit*.yaml +.agentkit/ + +# Local secrets — never ship into the image (envs are injected by the runtime) +.env + +# Python cache +__pycache__/ +*.py[cod] +*$py.class + +# Virtual environments +.venv/ +venv/ +ENV/ +env/ + +# IDE / git +.vscode/ +.idea/ +.git/ +.gitignore + +# Docker +Dockerfile* +.dockerignore diff --git a/examples/sso_frontend_on_agentkit/.env.example b/examples/sso_frontend_on_agentkit/.env.example new file mode 100644 index 00000000..12f454e5 --- /dev/null +++ b/examples/sso_frontend_on_agentkit/.env.example @@ -0,0 +1,14 @@ +# Copy this file to `.env`, fill in your values, then: +# set -a && source .env && set +a + +# --- Volcengine AK/SK --- +# Used by `veadk agentkit` to authenticate the build & deploy, and injected into +# the runtime so the app can call the VeIdentity API (the runtime has no usable +# role credentials). Create access keys at https://console.volcengine.com/iam/keymanage +VOLCENGINE_ACCESS_KEY=your-volcengine-access-key +VOLCENGINE_SECRET_KEY=your-volcengine-secret-key + +# --- SSO: VeIdentity user pool (https://console.volcengine.com/veidentity) --- +# The UID of an existing user pool and one of its WEB_APPLICATION clients. +OAUTH2_USER_POOL_ID=your-user-pool-uid +OAUTH2_USER_POOL_CLIENT_ID=your-user-pool-client-uid diff --git a/examples/sso_frontend_on_agentkit/README.md b/examples/sso_frontend_on_agentkit/README.md new file mode 100644 index 00000000..86bdb03b --- /dev/null +++ b/examples/sso_frontend_on_agentkit/README.md @@ -0,0 +1,131 @@ +# sso_frontend_on_agentkit · VeADK frontend with SSO on AgentKit + +Deploy the VeADK web UI (A2UI) together with **VeIdentity single sign-on** to +a [Volcengine AgentKit](https://www.volcengine.com/) runtime. Unauthenticated +browsers see a login page; after sign-in the UI and backend agent run as the +logged-in user. Fully non-interactive — copy the commands to deploy. + +> 中文版:[README.zh.md](./README.zh.md) + +## Layout + +```text +sso_frontend_on_agentkit/ +├── app.py # entry: web UI + agent API + SSO +├── agents/ +│ └── sso_demo_agent/ # a minimal agent +├── requirements.txt # veadk-python>=0.5.39 +├── .env.example +└── .dockerignore +``` + +## How it works + +The UI, agent API, VeIdentity OAuth2 middleware and bundled web UI all come +from `veadk-python` on PyPI. SSO is configured entirely through runtime +environment variables — no code changes. + +`app.py` adds two adaptations for the AgentKit gateway, which authenticates +every request with the runtime key in the `Authorization: Bearer ` header +and forwards that header to the container: + +- **Strip the gateway key**: the SSO middleware treats any `Authorization` + header as the user's access token and tries to decode it as a JWT — the + opaque gateway key fails with `Invalid JWT format`. `app.py` removes that + non-JWT header before the middleware runs, so SSO falls back to its session + cookie. A real user JWT is kept. +- **Forward the querystring onto assets**: if the gateway is configured to take + the key from the query string, the served `index.html` appends the page's + querystring to its `/assets/*` URLs so subresource requests carry it too. + +> Both adaptations are built into `veadk frontend` from the next release; this +> example self-contains them so it runs on the current release. + +## 1. Prerequisites + +- A VeIdentity user pool and one of its `WEB_APPLICATION` clients + () — note both **UIDs**. +- Your account AK/SK — used both by the local `veadk agentkit` build & deploy + and by the runtime to call the VeIdentity API (the runtime has no usable role + credentials, so they must be injected). The model is provided by the runtime. + +```bash +cd examples/sso_frontend_on_agentkit +cp .env.example .env +# Edit .env: +# VOLCENGINE_ACCESS_KEY / VOLCENGINE_SECRET_KEY (local deploy auth) +# OAUTH2_USER_POOL_ID / OAUTH2_USER_POOL_CLIENT_ID (pool & client UIDs) +set -a && source .env && set +a +``` + +## 2. Configure (non-interactive) + +Account-specific fields (container registry, runtime role, …) are auto-created +when omitted. The runtime needs the two UIDs and AK/SK — it has no usable role +credentials, so AK/SK must be injected to call the VeIdentity API. The model is +provided by the runtime, so it is not in `--runtime_envs`: + +```bash +veadk agentkit config \ + --agent_name sso-frontend-demo \ + --entry_point app.py \ + --language Python --language_version 3.12 \ + --launch_type cloud --region cn-beijing \ + --runtime_name sso-frontend-demo \ + --runtime_auth_type key_auth \ + --runtime_envs OAUTH2_USER_POOL_ID="$OAUTH2_USER_POOL_ID" \ + --runtime_envs OAUTH2_USER_POOL_CLIENT_ID="$OAUTH2_USER_POOL_CLIENT_ID" \ + --runtime_envs VOLCENGINE_ACCESS_KEY="$VOLCENGINE_ACCESS_KEY" \ + --runtime_envs VOLCENGINE_SECRET_KEY="$VOLCENGINE_SECRET_KEY" \ + --runtime_envs OTEL_SDK_DISABLED=true \ + --runtime_envs VEADK_DISABLE_EXPIRE_AT=true +``` + +## 3. Deploy + +```bash +# Build the image and create the runtime; prints the endpoint and API key. +veadk agentkit launch +``` + +Set the callback to the printed endpoint (merged into the existing +`runtime_envs`) and update the runtime once more: + +```bash +veadk agentkit config \ + --runtime_envs OAUTH2_REDIRECT_URI=https:///oauth2/callback +veadk agentkit deploy +``` + +Locally the callback defaults to `http://127.0.0.1:8000/oauth2/callback`, so no +env var is needed; once deployed the browser hits the public endpoint, which is +only known after the runtime is created — hence this separate step. The callback +is registered with the user pool client automatically. + +## 4. Access + +The AgentKit gateway requires the runtime key on every request, and the runtime +key currently only supports the header location (`CreateRuntime`'s +`ApiKeyLocation` accepts only `header`). A browser's top-level navigation cannot +set a header, so use a browser extension (e.g. ModHeader) to add, for this +domain, globally: + +```text +Authorization: Bearer +``` + +Then open the endpoint: the UI loads → redirects to VeIdentity login → callback +(the extension adds the header to pass the gateway) → the session lives in a +cookie and the UI and agent API work. + +## Notes + +- **Model**: provided by the AgentKit runtime; to pin one, add + `--runtime_envs MODEL_AGENT_NAME=... --runtime_envs MODEL_AGENT_API_KEY=...`. +- **AK/SK**: used by the local `veadk agentkit` build & deploy **and** on the + runtime for the VeIdentity API calls (resolve the pool, register the callback). + The runtime has no usable role credentials (IMDS times out), so AK/SK must be + injected or the container crashes on startup. +- **Redeploy**: after changing an env var, merge it with + `veadk agentkit config --runtime_envs K=V` and re-run `veadk agentkit deploy` + (image layers are reused). Tear down with `veadk agentkit destroy`. diff --git a/examples/sso_frontend_on_agentkit/README.zh.md b/examples/sso_frontend_on_agentkit/README.zh.md new file mode 100644 index 00000000..0958cdff --- /dev/null +++ b/examples/sso_frontend_on_agentkit/README.zh.md @@ -0,0 +1,115 @@ +# sso_frontend_on_agentkit · 把带 SSO 的 VeADK 前端部署到 AgentKit + +将 VeADK 的 Web 界面(A2UI)连同 **VeIdentity 单点登录** 一起部署到 +[火山引擎 AgentKit](https://www.volcengine.com/) 运行时。未登录的浏览器看到登录页, +登录后以登录用户的身份使用界面与后端 Agent。全程非交互,复制命令即可部署。 + +> English version: [README.md](./README.md) + +## 目录结构 + +```text +sso_frontend_on_agentkit/ +├── app.py # 部署入口:Web 界面 + Agent API + SSO +├── agents/ +│ └── sso_demo_agent/ # 一个最简 Agent +├── requirements.txt # veadk-python>=0.5.39 +├── .env.example +└── .dockerignore +``` + +## 工作原理 + +界面、Agent API、VeIdentity OAuth2 中间件、内置 Web 界面都来自 PyPI 上的 +`veadk-python`。SSO 全部通过运行时环境变量配置,无需改代码。 + +`app.py` 针对 AgentKit 网关做了两处适配。网关对每个请求都用运行时 key 鉴权, +key 放在 `Authorization: Bearer ` 请求头里,并把该头透传给容器: + +- **剥离网关 key**:SSO 中间件会把 `Authorization` 头当成用户的访问令牌去解析 JWT, + 而网关 key 不是 JWT,会报 `Invalid JWT format`。`app.py` 在中间件之前移除这个非 JWT + 的头,使 SSO 回退到会话 cookie;合法的用户 JWT 保持不变。 +- **静态资源透传 querystring**:若网关改为从查询串取 key,浏览器加载 `/assets/*` + 也需带上 key。返回的 `index.html` 会把页面的查询串拼到各静态资源 URL 上。 + +> 这两处适配自下一个版本起已内置进 `veadk frontend`,本示例自带它们以便在当前 +> 发布版上直接运行。 + +## 1. 前置准备 + +- 一个 VeIdentity 用户池及其下的一个 `WEB_APPLICATION` 客户端 + (控制台:),记下两者的 **UID**。 +- 账号的 AK/SK:本地 `veadk agentkit` 构建部署,以及运行时调用 VeIdentity API + 都要用(运行时取不到角色凭证,必须注入)。模型由 AgentKit 运行时提供。 + +```bash +cd examples/sso_frontend_on_agentkit +cp .env.example .env +# 编辑 .env: +# VOLCENGINE_ACCESS_KEY / VOLCENGINE_SECRET_KEY (本地部署鉴权用) +# OAUTH2_USER_POOL_ID / OAUTH2_USER_POOL_CLIENT_ID (用户池、客户端的 UID) +set -a && source .env && set +a +``` + +## 2. 生成配置(非交互) + +账号相关字段(镜像仓库、运行时角色等)省略即自动创建。运行时需要两个 UID 和 +AK/SK——运行时取不到角色凭证,调用 VeIdentity API 必须注入 AK/SK;模型由运行时 +提供,无需写进 `--runtime_envs`: + +```bash +veadk agentkit config \ + --agent_name sso-frontend-demo \ + --entry_point app.py \ + --language Python --language_version 3.12 \ + --launch_type cloud --region cn-beijing \ + --runtime_name sso-frontend-demo \ + --runtime_auth_type key_auth \ + --runtime_envs OAUTH2_USER_POOL_ID="$OAUTH2_USER_POOL_ID" \ + --runtime_envs OAUTH2_USER_POOL_CLIENT_ID="$OAUTH2_USER_POOL_CLIENT_ID" \ + --runtime_envs VOLCENGINE_ACCESS_KEY="$VOLCENGINE_ACCESS_KEY" \ + --runtime_envs VOLCENGINE_SECRET_KEY="$VOLCENGINE_SECRET_KEY" \ + --runtime_envs OTEL_SDK_DISABLED=true \ + --runtime_envs VEADK_DISABLE_EXPIRE_AT=true +``` + +## 3. 部署 + +```bash +# 构建镜像并创建运行时,输出 endpoint 与 API key +veadk agentkit launch +``` + +把上一步输出的 endpoint 填入回调地址(会合并进现有 `runtime_envs`),再更新运行时: + +```bash +veadk agentkit config \ + --runtime_envs OAUTH2_REDIRECT_URI=https:///oauth2/callback +veadk agentkit deploy +``` + +本地启动时默认回调即 `http://127.0.0.1:8000/oauth2/callback`,无需设置;部署后浏览器 +访问的是公网 endpoint,而它只在运行时创建后才知道,因此这一步单独设置——回调会自动 +注册到用户池客户端,云端无需手动添加。 + +## 4. 访问 + +AgentKit 网关要求每个请求都带运行时 key。当前运行时 key 只支持放在请求头里 +(`CreateRuntime` 的 `ApiKeyLocation` 仅接受 `header`),而浏览器的顶层导航无法自带请求头, +因此用浏览器扩展(如 ModHeader)对该域名**全局**添加请求头: + +```text +Authorization: Bearer +``` + +随后访问 endpoint 即可:界面加载 → 跳转 VeIdentity 登录 → 回调(扩展会带上请求头过网关) +→ 登录态走会话 cookie,界面与 Agent API 正常工作。 + +## 注意 + +- **模型**:由 AgentKit 运行时提供;如需指定,可自行加 + `--runtime_envs MODEL_AGENT_NAME=... --runtime_envs MODEL_AGENT_API_KEY=...`。 +- **AK/SK**:本地构建部署要用;运行时调用 VeIdentity API(解析用户池、注册回调) + 也要用——运行时取不到角色凭证(IMDS 超时),必须注入 `runtime_envs`,否则容器启动即崩。 +- **重新部署**:改动环境变量后,`veadk agentkit config --runtime_envs K=V` 合并后重跑 + `veadk agentkit deploy` 即可,镜像层会复用。用 `veadk agentkit destroy` 拆除。 diff --git a/examples/sso_frontend_on_agentkit/agents/sso_demo_agent/__init__.py b/examples/sso_frontend_on_agentkit/agents/sso_demo_agent/__init__.py new file mode 100644 index 00000000..e1f5efff --- /dev/null +++ b/examples/sso_frontend_on_agentkit/agents/sso_demo_agent/__init__.py @@ -0,0 +1,17 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from . import agent + +__all__ = ["agent"] diff --git a/examples/sso_frontend_on_agentkit/agents/sso_demo_agent/agent.py b/examples/sso_frontend_on_agentkit/agents/sso_demo_agent/agent.py new file mode 100644 index 00000000..30357895 --- /dev/null +++ b/examples/sso_frontend_on_agentkit/agents/sso_demo_agent/agent.py @@ -0,0 +1,30 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Minimal backend agent for the SSO-frontend-on-AgentKit demo. + +A plain VeADK ``Agent`` (no callbacks). The model comes from the MODEL_AGENT_* +runtime environment variables. The Google ADK agent loader picks up ``root_agent``. +""" + +from veadk import Agent + +agent = Agent( + name="sso_demo_agent", + description="Demo agent served behind an SSO login page.", + instruction="You are a helpful assistant. Answer concisely.", +) + +# Required by the Google ADK agent loader. +root_agent = agent diff --git a/examples/sso_frontend_on_agentkit/app.py b/examples/sso_frontend_on_agentkit/app.py new file mode 100644 index 00000000..43ddc654 --- /dev/null +++ b/examples/sso_frontend_on_agentkit/app.py @@ -0,0 +1,167 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Deployable entry point: VeADK web UI (A2UI) behind VeIdentity SSO, on AgentKit. + +Serves the bundled web UI plus the ADK agent API for the agents under ``./agents`` +and protects them with VeIdentity OAuth2 single sign-on. SSO is configured purely +through runtime environment variables (see README). AgentKit runs this container +with ``python -m app`` on port 8000. + +Two adaptations make it work behind the AgentKit gateway (which authenticates every +request with the runtime key in the ``Authorization`` header and forwards that header +to the container): + +1. ``_StripGatewayAuth`` removes the gateway key from ``Authorization`` before the SSO + middleware runs, so SSO falls back to its session cookie instead of trying to decode + the opaque key as a user JWT ("Invalid JWT format"). A genuine user JWT is preserved. +2. The served ``index.html`` forwards the page querystring onto its ``/assets/*`` URLs, + so that if the gateway is configured to take the key from the query string, the + browser's subresource requests carry it too. + +Everything else comes from the pip-installed ``veadk-python`` (>= 0.5.39). +""" + +import os +import re +from pathlib import Path +from urllib.parse import urlsplit + +import uvicorn +import veadk +from fastapi import Request +from fastapi.responses import FileResponse, HTMLResponse +from fastapi.staticfiles import StaticFiles +from google.adk.cli.fast_api import get_fast_api_app +from veadk.auth.middleware.oauth2_auth import OAuth2Config, setup_oauth2 + +AGENTS_DIR = os.path.abspath(str(Path(__file__).resolve().parent / "agents")) +HOST = os.getenv("HOST", "0.0.0.0") +PORT = int(os.getenv("PORT", "8000")) +WEBUI = Path(veadk.__file__).resolve().parent / "webui" + +app = get_fast_api_app(agents_dir=AGENTS_DIR, allow_origins=[], web=False) + + +@app.get("/ping") +def ping() -> dict[str, str]: + return {"status": "ok"} + + +# ---- SSO: VeIdentity user pool (config from runtime env) ---- +redirect_uri = ( + os.getenv("OAUTH2_REDIRECT_URI") or f"http://{HOST}:{PORT}/oauth2/callback" +) +oauth2_config = OAuth2Config.from_veidentity( + user_pool_uid=os.environ["OAUTH2_USER_POOL_ID"], + client_uid=os.environ["OAUTH2_USER_POOL_CLIENT_ID"], + redirect_uri=redirect_uri, +) +# Secure cookies over HTTPS (runtime), plain over local HTTP. +oauth2_config.cookie_secure = redirect_uri.lower().startswith("https://") +origin = urlsplit(redirect_uri) +oauth2_config.logout_redirect_url = f"{origin.scheme}://{origin.netloc}/" +oauth2_config.end_session_url = None + +providers = [ + {"id": "veidentity", "label": "火山引擎 Identity", "loginUrl": "/oauth2/login"} +] + + +@app.get("/web/auth-config") +async def _web_auth_config() -> dict: + return {"providers": providers} + + +# Protect the API; exempt the SPA shell, assets, and the login-config endpoint so +# the app can load and render its own login page when unauthenticated. +setup_oauth2( + app, + oauth2_config, + exempt_paths={"/", "/index.html", "/favicon.ico", "/web/auth-config", "/ping"}, + exempt_prefixes={"/assets", "/skillhub"}, +) + +# ---- Serving with querystring injection ---- +_index_html = (WEBUI / "index.html").read_text(encoding="utf-8") +_ASSET_REF = re.compile(r'((?:src|href)=")(/[^"?]+)(")') + + +def _render_index(request: Request) -> HTMLResponse: + qs = request.url.query + if not qs: + return HTMLResponse(_index_html) + html = _ASSET_REF.sub( + lambda m: f"{m.group(1)}{m.group(2)}?{qs}{m.group(3)}", _index_html + ) + return HTMLResponse(html) + + +app.mount("/assets", StaticFiles(directory=str(WEBUI / "assets")), name="assets") + + +@app.get("/") +async def _spa_root(request: Request): + return _render_index(request) + + +# SPA fallback: real static files as-is, otherwise the injected HTML shell. +# Registered last so it never shadows the API routes above. +@app.get("/{path:path}") +async def _spa_fallback(path: str, request: Request): + candidate = WEBUI / path + if path and candidate.is_file(): + return FileResponse(str(candidate)) + return _render_index(request) + + +class _StripGatewayAuth: + """Drop a non-JWT ``Authorization`` header before the SSO middleware sees it. + + Behind the AgentKit gateway the runtime key rides in ``Authorization: Bearer + `` and the gateway forwards that header to this container. The SSO + middleware treats any ``Authorization`` header as the user's access token and + tries to decode it as a JWT — the opaque gateway key fails with "Invalid JWT + format", and the session cookie is never consulted. + + The gateway has already authenticated the request upstream, so the key is of + no use here. Remove it when it is not a well-formed JWT (3 dot-separated + parts), letting the SSO middleware fall back to the session cookie. A genuine + user JWT (e.g. from a programmatic client) is left untouched. + """ + + def __init__(self, app): + self.app = app + + async def __call__(self, scope, receive, send): + if scope["type"] == "http": + headers = scope.get("headers", []) + auth = next((v for k, v in headers if k == b"authorization"), None) + if auth is not None: + value = auth.split(b" ", 1)[1] if b" " in auth else auth + if len(value.split(b".")) != 3: # not a JWT -> gateway key + scope = { + **scope, + "headers": [ + (k, v) for k, v in headers if k != b"authorization" + ], + } + await self.app(scope, receive, send) + + +asgi_app = _StripGatewayAuth(app) + + +if __name__ == "__main__": + uvicorn.run(asgi_app, host=HOST, port=PORT) diff --git a/examples/sso_frontend_on_agentkit/requirements.txt b/examples/sso_frontend_on_agentkit/requirements.txt new file mode 100644 index 00000000..d9ec61e9 --- /dev/null +++ b/examples/sso_frontend_on_agentkit/requirements.txt @@ -0,0 +1,7 @@ +# Installed in the image by AgentKit's default `uv pip install -r requirements.txt`. +# +# veadk-python >= 0.5.39 ships the web UI (veadk/webui) and the VeIdentity OAuth2 +# middleware (veadk.auth.middleware.oauth2_auth). The gateway-specific handling +# (querystring-into-assets, stripping the gateway key from Authorization) lives in +# app.py, so no patched veadk build is needed. +veadk-python>=0.5.39 diff --git a/veadk/auth/middleware/oauth2_auth.py b/veadk/auth/middleware/oauth2_auth.py index 88ab10aa..6c7f716b 100644 --- a/veadk/auth/middleware/oauth2_auth.py +++ b/veadk/auth/middleware/oauth2_auth.py @@ -1688,6 +1688,16 @@ async def oauth2_middleware(request: Request, call_next): return await call_next(request) authorization = request.headers.get("authorization") + # Behind an API gateway that authenticates with a key in the Authorization + # header (e.g. an AgentKit runtime) the opaque gateway key arrives here. It + # is not the user's access token, and unless token introspection is enabled + # it cannot be a valid one (access tokens are JWTs). Ignore a non-JWT bearer + # so authentication falls back to the session cookie instead of failing with + # "Invalid JWT format"; a genuine user JWT is left untouched. + if authorization and not config.use_introspection: + _bearer = _extract_bearer_token(authorization) + if _bearer and len(_bearer.split(".")) != 3: + authorization = None if authorization: token = _extract_bearer_token(authorization) if not token: diff --git a/veadk/cli/cli_frontend.py b/veadk/cli/cli_frontend.py index ef8b0c6a..e1c09864 100644 --- a/veadk/cli/cli_frontend.py +++ b/veadk/cli/cli_frontend.py @@ -972,6 +972,9 @@ async def _web_auth_config(): f"run `cd frontend && npm run dev` and open {DEV_SERVER_ORIGIN}" ) else: + import re as _re + + from fastapi.responses import FileResponse, HTMLResponse from fastapi.staticfiles import StaticFiles webui = _resolve_frontend_dir(frontend_dir) @@ -981,8 +984,44 @@ async def _web_auth_config(): "cd frontend && npm install && npm run build " "(or use --dev for the Vite dev server)." ) - # Mount last so it doesn't shadow the API routes registered above. - app.mount("/", StaticFiles(directory=str(webui), html=True), name="frontend") + + _index_html = (webui / "index.html").read_text(encoding="utf-8") + _ASSET_REF = _re.compile(r'((?:src|href)=")(/[^"?]+)(")') + + def _render_index(request: Request) -> HTMLResponse: + # When behind a query-string API gateway (e.g. an AgentKit runtime + # with the key in the query string), the browser's subresource + # requests for /assets/* must also carry the key. The key arrives as + # the page's querystring; forward it onto every same-origin asset URL + # in the served HTML so those requests pass the gateway too. (The + # app's own API/navigation requests already forward it via auth.ts.) + qs = request.url.query + if not qs: + return HTMLResponse(_index_html) + html = _ASSET_REF.sub( + lambda m: f"{m.group(1)}{m.group(2)}?{qs}{m.group(3)}", _index_html + ) + return HTMLResponse(html) + + # Built assets (the gateway has already authorized the request). + app.mount( + "/assets", StaticFiles(directory=str(webui / "assets")), name="assets" + ) + + @app.get("/") + async def _spa_root(request: Request): + return _render_index(request) + + # SPA fallback: serve real static files as-is, otherwise return the + # (querystring-injected) HTML shell. Registered last so it never shadows + # the API routes above. + @app.get("/{path:path}") + async def _spa_fallback(path: str, request: Request): + candidate = webui / path + if path and candidate.is_file(): + return FileResponse(str(candidate)) + return _render_index(request) + logger.info( f"A2UI UI + API serving on http://{host}:{port} (UI: {webui}, agents: {agents_dir})" )