From a64b4c83115fea975decc12c0fe8befd7a8481da Mon Sep 17 00:00:00 2001 From: yiboyasss <3359595624@qq.com> Date: Thu, 9 Jul 2026 18:06:38 +0800 Subject: [PATCH 01/11] feat: allow widget embed to pass end-user identity via data-end-user-id --- frontend/public/widget.js | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/frontend/public/widget.js b/frontend/public/widget.js index 75f11e2ab..afab05aa3 100644 --- a/frontend/public/widget.js +++ b/frontend/public/widget.js @@ -103,11 +103,26 @@ var panel = document.createElement('div'); panel.className = 'xagent-widget-panel'; - // Generate guest_id if not exists - var guestId = localStorage.getItem('xagent_guest_id'); - if (!guestId) { - guestId = 'guest_' + Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15); - localStorage.setItem('xagent_guest_id', guestId); + // Identify the guest. If the embedding page knows who the visitor is + // (e.g. it renders the snippet server-side for a logged-in user), it can + // pass that identity via data-end-user-id so the widget session is scoped + // to that specific user instead of an anonymous per-browser id. This value + // is supplied by the embedding page on every load, so it is not cached. + var endUserId = (scriptTag.getAttribute('data-end-user-id') || '').trim(); + var guestId; + if (endUserId) { + // Match the backend's guest_id length cap (see WidgetAuthRequest) so an + // oversized value fails fast client-side instead of a 422 from /auth. + guestId = endUserId.substring(0, 256); + } else { + // No end-user identity provided: fall back to an anonymous per-browser + // id, generated once and cached so the same browser resumes the same + // conversation across page loads. + guestId = localStorage.getItem('xagent_guest_id'); + if (!guestId) { + guestId = 'guest_' + Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15); + localStorage.setItem('xagent_guest_id', guestId); + } } // Iframe From c92e5437959c5e791bc279e69246d5c38be20876 Mon Sep 17 00:00:00 2001 From: yiboyasss <3359595624@qq.com> Date: Fri, 10 Jul 2026 15:26:59 +0800 Subject: [PATCH 02/11] feat: resolve widget guest identity to a resumable task across devices --- .../widget/public-agent-chat-page.tsx | 61 +++++++++++++++---- src/xagent/web/api/public_chat_access.py | 28 +++++++++ src/xagent/web/api/widget.py | 15 ++++- src/xagent/web/schemas/chat.py | 6 ++ 4 files changed, 96 insertions(+), 14 deletions(-) diff --git a/frontend/src/components/widget/public-agent-chat-page.tsx b/frontend/src/components/widget/public-agent-chat-page.tsx index 73d71db3b..1b51d0cb4 100644 --- a/frontend/src/components/widget/public-agent-chat-page.tsx +++ b/frontend/src/components/widget/public-agent-chat-page.tsx @@ -68,24 +68,59 @@ function PublicConversationContent({ const publicApiPrefix = authMode === "share" ? "/api/share" : "/api/widget" useEffect(() => { + let cancelled = false setHasResolvedStoredTask(false) - const savedTaskId = localStorage.getItem(storageKey) - if (!savedTaskId) { - setTaskId(null, { navigate: false }) - setHasResolvedStoredTask(true) - return + + const readStoredTaskId = () => { + const savedTaskId = localStorage.getItem(storageKey) + if (!savedTaskId) return null + const parsedTaskId = parseInt(savedTaskId, 10) + return Number.isNaN(parsedTaskId) ? null : parsedTaskId } - const parsedTaskId = parseInt(savedTaskId, 10) - if (Number.isNaN(parsedTaskId)) { - setTaskId(null, { navigate: false }) - setHasResolvedStoredTask(true) - return + const resolveTaskId = async () => { + const storedTaskId = readStoredTaskId() + if (storedTaskId !== null) { + if (!cancelled) { + setTaskId(storedTaskId, { navigate: false }) + setHasResolvedStoredTask(true) + } + return + } + + // Nothing cached on this browser. Widget guests may carry a stable + // end-user identity (data-end-user-id) rather than a per-browser + // random id, so check whether this guest already has a conversation + // from another device/browser before starting a new one. + if (authMode === "widget") { + try { + const response = await fetch(`${getApiUrl()}${publicApiPrefix}/tasks/latest`, { + headers: { "Authorization": `Bearer ${accessToken}` }, + }) + if (response.ok) { + const data = await response.json() + if (!cancelled && typeof data.task_id === "number") { + setTaskId(data.task_id, { navigate: false }) + setHasResolvedStoredTask(true) + return + } + } + } catch { + // Fall through to a fresh conversation if the lookup fails. + } + } + + if (!cancelled) { + setTaskId(null, { navigate: false }) + setHasResolvedStoredTask(true) + } } - setTaskId(parsedTaskId, { navigate: false }) - setHasResolvedStoredTask(true) - }, [setTaskId, storageKey]) + resolveTaskId() + return () => { + cancelled = true + } + }, [accessToken, authMode, publicApiPrefix, setTaskId, storageKey]) useEffect(() => { if (!hasResolvedStoredTask) { diff --git a/src/xagent/web/api/public_chat_access.py b/src/xagent/web/api/public_chat_access.py index ae1aafe3e..879424dd0 100644 --- a/src/xagent/web/api/public_chat_access.py +++ b/src/xagent/web/api/public_chat_access.py @@ -250,6 +250,34 @@ def get_task_for_public_context( return task +def get_latest_public_chat_task( + db: Session, access_context: PublicChatAccessContext +) -> Task | None: + """Find the most recent widget task for this guest identity. + + A guest_id derived from a random per-browser id (the default when the + embedding page passes no end-user identity) is only ever seen from one + browser, so this naturally returns nothing for it. A guest_id supplied by + the embedding page via data-end-user-id is stable across devices, so a + returning guest can resume their prior conversation instead of starting a + new one just because local storage is empty on this browser. + """ + if access_context.widget_agent_id is None: + return None + return ( + db.query(Task) + .filter( + Task.user_id == access_context.user.id, + Task.agent_id == access_context.widget_agent_id, + Task.channel_id.is_(access_context.channel_id), + Task.source == "widget", + Task.agent_config["guest_id"].as_string() == access_context.guest_id, + ) + .order_by(Task.id.desc()) + .first() + ) + + def get_task_for_share_context( db: Session, task_id: int, access_context: ShareChatAccessContext ) -> Task: diff --git a/src/xagent/web/api/widget.py b/src/xagent/web/api/widget.py index fbe7d64e7..a60b65d84 100644 --- a/src/xagent/web/api/widget.py +++ b/src/xagent/web/api/widget.py @@ -23,7 +23,7 @@ from ..models.agent import Agent, is_workforce_generated_manager_agent from ..models.database import get_db from ..models.user import User -from ..schemas.chat import TaskCreateRequest, TaskCreateResponse +from ..schemas.chat import LatestTaskResponse, TaskCreateRequest, TaskCreateResponse from .auth import create_access_token from .public_chat_access import ( PublicChatAccessContext, @@ -31,6 +31,7 @@ build_public_chat_dependency, create_public_chat_access_token, create_public_chat_task, + get_latest_public_chat_task, public_chat_websocket_endpoint, upload_public_chat_files, ) @@ -299,6 +300,18 @@ async def upload_widget_file( ) +@widget_router.get("/tasks/latest", response_model=LatestTaskResponse) +async def get_latest_widget_task( + widget_info: PublicChatAccessContext = Depends(get_current_widget_user_dep), + db: Session = Depends(get_db), +) -> Any: + """Look up the guest's most recent task, so a returning guest with a + stable end-user identity (data-end-user-id) can resume it on a new + browser/device instead of local storage forcing a fresh conversation.""" + task = get_latest_public_chat_task(db, widget_info) + return LatestTaskResponse(task_id=int(task.id) if task else None) + + @widget_router.post("/chat/task/create", response_model=TaskCreateResponse) async def create_widget_task( request: TaskCreateRequest, diff --git a/src/xagent/web/schemas/chat.py b/src/xagent/web/schemas/chat.py index 50a09dcd8..b1b4a58cd 100644 --- a/src/xagent/web/schemas/chat.py +++ b/src/xagent/web/schemas/chat.py @@ -110,6 +110,12 @@ class TaskCreateResponse(BaseModel): control_state: str = "idle" +class LatestTaskResponse(BaseModel): + """Most recent task for a guest identity, if one exists""" + + task_id: Optional[int] = None + + class ExecutionStatus(BaseModel): """Execution status model""" From f8226849706f86907cf00d94c0eb2dac50431009 Mon Sep 17 00:00:00 2001 From: yiboyasss <3359595624@qq.com> Date: Fri, 10 Jul 2026 15:31:58 +0800 Subject: [PATCH 03/11] fix(widget): url-encode guest_id in the iframe query string --- frontend/public/widget.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/public/widget.js b/frontend/public/widget.js index afab05aa3..7a649eec0 100644 --- a/frontend/public/widget.js +++ b/frontend/public/widget.js @@ -134,7 +134,7 @@ // The widget key is deliberately NOT placed in the iframe URL: the ticket // is sufficient to authenticate, and keeping the key out of the frame // means the embedded widget has no credential to fall back on. - var url = host + '/widget/chat/' + token + '?guest_id=' + guestId; + var url = host + '/widget/chat/' + token + '?guest_id=' + encodeURIComponent(guestId); if (agentId) { url += '&agent_id=' + encodeURIComponent(agentId); } From 3fcf811b6a60adc3a44823c753738fa82a01e9b4 Mon Sep 17 00:00:00 2001 From: yiboyasss <3359595624@qq.com> Date: Fri, 10 Jul 2026 15:44:13 +0800 Subject: [PATCH 04/11] fix(widget): guard channel_id filter for non-null values in latest-task lookup --- src/xagent/web/api/public_chat_access.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/xagent/web/api/public_chat_access.py b/src/xagent/web/api/public_chat_access.py index 879424dd0..fd3715cad 100644 --- a/src/xagent/web/api/public_chat_access.py +++ b/src/xagent/web/api/public_chat_access.py @@ -269,7 +269,9 @@ def get_latest_public_chat_task( .filter( Task.user_id == access_context.user.id, Task.agent_id == access_context.widget_agent_id, - Task.channel_id.is_(access_context.channel_id), + Task.channel_id.is_(access_context.channel_id) + if access_context.channel_id is None + else Task.channel_id == access_context.channel_id, Task.source == "widget", Task.agent_config["guest_id"].as_string() == access_context.guest_id, ) From 6e9dd8fe5cf72e154a355317e452e2a6f1f0aecc Mon Sep 17 00:00:00 2001 From: yiboyasss <3359595624@qq.com> Date: Fri, 10 Jul 2026 16:35:38 +0800 Subject: [PATCH 05/11] fix(widget): require a verified signature for data-end-user-id --- frontend/public/widget.js | 31 +++++-- .../app/widget/chat/[token]/page-client.tsx | 2 + .../widget/public-agent-chat-page.tsx | 15 ++- ...260710_add_agent_widget_end_user_secret.py | 42 +++++++++ src/xagent/web/api/agents.py | 93 ++++++++++++++++++- src/xagent/web/api/widget.py | 81 +++++++++++++++- src/xagent/web/models/agent.py | 6 ++ src/xagent/web/services/agent_store.py | 7 ++ 8 files changed, 263 insertions(+), 14 deletions(-) create mode 100644 src/xagent/migrations/versions/20260710_add_agent_widget_end_user_secret.py diff --git a/frontend/public/widget.js b/frontend/public/widget.js index 7a649eec0..cc4ef172b 100644 --- a/frontend/public/widget.js +++ b/frontend/public/widget.js @@ -108,14 +108,23 @@ // pass that identity via data-end-user-id so the widget session is scoped // to that specific user instead of an anonymous per-browser id. This value // is supplied by the embedding page on every load, so it is not cached. + // + // An identity claim is only trustworthy if it's signed: without + // data-end-user-signature, anyone viewing this page's source (or calling + // /api/widget/auth directly) could claim to be any other user and read + // their conversation history. The signature must be computed server-side + // by the embedding site using the agent's widget_end_user_secret (from + // App Widget settings) -- never compute it in browser JS, which would + // expose the secret to the same visitors it's meant to authenticate. var endUserId = (scriptTag.getAttribute('data-end-user-id') || '').trim(); - var guestId; - if (endUserId) { - // Match the backend's guest_id length cap (see WidgetAuthRequest) so an - // oversized value fails fast client-side instead of a 422 from /auth. - guestId = endUserId.substring(0, 256); - } else { - // No end-user identity provided: fall back to an anonymous per-browser + var endUserSignature = (scriptTag.getAttribute('data-end-user-signature') || '').trim(); + var guestId = null; + if (endUserId && !endUserSignature) { + console.error('Xagent Widget: data-end-user-id was provided without data-end-user-signature; ignoring it and falling back to an anonymous session. Sign end_user_id server-side with the agent\'s end-user secret (see App Widget settings).'); + endUserId = ''; + } + if (!endUserId) { + // No verified end-user identity: fall back to an anonymous per-browser // id, generated once and cached so the same browser resumes the same // conversation across page loads. guestId = localStorage.getItem('xagent_guest_id'); @@ -134,7 +143,13 @@ // The widget key is deliberately NOT placed in the iframe URL: the ticket // is sufficient to authenticate, and keeping the key out of the frame // means the embedded widget has no credential to fall back on. - var url = host + '/widget/chat/' + token + '?guest_id=' + encodeURIComponent(guestId); + var url = host + '/widget/chat/' + token; + if (endUserId) { + url += '?end_user_id=' + encodeURIComponent(endUserId.substring(0, 256)) + + '&end_user_signature=' + encodeURIComponent(endUserSignature); + } else { + url += '?guest_id=' + encodeURIComponent(guestId); + } if (agentId) { url += '&agent_id=' + encodeURIComponent(agentId); } diff --git a/frontend/src/app/widget/chat/[token]/page-client.tsx b/frontend/src/app/widget/chat/[token]/page-client.tsx index a753c2d6e..6aa4979f8 100644 --- a/frontend/src/app/widget/chat/[token]/page-client.tsx +++ b/frontend/src/app/widget/chat/[token]/page-client.tsx @@ -14,6 +14,8 @@ function WidgetChatInner() { authMode="widget" routeToken={token} guestId={searchParams.get("guest_id")} + endUserId={searchParams.get("end_user_id")} + endUserSignature={searchParams.get("end_user_signature")} searchAgentId={searchParams.get("agent_id") ? parseInt(searchParams.get("agent_id") as string, 10) : null} embedTicket={searchParams.get("embed_ticket")} widgetKey={searchParams.get("widget_key")} diff --git a/frontend/src/components/widget/public-agent-chat-page.tsx b/frontend/src/components/widget/public-agent-chat-page.tsx index 1b51d0cb4..f9c15ba4f 100644 --- a/frontend/src/components/widget/public-agent-chat-page.tsx +++ b/frontend/src/components/widget/public-agent-chat-page.tsx @@ -18,6 +18,8 @@ interface PublicAgentChatPageProps { authMode: "widget" | "share" routeToken: string guestId?: string | null + endUserId?: string | null + endUserSignature?: string | null searchAgentId?: number | null embedTicket?: string | null widgetKey?: string | null @@ -295,12 +297,17 @@ export function PublicAgentChatPage({ authMode, routeToken, guestId, + endUserId, + endUserSignature, searchAgentId = null, embedTicket = null, widgetKey = null, }: PublicAgentChatPageProps) { const { t } = useI18n() - const normalizedGuestId = authMode === "widget" ? (guestId || "anonymous") : null + // A verified end-user identity takes precedence over the anonymous + // per-browser guest_id for cache-key purposes too, so the same person + // resumes their conversation across devices/browsers. + const normalizedGuestId = authMode === "widget" ? (endUserId || guestId || "anonymous") : null const [isInitializing, setIsInitializing] = useState(true) const [errorMessage, setErrorMessage] = useState(null) const [authResult, setAuthResult] = useState(null) @@ -312,7 +319,11 @@ export function PublicAgentChatPage({ const authPayload = authMode === "share" ? { share_token: routeToken } : { - guest_id: normalizedGuestId, + // end_user_id/end_user_signature identify a specific, signed + // user; guest_id is only sent for the anonymous fallback. + ...(endUserId + ? { end_user_id: endUserId, end_user_signature: endUserSignature } + : { guest_id: normalizedGuestId }), agent_id: searchAgentId, embed_ticket: embedTicket || undefined, // Direct visits (no embed ticket) authenticate with the widget diff --git a/src/xagent/migrations/versions/20260710_add_agent_widget_end_user_secret.py b/src/xagent/migrations/versions/20260710_add_agent_widget_end_user_secret.py new file mode 100644 index 000000000..d540a2914 --- /dev/null +++ b/src/xagent/migrations/versions/20260710_add_agent_widget_end_user_secret.py @@ -0,0 +1,42 @@ +"""add agent widget end-user signing secret + +Revision ID: 20260710_add_widget_end_user_secret +Revises: 1c2ae61b5a6d +Create Date: 2026-07-10 00:00:00.000000 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "20260710_add_widget_end_user_secret" +down_revision: Union[str, tuple[str, str], None] = "1c2ae61b5a6d" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + if "agents" not in inspector.get_table_names(): + return + + existing_columns = {col["name"] for col in inspector.get_columns("agents")} + if "widget_end_user_secret" not in existing_columns: + op.add_column( + "agents", + sa.Column("widget_end_user_secret", sa.String(length=255), nullable=True), + ) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + if "agents" not in inspector.get_table_names(): + return + + existing_columns = {col["name"] for col in inspector.get_columns("agents")} + if "widget_end_user_secret" in existing_columns: + op.drop_column("agents", "widget_end_user_secret") diff --git a/src/xagent/web/api/agents.py b/src/xagent/web/api/agents.py index 326c1b2a1..fde82c099 100644 --- a/src/xagent/web/api/agents.py +++ b/src/xagent/web/api/agents.py @@ -36,7 +36,11 @@ DuplicateAgentNameError, TemplateNotFoundError, ) -from ..services.agent_store import AgentStore, new_widget_key +from ..services.agent_store import ( + AgentStore, + new_widget_end_user_secret, + new_widget_key, +) from ..services.api_keys import AgentApiKeyService, KeyRotationConflict from ..services.llm_utils import UserAwareModelStorage from ..tools.config import WebToolConfig @@ -161,6 +165,17 @@ class AgentWidgetKeyResponse(BaseModel): widget_key: str +class AgentWidgetEndUserSecretResponse(BaseModel): + """Owner-only HMAC secret for signing data-end-user-id claims. + + Never returned by any endpoint reachable from the embed snippet or the + widget iframe -- only from this owner-authenticated route. + """ + + agent_id: int + widget_end_user_secret: str + + class PublishResponse(BaseModel): """Response model for publish/unpublish operations.""" @@ -950,6 +965,82 @@ async def rotate_agent_widget_key( raise HTTPException(status_code=500, detail=str(e)) +@router.get( + "/{agent_id}/widget-end-user-secret", + response_model=AgentWidgetEndUserSecretResponse, +) +async def get_agent_widget_end_user_secret( + agent_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +) -> AgentWidgetEndUserSecretResponse: + """Return the owner-only HMAC secret for signing data-end-user-id claims, + generating one if missing. Use this secret server-side, on the embedding + site, to sign each end user's id -- never expose it in the embed snippet + or any client-side code.""" + try: + store = AgentStore(db) + user_id = int(current_user.id) + agent = store.get_owned_agent(user_id, agent_id) + if agent is None: + raise HTTPException(status_code=404, detail="Agent not found") + if not agent.widget_end_user_secret: + agent = store.update_agent_fields( + user_id, + agent_id, + {"widget_end_user_secret": new_widget_end_user_secret()}, + ) + if agent is None: + raise HTTPException(status_code=404, detail="Agent not found") + return AgentWidgetEndUserSecretResponse( + agent_id=int(agent.id), + widget_end_user_secret=str(agent.widget_end_user_secret), + ) + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to get widget end-user secret for agent {agent_id}: {e}") + db.rollback() + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post( + "/{agent_id}/widget-end-user-secret/rotate", + response_model=AgentWidgetEndUserSecretResponse, +) +async def rotate_agent_widget_end_user_secret( + agent_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +) -> AgentWidgetEndUserSecretResponse: + """Rotate the end-user signing secret, invalidating signatures computed + with the previous value.""" + try: + store = AgentStore(db) + user_id = int(current_user.id) + if store.get_owned_agent(user_id, agent_id) is None: + raise HTTPException(status_code=404, detail="Agent not found") + agent = store.update_agent_fields( + user_id, + agent_id, + {"widget_end_user_secret": new_widget_end_user_secret()}, + ) + if agent is None: + raise HTTPException(status_code=404, detail="Agent not found") + return AgentWidgetEndUserSecretResponse( + agent_id=int(agent.id), + widget_end_user_secret=str(agent.widget_end_user_secret), + ) + except HTTPException: + raise + except Exception as e: + logger.error( + f"Failed to rotate widget end-user secret for agent {agent_id}: {e}" + ) + db.rollback() + raise HTTPException(status_code=500, detail=str(e)) + + @router.post("/{agent_id}/logo", response_model=dict) async def upload_agent_logo( agent_id: int, diff --git a/src/xagent/web/api/widget.py b/src/xagent/web/api/widget.py index a60b65d84..26db40b49 100644 --- a/src/xagent/web/api/widget.py +++ b/src/xagent/web/api/widget.py @@ -1,5 +1,7 @@ """Web Widget API route handlers.""" +import hashlib +import hmac from datetime import timedelta from typing import Any, Optional from urllib.parse import urlparse @@ -16,7 +18,7 @@ WebSocket, ) from jose import JWTError, jwt -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, model_validator from sqlalchemy.orm import Session from ..auth_config import JWT_ALGORITHM, JWT_SECRET_KEY @@ -52,9 +54,32 @@ "Re-copy the embed snippet from the agent's App Widget settings." ) +# Namespace for guest_ids backed by a verified end-user identity (see +# _resolve_verified_guest_id). Reserved so a client can never forge a +# "verified" guest_id for someone else's end_user_id without producing a +# valid HMAC signature for it -- request validation rejects any +# client-supplied guest_id that starts with this prefix outright. +VERIFIED_END_USER_GUEST_ID_PREFIX = "verified_end_user:" + class WidgetAuthRequest(BaseModel): - guest_id: str = Field(max_length=256) + # Opaque, unauthenticated per-browser session id (the widget's own random + # anonymous fallback). Not used to assert a real identity, so it needs no + # verification -- knowledge of this high-entropy value is itself the only + # "credential" it ever carried. Reserved-prefix values are rejected below + # since only a verified end_user_id may produce one (see + # _resolve_verified_guest_id). + guest_id: Optional[str] = Field(default=None, max_length=256) + # A real end-user identity from the embedding page (data-end-user-id), + # scoped to a specific user/tenant rather than an anonymous browser. Must + # be accompanied by end_user_signature: an unverified identity claim is a + # BOLA/IDOR risk (anyone could claim to be any user), unlike guest_id + # which was never meant to assert who someone is. + end_user_id: Optional[str] = Field(default=None, max_length=256) + # hex-encoded HMAC-SHA256(agent.widget_end_user_secret, end_user_id), + # computed server-side by the embedding site and never exposed to the + # browser it's minted for. + end_user_signature: Optional[str] = Field(default=None, max_length=128) # Retained for backward compatibility with older embed pages; the agent is # authoritatively resolved from the embed ticket or widget key, never from # this client-supplied id. @@ -65,6 +90,14 @@ class WidgetAuthRequest(BaseModel): # Direct (non-embedded) visits carry the widget key instead of a ticket. widget_key: Optional[str] = Field(default=None, max_length=512) + @model_validator(mode="after") + def _require_an_identity(self) -> "WidgetAuthRequest": + if not self.guest_id and not self.end_user_id: + raise ValueError("Either guest_id or end_user_id is required") + if self.end_user_id and not self.end_user_signature: + raise ValueError("end_user_signature is required when end_user_id is set") + return self + class EmbedTicketRequest(BaseModel): # The widget key is the unguessable per-agent credential distributed in the @@ -239,6 +272,46 @@ def _resolve_widget_auth_agent(db: Session, request: WidgetAuthRequest) -> Agent raise HTTPException(status_code=403, detail=WIDGET_CREDENTIAL_REQUIRED_DETAIL) +def _resolve_authenticated_guest_id(agent: Agent, request: WidgetAuthRequest) -> str: + """Resolve the guest_id to embed in the guest token. + + An end_user_id claim only becomes a guest_id if it carries a valid HMAC + signature (proving the embedding site's own server vouched for it, not + just whatever the browser sent) -- otherwise anyone could claim to be any + user and read their conversation history via /tasks/latest. A bare + guest_id is left as an opaque, unauthenticated per-browser id exactly as + before, except it may never itself forge into the reserved "verified" + namespace: that would let a request skip signing by simply omitting + end_user_id and passing the target guest_id directly. + """ + if request.end_user_id: + secret = agent.widget_end_user_secret + if not secret: + raise HTTPException( + status_code=403, + detail=( + "This agent has no end-user signing secret configured; " + "generate one from the agent's App Widget settings before " + "sending data-end-user-id." + ), + ) + expected_signature = hmac.new( + secret.encode("utf-8"), + request.end_user_id.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + if not hmac.compare_digest( + expected_signature, request.end_user_signature or "" + ): + raise HTTPException(status_code=403, detail="Invalid end-user signature") + return f"{VERIFIED_END_USER_GUEST_ID_PREFIX}{request.end_user_id}" + + guest_id = request.guest_id or "" + if guest_id.startswith(VERIFIED_END_USER_GUEST_ID_PREFIX): + raise HTTPException(status_code=403, detail="Invalid guest_id") + return guest_id + + @widget_router.post("/auth", response_model=WidgetAuthResponse) async def authenticate_widget( request: WidgetAuthRequest, @@ -253,12 +326,14 @@ async def authenticate_widget( status_code=401, detail="Widget owner not found or invalid agent_id" ) + guest_id = _resolve_authenticated_guest_id(agent, request) + access_token = create_public_chat_access_token( { "sub": user.username, "user_id": user.id, "channel_id": None, - "guest_id": request.guest_id, + "guest_id": guest_id, "auth_mode": "widget", "widget_agent_id": int(agent.id), } diff --git a/src/xagent/web/models/agent.py b/src/xagent/web/models/agent.py index 8e62d0c9d..887bff9a0 100644 --- a/src/xagent/web/models/agent.py +++ b/src/xagent/web/models/agent.py @@ -74,6 +74,12 @@ class Agent(Base): # type: ignore # real access gate for widget guest tokens (allowed_domains is only a # browser-level restriction). Owner-visible, rotatable. widget_key = Column(String(255), nullable=True, unique=True, index=True) + # Secret used to verify HMAC-signed data-end-user-id claims (see + # /api/widget/auth). Unlike widget_key, this must never be returned by any + # endpoint reachable from the embed snippet or the widget iframe -- it is + # owner-only, fetched from the agent builder and used server-side by the + # embedding site to sign end-user ids, never shipped to a browser. + widget_end_user_secret = Column(String(255), nullable=True) share_enabled = Column(Boolean, default=False, nullable=False) share_token = Column(String(255), nullable=True, index=True) share_updated_at = Column(DateTime(timezone=True), nullable=True) diff --git a/src/xagent/web/services/agent_store.py b/src/xagent/web/services/agent_store.py index 28c14dd93..4109ef0f1 100644 --- a/src/xagent/web/services/agent_store.py +++ b/src/xagent/web/services/agent_store.py @@ -35,6 +35,7 @@ "widget_enabled", "allowed_domains", "widget_key", + "widget_end_user_secret", "share_enabled", "share_token", "share_updated_at", @@ -50,6 +51,12 @@ def new_widget_key() -> str: return secrets.token_urlsafe(32) +def new_widget_end_user_secret() -> str: + """Generate the HMAC secret used to verify signed end-user identity + claims. Owner-only: never returned by the embed-ticket/auth flow.""" + return secrets.token_urlsafe(32) + + class AgentStore: """Owns Agent reads/writes that participate in hot-path cache policy.""" From 4feb525cca437ab9258f33e66cdddfc15432bba0 Mon Sep 17 00:00:00 2001 From: yiboyasss <3359595624@qq.com> Date: Fri, 10 Jul 2026 18:35:00 +0800 Subject: [PATCH 06/11] feat(widget): add UI to view/rotate the end-user signing secret --- .../build/agent-builder-preview.test.tsx | 157 ----- .../src/components/build/agent-builder.tsx | 92 +-- .../agent-widget-settings-dialog.test.tsx | 340 ---------- .../build/agent-widget-settings-dialog.tsx | 384 ------------ .../components/build/deploy-agent-dialog.tsx | 589 ++++++++++++------ frontend/src/i18n/locales/en.ts | 35 +- frontend/src/i18n/locales/zh.ts | 35 +- frontend/src/lib/agent-widget-config.ts | 36 ++ 8 files changed, 446 insertions(+), 1222 deletions(-) delete mode 100644 frontend/src/components/build/agent-widget-settings-dialog.test.tsx delete mode 100644 frontend/src/components/build/agent-widget-settings-dialog.tsx diff --git a/frontend/src/components/build/agent-builder-preview.test.tsx b/frontend/src/components/build/agent-builder-preview.test.tsx index da1ef0576..ccd0c8144 100644 --- a/frontend/src/components/build/agent-builder-preview.test.tsx +++ b/frontend/src/components/build/agent-builder-preview.test.tsx @@ -304,161 +304,4 @@ describe("AgentBuilder preview", () => { expect(closeFilePreviewMock).toHaveBeenCalledTimes(1) }) - it("shows App Widget settings for an existing agent without adding it to trigger dialog", async () => { - apiRequestMock.mockImplementation((url: string) => { - if (url === "http://api.local/api/agents/42") { - return Promise.resolve(new Response(JSON.stringify({ - id: 42, - name: "Widget Agent", - description: "Helps website visitors", - instructions: "Answer visitor questions", - execution_mode: "balanced", - suggested_prompts: [], - knowledge_bases: [], - skills: [], - tool_categories: [], - models: { general: 7 }, - logo_url: null, - status: "draft", - widget_enabled: true, - allowed_domains: ["example.com"], - }), { status: 200 })) - } - if (url === "http://api.local/api/agents/42/triggers") { - return Promise.resolve(new Response(JSON.stringify([]), { status: 200 })) - } - if (url.endsWith("/api/agents/42/widget-key")) { - return Promise.resolve(new Response(JSON.stringify({ - agent_id: 42, - widget_enabled: true, - widget_key: "wk-preview-key", - }), { status: 200 })) - } - if (url.endsWith("/api/kb/collections")) { - return Promise.resolve(new Response(JSON.stringify({ collections: [] }), { status: 200 })) - } - if (url.endsWith("/api/skills/")) { - return Promise.resolve(new Response(JSON.stringify([]), { status: 200 })) - } - if (url.endsWith("/api/tools/available")) { - return Promise.resolve(new Response(JSON.stringify({ tools: [] }), { status: 200 })) - } - if (url.endsWith("/api/models/?category=llm")) { - return Promise.resolve(new Response(JSON.stringify([ - { id: 7, model_id: "gpt-test", model_name: "GPT Test", model_provider: "test", category: "llm" }, - ]), { status: 200 })) - } - if (url.endsWith("/api/models/user-default")) { - return Promise.resolve(new Response(JSON.stringify([]), { status: 200 })) - } - if (url.endsWith("/api/mcp/servers")) { - return Promise.resolve(new Response(JSON.stringify([]), { status: 200 })) - } - return Promise.resolve(new Response(JSON.stringify({}), { status: 200 })) - }) - - render() - - expect(await screen.findByText("appWidget.builder.title")).toBeInTheDocument() - expect(screen.getByRole("switch", { name: "appWidget.builder.toggle" })).toBeChecked() - - fireEvent.click(screen.getByText("appWidget.builder.title")) - - expect(await screen.findByText("appWidget.dialog.title")).toBeInTheDocument() - expect(screen.getByText("example.com")).toBeInTheDocument() - expect(screen.getByText((content) => content.includes("widget.js"))).toBeInTheDocument() - expect( - await screen.findByText((content) => content.includes('data-widget-key="wk-preview-key"')), - ).toBeInTheDocument() - expect(screen.queryByText("triggers.cards.appWidget.title")).not.toBeInTheDocument() - }) - - it("updates widget_enabled when the builder card switch is toggled", async () => { - const widgetAgent = { - id: 42, - name: "Widget Agent", - description: "Helps website visitors", - instructions: "Answer visitor questions", - execution_mode: "balanced", - suggested_prompts: [], - knowledge_bases: [], - skills: [], - tool_categories: [], - models: { general: 7 }, - logo_url: null, - status: "draft", - widget_enabled: true, - allowed_domains: ["example.com"], - } - apiRequestMock.mockImplementation((url: string, options?: { method?: string; body?: string }) => { - if (url === "http://api.local/api/agents/42" && options?.method === "PUT") { - const updates = options?.body ? JSON.parse(options.body) : {} - return Promise.resolve(new Response(JSON.stringify({ ...widgetAgent, ...updates }), { status: 200 })) - } - if (url === "http://api.local/api/agents/42") { - return Promise.resolve(new Response(JSON.stringify(widgetAgent), { status: 200 })) - } - if (url === "http://api.local/api/agents/42/triggers") { - return Promise.resolve(new Response(JSON.stringify([]), { status: 200 })) - } - if (url.endsWith("/api/agents/42/widget-key")) { - return Promise.resolve(new Response(JSON.stringify({ - agent_id: 42, - widget_enabled: true, - widget_key: "wk-preview-key", - }), { status: 200 })) - } - if (url.endsWith("/api/kb/collections")) { - return Promise.resolve(new Response(JSON.stringify({ collections: [] }), { status: 200 })) - } - if (url.endsWith("/api/skills/")) { - return Promise.resolve(new Response(JSON.stringify([]), { status: 200 })) - } - if (url.endsWith("/api/tools/available")) { - return Promise.resolve(new Response(JSON.stringify({ tools: [] }), { status: 200 })) - } - if (url.endsWith("/api/models/?category=llm")) { - return Promise.resolve(new Response(JSON.stringify([ - { id: 7, model_id: "gpt-test", model_name: "GPT Test", model_provider: "test", category: "llm" }, - ]), { status: 200 })) - } - if (url.endsWith("/api/models/user-default")) { - return Promise.resolve(new Response(JSON.stringify([]), { status: 200 })) - } - if (url.endsWith("/api/mcp/servers")) { - return Promise.resolve(new Response(JSON.stringify([]), { status: 200 })) - } - return Promise.resolve(new Response(JSON.stringify({}), { status: 200 })) - }) - - render() - - const widgetSwitch = await screen.findByRole("switch", { name: "appWidget.builder.toggle" }) - expect(widgetSwitch).toBeChecked() - - fireEvent.click(widgetSwitch) - - await waitFor(() => { - expect(apiRequestMock).toHaveBeenCalledWith("http://api.local/api/agents/42", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ widget_enabled: false }), - }) - }) - await waitFor(() => { - expect(screen.getByRole("switch", { name: "appWidget.builder.toggle" })).not.toBeChecked() - }) - }) - - it("shows a save-first App Widget state before an agent exists", async () => { - render() - - expect(await screen.findByText("appWidget.builder.title")).toBeInTheDocument() - expect(screen.getByText("appWidget.builder.saveFirst")).toBeInTheDocument() - expect(screen.getByTitle("appWidget.builder.configure")).toBeDisabled() - - fireEvent.click(screen.getByTitle("appWidget.builder.configure")) - - expect(screen.queryByText("appWidget.dialog.title")).not.toBeInTheDocument() - }) }) diff --git a/frontend/src/components/build/agent-builder.tsx b/frontend/src/components/build/agent-builder.tsx index 575c1f9e0..59b7c6de0 100644 --- a/frontend/src/components/build/agent-builder.tsx +++ b/frontend/src/components/build/agent-builder.tsx @@ -10,7 +10,7 @@ import { Label } from "@/components/ui/label" import { Switch } from "@/components/ui/switch" import { apiRequest } from "@/lib/api-wrapper" import { getApiUrl } from "@/lib/utils" -import { PlusCircle, MessageSquare, Upload, Settings2, Check, Zap, BookOpen, ChevronLeft, Gauge, Sparkles, Loader2, X, XCircle, Trash2, Bot, Brain, Webhook, CalendarClock, Mail, Code2 } from "lucide-react" +import { PlusCircle, MessageSquare, Upload, Settings2, Check, Zap, BookOpen, ChevronLeft, Gauge, Sparkles, Loader2, X, XCircle, Trash2, Bot, Brain, Webhook, CalendarClock, Mail } from "lucide-react" import { ConnectMcpDialog } from "@/components/mcp/connect-mcp-dialog" import { useI18n } from "@/contexts/i18n-context" import { useApp } from "@/contexts/app-context-chat" @@ -42,8 +42,6 @@ import { BuildFilePreviewSheet } from "./build-file-preview-sheet" import { TaskConversationPanel } from "@/components/task/task-conversation-panel" import { AgentTriggersDialog } from "./agent-triggers-dialog" import { AgentTrigger, AgentTriggerType, listAgentTriggers } from "@/lib/agent-triggers-api" -import { AgentWidgetSettingsDialog } from "./agent-widget-settings-dialog" -import { updateAgentWidgetConfig } from "@/lib/agent-widget-config" interface KnowledgeBase { name: string @@ -145,8 +143,6 @@ export function AgentBuilder({ agentId }: AgentBuilderProps) { const [isKbModalOpen, setIsKbModalOpen] = useState(false) const [isModelConfigOpen, setIsModelConfigOpen] = useState(false) const [isTriggersDialogOpen, setIsTriggersDialogOpen] = useState(false) - const [isWidgetSettingsOpen, setIsWidgetSettingsOpen] = useState(false) - const [isWidgetUpdating, setIsWidgetUpdating] = useState(false) const [triggerDialogInitialType, setTriggerDialogInitialType] = useState(null) const [triggerSummary, setTriggerSummary] = useState([]) const [triggerSummaryLoading, setTriggerSummaryLoading] = useState(false) @@ -216,37 +212,6 @@ export function AgentBuilder({ agentId }: AgentBuilderProps) { return stats }, [triggerSummary]) - const appWidgetConfig = useMemo(() => { - const source = originalData ?? createdAgent - return { - widget_enabled: Boolean(source?.widget_enabled), - allowed_domains: Array.isArray(source?.allowed_domains) ? source.allowed_domains : [], - } - }, [createdAgent, originalData]) - - const mergeWidgetAgentData = useCallback((updatedAgent: Record) => { - setOriginalData((current: any) => current ? { ...current, ...updatedAgent } : updatedAgent) - setCreatedAgent((current: any) => current ? { ...current, ...updatedAgent } : current) - }, []) - - const handleWidgetEnabledChange = useCallback(async (checked: boolean) => { - if (!localAgentId) return - setIsWidgetUpdating(true) - try { - const updatedAgent = await updateAgentWidgetConfig( - localAgentId, - { widget_enabled: checked }, - t("appWidget.messages.updateFailed"), - ) - mergeWidgetAgentData(updatedAgent) - toast.success(t("appWidget.messages.updated")) - } catch (error) { - toast.error(error instanceof Error ? error.message : t("appWidget.messages.updateFailed")) - } finally { - setIsWidgetUpdating(false) - } - }, [localAgentId, mergeWidgetAgentData, t]) - const gmailConnection = useMemo(() => { const gmailApp = findMatchingMcpApp(officialApps, "gmail") return { @@ -1869,7 +1834,7 @@ export function AgentBuilder({ agentId }: AgentBuilderProps) {
trigger.enabled) || appWidgetConfig.widget_enabled) && "border-primary/70", + triggerSummary.some((trigger) => trigger.enabled) && "border-primary/70", )}>
@@ -1981,50 +1946,6 @@ export function AgentBuilder({ agentId }: AgentBuilderProps) {
) })} -
- - void handleWidgetEnabledChange(checked)} - className="scale-75" - /> - -
@@ -2298,15 +2219,6 @@ export function AgentBuilder({ agentId }: AgentBuilderProps) { }} /> - - {state.filePreview.isOpen && (
diff --git a/frontend/src/components/build/agent-widget-settings-dialog.test.tsx b/frontend/src/components/build/agent-widget-settings-dialog.test.tsx deleted file mode 100644 index 06793036b..000000000 --- a/frontend/src/components/build/agent-widget-settings-dialog.test.tsx +++ /dev/null @@ -1,340 +0,0 @@ -/// -import React from "react" -import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react" -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" - -const apiRequestMock = vi.hoisted(() => vi.fn()) -const toastErrorMock = vi.hoisted(() => vi.fn()) -const toastSuccessMock = vi.hoisted(() => vi.fn()) -const copyToClipboardMock = vi.hoisted(() => vi.fn()) - -vi.mock("@/lib/api-wrapper", () => ({ - apiRequest: apiRequestMock, -})) - -vi.mock("@/lib/utils", async () => { - const actual = await vi.importActual("@/lib/utils") - return { - ...actual, - getApiUrl: () => "http://api.local", - } -}) - -vi.mock("@/lib/browser-location", () => ({ - getBrowserLocationOrigin: () => "http://app.local", -})) - -vi.mock("@/lib/clipboard", () => ({ - copyToClipboard: copyToClipboardMock, -})) - -vi.mock("@/contexts/i18n-context", () => ({ - useI18n: () => ({ t: (key: string) => key }), -})) - -vi.mock("@/components/ui/sonner", () => ({ - toast: { - error: toastErrorMock, - success: toastSuccessMock, - }, -})) - -import { AgentWidgetSettingsDialog } from "./agent-widget-settings-dialog" - -function jsonResponse(body: unknown, init?: ResponseInit): Response { - return new Response(JSON.stringify(body), { - status: 200, - headers: { "Content-Type": "application/json" }, - ...init, - }) -} - -function renderDialog( - props?: Partial>, -) { - const onWidgetConfigUpdated = vi.fn() - render( - , - ) - return { onWidgetConfigUpdated } -} - -describe("AgentWidgetSettingsDialog", () => { - beforeEach(() => { - apiRequestMock.mockReset() - toastErrorMock.mockReset() - toastSuccessMock.mockReset() - copyToClipboardMock.mockReset() - copyToClipboardMock.mockResolvedValue(true) - apiRequestMock.mockImplementation((url: string, options?: { body?: string }) => { - if (url.endsWith("/widget-key/rotate")) { - return Promise.resolve(jsonResponse({ - agent_id: 42, - widget_enabled: true, - widget_key: "wk-rotated-key", - })) - } - if (url.endsWith("/widget-key")) { - return Promise.resolve(jsonResponse({ - agent_id: 42, - widget_enabled: true, - widget_key: "wk-test-key", - })) - } - const updates = options?.body ? JSON.parse(options.body) : {} - return Promise.resolve(jsonResponse({ - id: 42, - widget_enabled: false, - allowed_domains: ["example.com"], - ...updates, - })) - }) - }) - - function agentUpdateCalls() { - return apiRequestMock.mock.calls.filter( - ([, options]) => (options as { method?: string } | undefined)?.method === "PUT", - ) - } - - afterEach(() => { - cleanup() - }) - - it("updates widget_enabled through the agent update endpoint", async () => { - const { onWidgetConfigUpdated } = renderDialog() - - expect(screen.getByRole("button", { name: "common.back" })).toBeInTheDocument() - fireEvent.click(screen.getByRole("switch", { name: "appWidget.dialog.enabledLabel" })) - - await waitFor(() => { - expect(apiRequestMock).toHaveBeenCalledWith("http://api.local/api/agents/42", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ widget_enabled: true }), - }) - }) - expect(onWidgetConfigUpdated).toHaveBeenCalledWith(expect.objectContaining({ - widget_enabled: true, - })) - expect(toastSuccessMock).toHaveBeenCalledWith("appWidget.messages.updated") - }) - - it("adds a normalized allowed domain through the agent update endpoint", async () => { - const { onWidgetConfigUpdated } = renderDialog() - - fireEvent.change(screen.getByPlaceholderText("appWidget.dialog.domainPlaceholder"), { - target: { value: "Docs.Example.com" }, - }) - fireEvent.click(screen.getByRole("button", { name: "appWidget.dialog.addDomain" })) - - await waitFor(() => { - expect(apiRequestMock).toHaveBeenCalledWith("http://api.local/api/agents/42", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ allowed_domains: ["example.com", "docs.example.com"] }), - }) - }) - expect(onWidgetConfigUpdated).toHaveBeenCalledWith(expect.objectContaining({ - allowed_domains: ["example.com", "docs.example.com"], - })) - await waitFor(() => { - expect(screen.getByPlaceholderText("appWidget.dialog.domainPlaceholder")).toHaveValue("") - }) - }) - - it("rejects domains with a scheme, path, or wildcard and shows an inline error", () => { - renderDialog() - - for (const invalidValue of ["https://example.com", "example.com/path", "*.example.com"]) { - fireEvent.change(screen.getByPlaceholderText("appWidget.dialog.domainPlaceholder"), { - target: { value: invalidValue }, - }) - fireEvent.click(screen.getByRole("button", { name: "appWidget.dialog.addDomain" })) - - expect(agentUpdateCalls()).toHaveLength(0) - expect(screen.getByText("appWidget.dialog.invalidDomain")).toBeInTheDocument() - expect(screen.getByPlaceholderText("appWidget.dialog.domainPlaceholder")).toHaveValue(invalidValue) - } - }) - - it("clears the inline domain error once the input changes", () => { - renderDialog() - - fireEvent.change(screen.getByPlaceholderText("appWidget.dialog.domainPlaceholder"), { - target: { value: "https://example.com" }, - }) - fireEvent.click(screen.getByRole("button", { name: "appWidget.dialog.addDomain" })) - expect(screen.getByText("appWidget.dialog.invalidDomain")).toBeInTheDocument() - - fireEvent.change(screen.getByPlaceholderText("appWidget.dialog.domainPlaceholder"), { - target: { value: "docs.example.com" }, - }) - expect(screen.queryByText("appWidget.dialog.invalidDomain")).not.toBeInTheDocument() - }) - - it("accepts the * wildcard entry", async () => { - renderDialog() - - fireEvent.change(screen.getByPlaceholderText("appWidget.dialog.domainPlaceholder"), { - target: { value: "*" }, - }) - fireEvent.click(screen.getByRole("button", { name: "appWidget.dialog.addDomain" })) - - await waitFor(() => { - expect(apiRequestMock).toHaveBeenCalledWith("http://api.local/api/agents/42", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ allowed_domains: ["example.com", "*"] }), - }) - }) - }) - - it("removes an allowed domain through the agent update endpoint", async () => { - renderDialog() - - fireEvent.click(screen.getByRole("button", { name: "appWidget.dialog.removeDomain" })) - - await waitFor(() => { - expect(apiRequestMock).toHaveBeenCalledWith("http://api.local/api/agents/42", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ allowed_domains: [] }), - }) - }) - }) - - it("ignores duplicate domain additions", () => { - renderDialog() - - fireEvent.change(screen.getByPlaceholderText("appWidget.dialog.domainPlaceholder"), { - target: { value: "Example.com" }, - }) - fireEvent.click(screen.getByRole("button", { name: "appWidget.dialog.addDomain" })) - - expect(agentUpdateCalls()).toHaveLength(0) - }) - - it("shows an error and keeps local state unchanged when updates fail", async () => { - const { onWidgetConfigUpdated } = renderDialog() - // Let the on-open widget-key fetch settle first so the failure below - // lands on the domain update, not the key fetch. - await waitFor(() => { - expect(screen.getByText("wk-test-key")).toBeInTheDocument() - }) - apiRequestMock.mockImplementationOnce(() => - Promise.resolve(jsonResponse({ detail: "Nope" }, { status: 500 })), - ) - - fireEvent.change(screen.getByPlaceholderText("appWidget.dialog.domainPlaceholder"), { - target: { value: "docs.example.com" }, - }) - fireEvent.click(screen.getByRole("button", { name: "appWidget.dialog.addDomain" })) - - await waitFor(() => { - expect(toastErrorMock).toHaveBeenCalled() - }) - expect(onWidgetConfigUpdated).not.toHaveBeenCalled() - expect(screen.queryByText("docs.example.com")).not.toBeInTheDocument() - // The typed value is kept so the user can retry without retyping. - expect(screen.getByPlaceholderText("appWidget.dialog.domainPlaceholder")).toHaveValue("docs.example.com") - }) - - it("fetches and shows the widget key when opened", async () => { - renderDialog() - - await waitFor(() => { - expect(screen.getByText("wk-test-key")).toBeInTheDocument() - }) - expect(apiRequestMock).toHaveBeenCalledWith("http://api.local/api/agents/42/widget-key") - }) - - it("copies the widget key", async () => { - renderDialog() - await waitFor(() => { - expect(screen.getByText("wk-test-key")).toBeInTheDocument() - }) - - fireEvent.click(screen.getByRole("button", { name: "appWidget.dialog.copyWidgetKey" })) - - await waitFor(() => { - expect(copyToClipboardMock).toHaveBeenCalledWith("wk-test-key") - }) - expect(toastSuccessMock).toHaveBeenCalledWith("common.copied") - }) - - it("rotates the widget key after the operator confirms", async () => { - const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true) - try { - renderDialog() - await waitFor(() => { - expect(screen.getByText("wk-test-key")).toBeInTheDocument() - }) - - fireEvent.click(screen.getByRole("button", { name: "appWidget.dialog.rotateWidgetKey" })) - - await waitFor(() => { - expect(apiRequestMock).toHaveBeenCalledWith( - "http://api.local/api/agents/42/widget-key/rotate", - { method: "POST" }, - ) - }) - expect(confirmSpy).toHaveBeenCalledWith("appWidget.dialog.rotateWidgetKeyConfirm") - await waitFor(() => { - expect(screen.getByText("wk-rotated-key")).toBeInTheDocument() - }) - expect(toastSuccessMock).toHaveBeenCalledWith("appWidget.messages.widgetKeyRotated") - } finally { - confirmSpy.mockRestore() - } - }) - - it("does not rotate the widget key when the operator declines", async () => { - const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(false) - try { - renderDialog() - await waitFor(() => { - expect(screen.getByText("wk-test-key")).toBeInTheDocument() - }) - - fireEvent.click(screen.getByRole("button", { name: "appWidget.dialog.rotateWidgetKey" })) - - expect(apiRequestMock).not.toHaveBeenCalledWith( - "http://api.local/api/agents/42/widget-key/rotate", - { method: "POST" }, - ) - expect(screen.getByText("wk-test-key")).toBeInTheDocument() - } finally { - confirmSpy.mockRestore() - } - }) - - it("copies the embed snippet with the widget key for the selected agent", async () => { - renderDialog() - await waitFor(() => { - expect(screen.getByText("wk-test-key")).toBeInTheDocument() - }) - - fireEvent.click(screen.getByRole("button", { name: "appWidget.dialog.copySnippet" })) - - await waitFor(() => { - expect(copyToClipboardMock).toHaveBeenCalledWith(expect.stringContaining("widget.js")) - }) - expect(copyToClipboardMock).toHaveBeenCalledWith(expect.stringContaining('src="http://app.local/widget.js"')) - expect(copyToClipboardMock).not.toHaveBeenCalledWith(expect.stringContaining("http://api.local/widget.js")) - expect(copyToClipboardMock).toHaveBeenCalledWith(expect.stringContaining('data-widget-key="wk-test-key"')) - expect(copyToClipboardMock).not.toHaveBeenCalledWith(expect.stringContaining("data-agent-id")) - expect(toastSuccessMock).toHaveBeenCalledWith("common.copied") - }) -}) diff --git a/frontend/src/components/build/agent-widget-settings-dialog.tsx b/frontend/src/components/build/agent-widget-settings-dialog.tsx deleted file mode 100644 index 6e77d35a7..000000000 --- a/frontend/src/components/build/agent-widget-settings-dialog.tsx +++ /dev/null @@ -1,384 +0,0 @@ -"use client" - -import React, { useEffect, useMemo, useState } from "react" -import { Check, ChevronLeft, Code2, Copy, KeyRound, RefreshCw, X, Zap } from "lucide-react" - -import { Badge } from "@/components/ui/badge" -import { Button } from "@/components/ui/button" -import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog" -import { Input } from "@/components/ui/input" -import { Switch } from "@/components/ui/switch" -import { toast } from "@/components/ui/sonner" -import { useI18n } from "@/contexts/i18n-context" -import { - buildWidgetSnippet, - fetchAgentWidgetKey, - isValidAllowedDomain, - normalizeAllowedDomain, - rotateAgentWidgetKey, - updateAgentWidgetConfig, -} from "@/lib/agent-widget-config" -import { getBrowserLocationOrigin } from "@/lib/browser-location" -import { copyToClipboard } from "@/lib/clipboard" - -export interface AgentWidgetConfig { - widget_enabled: boolean - allowed_domains: string[] -} - -interface AgentWidgetSettingsDialogProps { - agentId: number | null - agentName?: string - open: boolean - onOpenChange: (open: boolean) => void - widgetConfig: AgentWidgetConfig - onWidgetConfigUpdated?: (updatedAgent: Record) => void -} - -export function AgentWidgetSettingsDialog({ - agentId, - agentName, - open, - onOpenChange, - widgetConfig, - onWidgetConfigUpdated, -}: AgentWidgetSettingsDialogProps) { - const { t } = useI18n() - const [appOrigin, setAppOrigin] = useState(() => getBrowserLocationOrigin()) - const [widgetState, setWidgetState] = useState(widgetConfig) - const [newDomain, setNewDomain] = useState("") - const [domainError, setDomainError] = useState(null) - const [isUpdating, setIsUpdating] = useState(false) - const [copiedSnippet, setCopiedSnippet] = useState(false) - const [widgetKey, setWidgetKey] = useState(null) - const [isRotatingKey, setIsRotatingKey] = useState(false) - const [copiedKey, setCopiedKey] = useState(false) - - useEffect(() => { - if (open) { - setAppOrigin(getBrowserLocationOrigin()) - } - }, [open]) - - useEffect(() => { - if (!open || !agentId) { - setWidgetKey(null) - return - } - let cancelled = false - fetchAgentWidgetKey(agentId, t("appWidget.messages.widgetKeyLoadFailed")) - .then((state) => { - if (!cancelled) setWidgetKey(state.widget_key) - }) - .catch((error) => { - if (!cancelled) { - toast.error( - error instanceof Error ? error.message : t("appWidget.messages.widgetKeyLoadFailed"), - ) - } - }) - return () => { - cancelled = true - } - // `t` is intentionally excluded: it is only used for the error toast, and - // depending on it would re-fetch (and clobber a freshly rotated key) on - // every render where the i18n function identity changes. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open, agentId]) - - useEffect(() => { - if (!open) return - setWidgetState({ - widget_enabled: Boolean(widgetConfig.widget_enabled), - allowed_domains: Array.isArray(widgetConfig.allowed_domains) ? widgetConfig.allowed_domains : [], - }) - setNewDomain("") - setDomainError(null) - setCopiedSnippet(false) - }, [open, widgetConfig.allowed_domains, widgetConfig.widget_enabled]) - - const allowedDomains = Array.isArray(widgetState.allowed_domains) - ? widgetState.allowed_domains - : [] - - const widgetSnippet = useMemo( - () => buildWidgetSnippet(widgetKey ?? "", appOrigin), - [widgetKey, appOrigin], - ) - - const handleWidgetConfigUpdate = async (updates: Partial): Promise => { - if (!agentId) return false - setIsUpdating(true) - try { - const updatedAgent = await updateAgentWidgetConfig( - agentId, - updates, - t("appWidget.messages.updateFailed"), - ) - const nextState = { - widget_enabled: - typeof updatedAgent.widget_enabled === "boolean" - ? updatedAgent.widget_enabled - : updates.widget_enabled ?? widgetState.widget_enabled, - allowed_domains: Array.isArray(updatedAgent.allowed_domains) - ? updatedAgent.allowed_domains - : updates.allowed_domains ?? widgetState.allowed_domains, - } - setWidgetState(nextState) - onWidgetConfigUpdated?.(updatedAgent) - toast.success(t("appWidget.messages.updated")) - return true - } catch (error) { - toast.error(error instanceof Error ? error.message : t("appWidget.messages.updateFailed")) - return false - } finally { - setIsUpdating(false) - } - } - - const handleCopyWidgetKey = async () => { - if (!widgetKey) return - if (await copyToClipboard(widgetKey)) { - setCopiedKey(true) - toast.success(t("common.copied")) - window.setTimeout(() => setCopiedKey(false), 2000) - } else { - toast.error(t("appWidget.messages.copyFailed")) - } - } - - const handleRotateWidgetKey = async () => { - if (!agentId) return - if (!window.confirm(t("appWidget.dialog.rotateWidgetKeyConfirm"))) return - setIsRotatingKey(true) - try { - const state = await rotateAgentWidgetKey( - agentId, - t("appWidget.messages.widgetKeyRotateFailed"), - ) - setWidgetKey(state.widget_key) - toast.success(t("appWidget.messages.widgetKeyRotated")) - } catch (error) { - toast.error( - error instanceof Error ? error.message : t("appWidget.messages.widgetKeyRotateFailed"), - ) - } finally { - setIsRotatingKey(false) - } - } - - const handleCopySnippet = async () => { - if (!widgetSnippet) return - if (await copyToClipboard(widgetSnippet)) { - setCopiedSnippet(true) - toast.success(t("common.copied")) - window.setTimeout(() => setCopiedSnippet(false), 2000) - } else { - toast.error(t("appWidget.messages.copyFailed")) - } - } - - const handleAddDomain = async () => { - const domain = normalizeAllowedDomain(newDomain) - if (!domain) return - if (!isValidAllowedDomain(domain)) { - setDomainError(t("appWidget.dialog.invalidDomain")) - return - } - const existingDomains = new Set(allowedDomains.map((item) => item.toLowerCase())) - if (existingDomains.has(domain)) { - setNewDomain("") - return - } - const updated = await handleWidgetConfigUpdate({ - allowed_domains: [...allowedDomains, domain], - }) - if (updated) { - setNewDomain("") - } - } - - const handleRemoveDomain = (domain: string) => { - void handleWidgetConfigUpdate({ - allowed_domains: allowedDomains.filter((item) => item !== domain), - }) - } - - return ( - - - - - - {t("triggers.title")} - - - {agentName ? `${agentName} · ${t("triggers.subtitle")}` : t("triggers.subtitle")} - - - -
-
-
-
- -
-
- -
-
- {t("appWidget.dialog.title")} -
-
-
- void handleWidgetConfigUpdate({ widget_enabled: checked })} - /> -
- -
-
-

{t("appWidget.dialog.allowedDomains")}

-

- {t("deploy_agent.access_control.allowed_domains_desc")} -

-

- {t("appWidget.dialog.allowedDomainsSecurityNote")} -

-
-
- { - setNewDomain(event.target.value) - setDomainError(null) - }} - onKeyDown={(event) => { - if (event.key === "Enter") { - event.preventDefault() - void handleAddDomain() - } - }} - placeholder={t("appWidget.dialog.domainPlaceholder")} - disabled={isUpdating || !agentId} - aria-invalid={domainError ? true : undefined} - /> - -
- {domainError && ( -

- {domainError} -

- )} -
- {allowedDomains.length > 0 ? ( - allowedDomains.map((domain) => ( - - {domain} - - - )) - ) : ( - - {t("appWidget.dialog.noDomains")} - - )} -
-
- -
-
-

- - {t("appWidget.dialog.widgetKeyTitle")} -

-

- {t("appWidget.dialog.widgetKeyDescription")} -

-
-
- - {widgetKey ?? "…"} - - - -
-
- -
-
-

{t("appWidget.dialog.embedTitle")}

-

- {t("appWidget.dialog.embedDescription")} -

-
-
-
-
-                    {widgetSnippet}
-                  
- -
-
-
-
-
-
-
- ) -} diff --git a/frontend/src/components/build/deploy-agent-dialog.tsx b/frontend/src/components/build/deploy-agent-dialog.tsx index d4d2e129b..2206a2d10 100644 --- a/frontend/src/components/build/deploy-agent-dialog.tsx +++ b/frontend/src/components/build/deploy-agent-dialog.tsx @@ -8,7 +8,7 @@ import { Switch } from "@/components/ui/switch" import { Label } from "@/components/ui/label" import { Input } from "@/components/ui/input" import { Badge } from "@/components/ui/badge" -import { Rocket, LayoutGrid, Code2, Share, Webhook, ArrowRight, Copy, Check } from "lucide-react" +import { Rocket, LayoutGrid, Code2, Share, Webhook, ArrowRight, Copy, Check, KeyRound, RefreshCw, ChevronRight } from "lucide-react" import { useI18n } from "@/contexts/i18n-context" import { toast } from "@/components/ui/sonner" import { getApiUrl } from "@/lib/utils" @@ -18,7 +18,7 @@ import { getApiSnippetTarget } from "@/lib/api-snippet-base-url" import { formatAgentApiSnippets, type ApiSnippetTab } from "@/lib/api-snippet-format" import type { ApiSnippetTarget } from "@/lib/api-snippet-target" import { getBrowserLocationOrigin } from "@/lib/browser-location" -import { buildWidgetSnippet, fetchAgentWidgetKey, isValidAllowedDomain, normalizeAllowedDomain, updateAgentWidgetConfig } from "@/lib/agent-widget-config" +import { buildWidgetSnippet, fetchAgentWidgetEndUserSecret, fetchAgentWidgetKey, isValidAllowedDomain, normalizeAllowedDomain, rotateAgentWidgetEndUserSecret, rotateAgentWidgetKey, updateAgentWidgetConfig } from "@/lib/agent-widget-config" export interface Agent { id: number @@ -67,6 +67,12 @@ export function DeployAgentDialog({ deployAgent, onClose, onUpdate, onManageApiK const [isLoadingShareLink, setIsLoadingShareLink] = useState(false) const [shareLink, setShareLink] = useState(null) const [widgetKey, setWidgetKey] = useState(null) + const [isRotatingWidgetKey, setIsRotatingWidgetKey] = useState(false) + const [copiedWidgetKey, setCopiedWidgetKey] = useState(false) + const [endUserSecret, setEndUserSecret] = useState(null) + const [isRotatingEndUserSecret, setIsRotatingEndUserSecret] = useState(false) + const [copiedEndUserSecret, setCopiedEndUserSecret] = useState(false) + const [showAdvancedWidgetOptions, setShowAdvancedWidgetOptions] = useState(false) const [newDomain, setNewDomain] = useState("") const [appOrigin, setAppOrigin] = useState("") const isPublished = deployAgent?.status === "published" @@ -90,6 +96,8 @@ export function DeployAgentDialog({ deployAgent, onClose, onUpdate, onManageApiK setShareLink(null) setCopiedShareLink(false) setWidgetKey(null) + setEndUserSecret(null) + setShowAdvancedWidgetOptions(false) }, [deployAgent?.id]) useEffect(() => { @@ -116,6 +124,28 @@ export function DeployAgentDialog({ deployAgent, onClose, onUpdate, onManageApiK // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeView, deployAgent?.id]) + useEffect(() => { + if (activeView !== "embed" || !deployAgent) { + return + } + let cancelled = false + const fallback = t("appWidget.messages.endUserSecretLoadFailed") || "Failed to load end-user signing secret" + fetchAgentWidgetEndUserSecret(deployAgent.id, fallback) + .then((state) => { + if (!cancelled) setEndUserSecret(state.widget_end_user_secret) + }) + .catch((err) => { + if (!cancelled) { + console.error(err) + toast.error(err instanceof Error ? err.message : fallback) + } + }) + return () => { + cancelled = true + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeView, deployAgent?.id]) + useEffect(() => { if (activeView !== "share" || !deployAgent || !isPublished) { return @@ -237,6 +267,38 @@ export function DeployAgentDialog({ deployAgent, onClose, onUpdate, onManageApiK void handleUpdateWidgetConfig({ allowed_domains: currentDomains.filter(d => d !== domain) }) } + const handleCopyWidgetKey = async () => { + if (!widgetKey) return + if (await copyToClipboard(widgetKey)) { + setCopiedWidgetKey(true) + toast.success(t("common.copied") || "Copied to clipboard") + setTimeout(() => setCopiedWidgetKey(false), 2000) + } else { + toast.error(t("appWidget.messages.copyFailed")) + } + } + + const handleRotateWidgetKey = async () => { + if (!deployAgent) return + if (!window.confirm(t("appWidget.dialog.rotateWidgetKeyConfirm"))) return + setIsRotatingWidgetKey(true) + try { + const fallback = t("appWidget.messages.widgetKeyRotateFailed") || "Failed to regenerate widget key" + const state = await rotateAgentWidgetKey(deployAgent.id, fallback) + setWidgetKey(state.widget_key) + toast.success(t("appWidget.messages.widgetKeyRotated")) + } catch (err) { + console.error(err) + toast.error( + err instanceof Error + ? err.message + : t("appWidget.messages.widgetKeyRotateFailed") || "Failed to regenerate widget key" + ) + } finally { + setIsRotatingWidgetKey(false) + } + } + const handleCopySnippet = () => { if (!deployAgent) return const snippet = buildWidgetSnippet(widgetKey ?? "", appOrigin) @@ -247,6 +309,35 @@ export function DeployAgentDialog({ deployAgent, onClose, onUpdate, onManageApiK setTimeout(() => setCopiedSnippet(false), 2000) } + const handleCopyEndUserSecret = () => { + if (!endUserSecret) return + navigator.clipboard.writeText(endUserSecret) + setCopiedEndUserSecret(true) + toast.success(t("deploy_agent.messages.copied") || "Copied to clipboard") + setTimeout(() => setCopiedEndUserSecret(false), 2000) + } + + const handleRotateEndUserSecret = async () => { + if (!deployAgent) return + if (!window.confirm(t("appWidget.dialog.rotateEndUserSecretConfirm"))) return + setIsRotatingEndUserSecret(true) + try { + const fallback = t("appWidget.messages.endUserSecretRotateFailed") || "Failed to regenerate end-user signing secret" + const state = await rotateAgentWidgetEndUserSecret(deployAgent.id, fallback) + setEndUserSecret(state.widget_end_user_secret) + toast.success(t("appWidget.messages.endUserSecretRotated")) + } catch (err) { + console.error(err) + toast.error( + err instanceof Error + ? err.message + : t("appWidget.messages.endUserSecretRotateFailed") || "Failed to regenerate end-user signing secret" + ) + } finally { + setIsRotatingEndUserSecret(false) + } + } + const handleCopyShareLink = () => { if (!shareUrl) return navigator.clipboard.writeText(shareUrl) @@ -386,8 +477,10 @@ export function DeployAgentDialog({ deployAgent, onClose, onUpdate, onManageApiK return ( - - + + {t("deploy_agent.title") || "Deploy Agent"} @@ -395,241 +488,327 @@ export function DeployAgentDialog({ deployAgent, onClose, onUpdate, onManageApiK {deployAgent?.name} - {activeView === "options" ? ( -
-
- {deploymentOptions.map((option) => ( - - -
- -
- {option.title} - - {option.desc} - -
- -
- {option.actionText} -
-
-
- ))} -
-
- ) : activeView === "api" ? ( -
-
setActiveView("options")}> - {t("deploy_agent.back_to_options") || "Back to Deploy Options"} -
- -
-
{t("deploy_agent.api_panel.title") || "Call this agent via REST API"}
-
- {t("deploy_agent.api_panel.desc") || "Submit a task to the agent. Poll GET /v1/chat/tasks/{id} for the result."} +
+ {activeView === "options" ? ( +
+
+ {deploymentOptions.map((option) => ( + + +
+ +
+ {option.title} + + {option.desc} + +
+ +
+ {option.actionText} +
+
+
+ ))}
- -
- {(["curl", "python"] as ApiSnippetTab[]).map((tab) => ( - - ))} -
- -
-
-                {apiSnippets[apiTab]}
-              
- -
- -
- {t("deploy_agent.api_panel.key_hint") || "Replace YOUR_API_KEY with this agent's API key."}{" "} - -
-
- ) : activeView === "embed" ? ( -
-
setActiveView("options")}> - {t("deploy_agent.back_to_options") || "Back to Deploy Options"} -
- -
-
-
- -
- {t("deploy_agent.access_control.widget_enabled_desc") || "Allow this widget to be accessed externally."} -
-
- void handleUpdateWidgetConfig({ widget_enabled: checked })} - disabled={isUpdatingWidget} - /> + ) : activeView === "api" ? ( +
+
setActiveView("options")}> + {t("deploy_agent.back_to_options") || "Back to Deploy Options"}
- {deployAgent?.widget_enabled && ( -
-
- -
- {t("deploy_agent.access_control.allowed_domains_desc") || "Restrict widget access to specific domains. Use * for any domain."} -
-
-
- setNewDomain(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && void handleAddDomain()} - disabled={isUpdatingWidget} - className="flex-1" - /> - -
-
- {(deployAgent?.allowed_domains || []).map((domain) => ( - - {domain} - - - ))} - {(deployAgent?.allowed_domains || []).length === 0 && ( - - {t("deploy_agent.access_control.no_domains") || "No domains configured. Widget will block all requests unless * is added."} - - )} -
+
+
{t("deploy_agent.api_panel.title") || "Call this agent via REST API"}
+
+ {t("deploy_agent.api_panel.desc") || "Submit a task to the agent. Poll GET /v1/chat/tasks/{id} for the result."}
- )} -
+
-
-
{t("deploy_agent.embed_snippet.title") || "Embed Snippet"}
-
- {t("deploy_agent.embed_snippet.desc") || "Copy and paste this script tag into the of your website."} +
+ {(["curl", "python"] as ApiSnippetTab[]).map((tab) => ( + + ))}
-
-
-                  {widgetKey ? buildWidgetSnippet(widgetKey, appOrigin) : "…"}
+
+              
+
+                  {apiSnippets[apiTab]}
                 
+ +
+ {t("deploy_agent.api_panel.key_hint") || "Replace YOUR_API_KEY with this agent's API key."}{" "} + +
-
- ) : ( -
-
setActiveView("options")}> - {t("deploy_agent.back_to_options") || "Back to Deploy Options"} -
+ ) : activeView === "embed" ? ( +
+
setActiveView("options")}> + {t("deploy_agent.back_to_options") || "Back to Deploy Options"} +
-
-
-
{t("deploy_agent.share_link.title") || "Share Link"}
-
- {t("deploy_agent.share_link.desc") || "Generate a public page anyone can open to chat with this agent."} +
+
+
+ +
+ {t("deploy_agent.access_control.widget_enabled_desc") || "Allow this widget to be accessed externally."} +
+
+ void handleUpdateWidgetConfig({ widget_enabled: checked })} + disabled={isUpdatingWidget} + />
+ + {deployAgent?.widget_enabled && ( +
+
+ +
+ {t("deploy_agent.access_control.allowed_domains_desc") || "Restrict widget access to specific domains. Use * for any domain."} +
+
+
+ setNewDomain(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && void handleAddDomain()} + disabled={isUpdatingWidget} + className="flex-1" + /> + +
+
+ {(deployAgent?.allowed_domains || []).map((domain) => ( + + {domain} + + + ))} + {(deployAgent?.allowed_domains || []).length === 0 && ( + + {t("deploy_agent.access_control.no_domains") || "No domains configured. Widget will block all requests unless * is added."} + + )} +
+
+ )}
- {!isPublished ? ( -
- {t("deploy_agent.share_link.publish_required") || "Please publish this agent before generating a share link."} +
+
{t("deploy_agent.embed_snippet.title") || "Embed Snippet"}
+
+ {t("deploy_agent.embed_snippet.desc") || "Copy and paste this script tag into the of your website."}
- ) : isLoadingShareLink ? ( -
- {t("common.loading") || "Loading..."} +
+
+                    {widgetKey ? buildWidgetSnippet(widgetKey, appOrigin) : "…"}
+                  
+
- ) : shareEnabled && shareUrl ? ( -
+
+ +
setShowAdvancedWidgetOptions((e.target as HTMLDetailsElement).open)} + > + + + {t("deploy_agent.embed_snippet.advanced_toggle") || "Advanced options (custom identity, key rotation)"} + +
- -
- - +
-
- {t("deploy_agent.share_link.anyone_access") || "Anyone with this link can start a public chat with this agent."} -
-
- - + +
+
+ + {t("appWidget.dialog.endUserSecretTitle")} +
+
+ {t("appWidget.dialog.endUserSecretDescription")} +
+
+ + {endUserSecret ?? "…"} + + + +
- ) : shareEnabled ? ( -
+
+
+ ) : ( +
+
setActiveView("options")}> + {t("deploy_agent.back_to_options") || "Back to Deploy Options"} +
+ +
+
+
{t("deploy_agent.share_link.title") || "Share Link"}
- {t("deploy_agent.messages.share_failed") || "Share link action failed"} + {t("deploy_agent.share_link.desc") || "Generate a public page anyone can open to chat with this agent."}
-
- -
+ + {!isPublished ? ( +
+ {t("deploy_agent.share_link.publish_required") || "Please publish this agent before generating a share link."} +
+ ) : isLoadingShareLink ? ( +
+ {t("common.loading") || "Loading..."} +
+ ) : shareEnabled && shareUrl ? ( +
+
+ +
+ + +
+
+
+ {t("deploy_agent.share_link.anyone_access") || "Anyone with this link can start a public chat with this agent."} +
+
+ + +
+
+ ) : shareEnabled ? ( +
+
+ {t("deploy_agent.messages.share_failed") || "Share link action failed"} +
+
+ + +
+
+ ) : ( +
+
-
- ) : ( -
- -
- )} + )} +
-
- )} + )} +
) diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index cdb311e5c..1e005a804 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -3208,7 +3208,8 @@ Build when you need.`, embed_snippet: { title: "Embed Snippet", desc: "Copy and paste this script tag into the of your website.", - copy_btn: "Copy Snippet" + copy_btn: "Copy Snippet", + advanced_toggle: "Advanced options (custom identity, key rotation)" }, share_link: { title: "Share Link", @@ -3270,39 +3271,27 @@ Build when you need.`, } }, appWidget: { - builder: { - title: "App Widget", - description: "Embed this agent as a chat bubble on a website", - saveFirst: "Save the agent before configuring the app widget", - configure: "Configure app widget", - toggle: "Toggle app widget" - }, dialog: { - title: "App Widget", - enabledLabel: "Widget enabled", - allowedDomains: "Allowed domains", - domainPlaceholder: "e.g. example.com", - addDomain: "Add", invalidDomain: "Enter a domain like example.com or localhost:3000 (no scheme, path, or wildcard), or * to allow any domain.", - removeDomain: "Remove domain", - noDomains: "No domains configured. Widget requests are blocked until a domain or * is added.", - allowedDomainsSecurityNote: "Allowed domains are a browser-level restriction only, not a security boundary against non-browser clients. The widget key is the real access gate — keep it private and regenerate it if it leaks.", - embedTitle: "Embed snippet", - embedDescription: "Copy this script into the page where the chat bubble should appear.", - copySnippet: "Copy snippet", widgetKeyTitle: "Widget key", widgetKeyDescription: "The credential that authorizes this widget. It is included in the embed snippet; anyone holding it can reach the agent, so rotate it if it leaks.", copyWidgetKey: "Copy widget key", rotateWidgetKey: "Regenerate", - rotateWidgetKeyConfirm: "Regenerate the widget key? Every already-deployed embed snippet stops working until it is replaced with the new one." + rotateWidgetKeyConfirm: "Regenerate the widget key? Every already-deployed embed snippet stops working until it is replaced with the new one.", + endUserSecretTitle: "End-user signing secret", + endUserSecretDescription: "Use this secret on your own server to sign each visitor's identity before passing data-end-user-id, so the widget session is scoped to that specific user instead of an anonymous visitor. Never put this secret in the embed snippet or any browser-side code — compute the signature (HMAC-SHA256 of the end-user id) server-side and pass the result as data-end-user-signature.", + copyEndUserSecret: "Copy signing secret", + rotateEndUserSecret: "Regenerate", + rotateEndUserSecretConfirm: "Regenerate the end-user signing secret? Any signature already computed with the current secret stops verifying until your server switches to the new one." }, messages: { - updated: "Widget configuration updated", - updateFailed: "Failed to update widget configuration", copyFailed: "Failed to copy widget snippet", widgetKeyLoadFailed: "Failed to load widget key", widgetKeyRotated: "Widget key regenerated. Update deployed embed snippets.", - widgetKeyRotateFailed: "Failed to regenerate widget key" + widgetKeyRotateFailed: "Failed to regenerate widget key", + endUserSecretLoadFailed: "Failed to load end-user signing secret", + endUserSecretRotated: "Signing secret regenerated. Update the secret on your server before signing new sessions.", + endUserSecretRotateFailed: "Failed to regenerate end-user signing secret" } }, apiKeysPage: { diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 11cb4651c..ea55fd5cd 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -3208,7 +3208,8 @@ Build when you need.`, embed_snippet: { title: "引入代码", desc: "复制此 script 标签并将其粘贴到网站的 标签中。", - copy_btn: "复制代码" + copy_btn: "复制代码", + advanced_toggle: "高级选项(自定义身份、密钥轮换)" }, share_link: { title: "分享链接", @@ -3270,39 +3271,27 @@ Build when you need.`, } }, appWidget: { - builder: { - title: "App Widget", - description: "将此 Agent 作为聊天气泡嵌入网站", - saveFirst: "请先保存 Agent,再配置 App Widget", - configure: "配置 App Widget", - toggle: "切换 App Widget" - }, dialog: { - title: "App Widget", - enabledLabel: "开启 Widget", - allowedDomains: "允许的域名", - domainPlaceholder: "例如 example.com", - addDomain: "添加", invalidDomain: "请输入 example.com 或 localhost:3000 这样的域名(不含协议、路径或通配符),或输入 * 允许任意域名。", - removeDomain: "移除域名", - noDomains: "尚未配置域名。添加域名或 * 前,Widget 请求会被拦截。", - allowedDomainsSecurityNote: "允许的域名仅为浏览器层面的限制,并不能防御非浏览器客户端。真正的访问凭证是 Widget 密钥——请妥善保管,泄露后请重新生成。", - embedTitle: "嵌入代码", - embedDescription: "将这段 script 复制到需要显示聊天气泡的页面。", - copySnippet: "复制代码", widgetKeyTitle: "Widget 密钥", widgetKeyDescription: "授权此 Widget 的凭证,会包含在嵌入代码中;持有它的人即可访问该智能体,若泄露请重新生成。", copyWidgetKey: "复制 Widget 密钥", rotateWidgetKey: "重新生成", - rotateWidgetKeyConfirm: "重新生成 Widget 密钥?所有已部署的嵌入代码都会失效,需替换为新代码。" + rotateWidgetKeyConfirm: "重新生成 Widget 密钥?所有已部署的嵌入代码都会失效,需替换为新代码。", + endUserSecretTitle: "终端用户签名密钥", + endUserSecretDescription: "在你自己的服务端使用这个密钥,对每个访客的身份签名后再通过 data-end-user-id 传入,这样 widget 会话就能绑定到具体的用户,而不是匿名访客。切勿把这个密钥放进嵌入代码或任何浏览器端代码——应在服务端计算签名(对 end-user-id 做 HMAC-SHA256),再把结果通过 data-end-user-signature 传入。", + copyEndUserSecret: "复制签名密钥", + rotateEndUserSecret: "重新生成", + rotateEndUserSecretConfirm: "重新生成终端用户签名密钥?用旧密钥算出的签名会立即失效,需要让你的服务端切换到新密钥。" }, messages: { - updated: "Widget 配置已更新", - updateFailed: "更新 Widget 配置失败", copyFailed: "复制 Widget 代码失败", widgetKeyLoadFailed: "加载 Widget 密钥失败", widgetKeyRotated: "Widget 密钥已重新生成,请更新已部署的嵌入代码。", - widgetKeyRotateFailed: "重新生成 Widget 密钥失败" + widgetKeyRotateFailed: "重新生成 Widget 密钥失败", + endUserSecretLoadFailed: "加载终端用户签名密钥失败", + endUserSecretRotated: "签名密钥已重新生成,请在为新会话签名前更新你服务端的密钥。", + endUserSecretRotateFailed: "重新生成终端用户签名密钥失败" } }, apiKeysPage: { diff --git a/frontend/src/lib/agent-widget-config.ts b/frontend/src/lib/agent-widget-config.ts index def5eb9bb..83d5495e7 100644 --- a/frontend/src/lib/agent-widget-config.ts +++ b/frontend/src/lib/agent-widget-config.ts @@ -94,3 +94,39 @@ export async function rotateAgentWidgetKey( } return response.json() } + +export interface AgentWidgetEndUserSecretState { + agent_id: number + widget_end_user_secret: string +} + +// Unlike widget_key, this secret must never appear in the embed snippet or +// any client-side code -- it signs data-end-user-id server-side, on the +// embedding site's own backend, so the widget can trust which end user a +// session claims to be. +export async function fetchAgentWidgetEndUserSecret( + agentId: number | string, + fallbackErrorMessage: string, +): Promise { + const response = await apiRequest( + `${getApiUrl()}/api/agents/${agentId}/widget-end-user-secret`, + ) + if (!response.ok) { + throw await parseAgentUpdateError(response, fallbackErrorMessage) + } + return response.json() +} + +export async function rotateAgentWidgetEndUserSecret( + agentId: number | string, + fallbackErrorMessage: string, +): Promise { + const response = await apiRequest( + `${getApiUrl()}/api/agents/${agentId}/widget-end-user-secret/rotate`, + { method: "POST" }, + ) + if (!response.ok) { + throw await parseAgentUpdateError(response, fallbackErrorMessage) + } + return response.json() +} From ca8ee44171eb1386623e26faf96303cfcf4b02a1 Mon Sep 17 00:00:00 2001 From: yiboyasss <3359595624@qq.com> Date: Fri, 10 Jul 2026 18:52:34 +0800 Subject: [PATCH 07/11] fix(widget): give unrouted direct visits a random guest_id, not a shared literal --- .../widget/public-agent-chat-page.tsx | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/widget/public-agent-chat-page.tsx b/frontend/src/components/widget/public-agent-chat-page.tsx index f9c15ba4f..4ba96381c 100644 --- a/frontend/src/components/widget/public-agent-chat-page.tsx +++ b/frontend/src/components/widget/public-agent-chat-page.tsx @@ -34,6 +34,24 @@ type PublicAuthResult = { suggested_prompts?: string[] | null } +// This route is normally only reached via widget.js, which always supplies +// either end_user_id or a random per-browser guest_id in the query string. +// A visitor who reaches it directly (or an embed that omits both) has +// neither -- falling back to a shared literal like "anonymous" would let +// unrelated direct visitors collide on the same auto-resumed conversation +// (see /tasks/latest), so generate a real per-browser random id instead, +// exactly like widget.js does for its own anonymous fallback. +function getOrCreateDirectFallbackGuestId(): string { + if (typeof window === "undefined") return "anonymous" + const STORAGE_KEY = "xagent_direct_guest_id" + let stored = window.localStorage.getItem(STORAGE_KEY) + if (!stored) { + stored = "direct_" + Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15) + window.localStorage.setItem(STORAGE_KEY, stored) + } + return stored +} + interface PublicConversationContentProps { authMode: "widget" | "share" routeToken: string @@ -306,8 +324,12 @@ export function PublicAgentChatPage({ const { t } = useI18n() // A verified end-user identity takes precedence over the anonymous // per-browser guest_id for cache-key purposes too, so the same person - // resumes their conversation across devices/browsers. - const normalizedGuestId = authMode === "widget" ? (endUserId || guestId || "anonymous") : null + // resumes their conversation across devices/browsers. Neither present + // means this route was reached without going through widget.js -- fall + // back to a real per-browser random id, never a shared literal. + const normalizedGuestId = authMode === "widget" + ? (endUserId || guestId || getOrCreateDirectFallbackGuestId()) + : null const [isInitializing, setIsInitializing] = useState(true) const [errorMessage, setErrorMessage] = useState(null) const [authResult, setAuthResult] = useState(null) From 9f88a50193aa793c0ef8f95c3d634a14642e89e0 Mon Sep 17 00:00:00 2001 From: yiboyasss <3359595624@qq.com> Date: Fri, 10 Jul 2026 18:52:52 +0800 Subject: [PATCH 08/11] fix(widget): move the guest_id/end_user_id check out of a model_validator --- src/xagent/web/api/widget.py | 15 +- .../web/api/test_widget_end_user_identity.py | 273 ++++++++++++++++++ 2 files changed, 279 insertions(+), 9 deletions(-) create mode 100644 tests/web/api/test_widget_end_user_identity.py diff --git a/src/xagent/web/api/widget.py b/src/xagent/web/api/widget.py index 26db40b49..5473366a5 100644 --- a/src/xagent/web/api/widget.py +++ b/src/xagent/web/api/widget.py @@ -18,7 +18,7 @@ WebSocket, ) from jose import JWTError, jwt -from pydantic import BaseModel, Field, model_validator +from pydantic import BaseModel, Field from sqlalchemy.orm import Session from ..auth_config import JWT_ALGORITHM, JWT_SECRET_KEY @@ -90,14 +90,6 @@ class WidgetAuthRequest(BaseModel): # Direct (non-embedded) visits carry the widget key instead of a ticket. widget_key: Optional[str] = Field(default=None, max_length=512) - @model_validator(mode="after") - def _require_an_identity(self) -> "WidgetAuthRequest": - if not self.guest_id and not self.end_user_id: - raise ValueError("Either guest_id or end_user_id is required") - if self.end_user_id and not self.end_user_signature: - raise ValueError("end_user_signature is required when end_user_id is set") - return self - class EmbedTicketRequest(BaseModel): # The widget key is the unguessable per-agent credential distributed in the @@ -284,6 +276,11 @@ def _resolve_authenticated_guest_id(agent: Agent, request: WidgetAuthRequest) -> namespace: that would let a request skip signing by simply omitting end_user_id and passing the target guest_id directly. """ + if not request.guest_id and not request.end_user_id: + raise HTTPException( + status_code=422, detail="Either guest_id or end_user_id is required" + ) + if request.end_user_id: secret = agent.widget_end_user_secret if not secret: diff --git a/tests/web/api/test_widget_end_user_identity.py b/tests/web/api/test_widget_end_user_identity.py new file mode 100644 index 000000000..c2930f063 --- /dev/null +++ b/tests/web/api/test_widget_end_user_identity.py @@ -0,0 +1,273 @@ +"""Tests for the HMAC-verified end-user identity in the widget auth flow. + +Covers /api/widget/auth's end_user_id/end_user_signature verification and +the /api/widget/tasks/latest resume lookup added alongside it. The most +important case here is isolation: a guest who merely knows or guesses +another guest's end_user_id must not be able to forge a valid signature or +read that guest's conversation history. +""" + +import hashlib +import hmac +import secrets + +import pytest + +from xagent.web.models.agent import Agent, AgentStatus + +from .conftest import _admin_headers, _direct_db_session, client + +pytestmark = pytest.mark.usefixtures("_test_db") + + +def _create_widget_agent(user_id: int, name: str = "Widget Agent") -> int: + db = _direct_db_session() + try: + agent = Agent( + user_id=user_id, + name=name, + description=f"{name} description", + instructions=f"{name} instructions", + execution_mode="balanced", + status=AgentStatus.PUBLISHED, + widget_enabled=True, + allowed_domains=["*"], + widget_key=f"wk-{secrets.token_urlsafe(24)}", + ) + db.add(agent) + db.commit() + db.refresh(agent) + return int(agent.id) + finally: + db.close() + + +def _widget_key_for(agent_id: int) -> str: + db = _direct_db_session() + try: + agent = db.query(Agent).filter(Agent.id == agent_id).first() + assert agent is not None and agent.widget_key + return str(agent.widget_key) + finally: + db.close() + + +def _get_end_user_secret(headers: dict[str, str], agent_id: int) -> str: + resp = client.get(f"/api/agents/{agent_id}/widget-end-user-secret", headers=headers) + assert resp.status_code == 200, resp.text + return resp.json()["widget_end_user_secret"] + + +def _sign(secret: str, end_user_id: str) -> str: + return hmac.new( + secret.encode("utf-8"), end_user_id.encode("utf-8"), hashlib.sha256 + ).hexdigest() + + +def _widget_auth(*, agent_id: int, **payload: object) -> dict: + return client.post( + "/api/widget/auth", + json={"widget_key": _widget_key_for(agent_id), **payload}, + ) + + +def _widget_headers(*, agent_id: int, **payload: object) -> dict[str, str]: + resp = _widget_auth(agent_id=agent_id, **payload) + assert resp.status_code == 200, resp.text + return {"Authorization": f"Bearer {resp.json()['access_token']}"} + + +def _create_widget_task(headers: dict[str, str], title: str = "hello") -> int: + resp = client.post( + "/api/widget/chat/task/create", + json={"title": title, "description": title}, + headers=headers, + ) + assert resp.status_code == 200, resp.text + return int(resp.json()["task_id"]) + + +def _latest_task_id(headers: dict[str, str]) -> int | None: + resp = client.get("/api/widget/tasks/latest", headers=headers) + assert resp.status_code == 200, resp.text + return resp.json()["task_id"] + + +def test_valid_signature_resumes_the_signed_in_guests_own_task() -> None: + """The core happy path: a signed end_user_id can find its own history.""" + owner_headers = _admin_headers() + owner_id = _user_id_from(owner_headers) + agent_id = _create_widget_agent(owner_id) + secret = _get_end_user_secret(owner_headers, agent_id) + + device_a = _widget_headers( + agent_id=agent_id, + end_user_id="tenant_42:user_007", + end_user_signature=_sign(secret, "tenant_42:user_007"), + ) + assert _latest_task_id(device_a) is None + + task_id = _create_widget_task(device_a) + + # A second "device" authenticating with the same signed identity resumes + # the same task -- this is the cross-device continuity the feature exists + # to provide. + device_b = _widget_headers( + agent_id=agent_id, + end_user_id="tenant_42:user_007", + end_user_signature=_sign(secret, "tenant_42:user_007"), + ) + assert _latest_task_id(device_b) == task_id + + +def test_guest_cannot_retrieve_a_different_guests_task_via_tasks_latest() -> None: + """The isolation property /tasks/latest depends on: two different signed + identities on the same agent never see each other's history.""" + owner_headers = _admin_headers() + owner_id = _user_id_from(owner_headers) + agent_id = _create_widget_agent(owner_id) + secret = _get_end_user_secret(owner_headers, agent_id) + + victim = _widget_headers( + agent_id=agent_id, + end_user_id="victim@example.com", + end_user_signature=_sign(secret, "victim@example.com"), + ) + _create_widget_task(victim, title="victim's private conversation") + + attacker = _widget_headers( + agent_id=agent_id, + end_user_id="attacker@example.com", + end_user_signature=_sign(secret, "attacker@example.com"), + ) + assert _latest_task_id(attacker) is None + + +def test_guessed_end_user_id_without_the_matching_signature_is_rejected() -> None: + """An attacker who knows/guesses the victim's end_user_id (emails and + customer ids are often not secret) still cannot authenticate as them + without the signature only the embedding site's own server can produce. + """ + owner_headers = _admin_headers() + owner_id = _user_id_from(owner_headers) + agent_id = _create_widget_agent(owner_id) + secret = _get_end_user_secret(owner_headers, agent_id) + + victim_id = "victim@example.com" + victim = _widget_headers( + agent_id=agent_id, + end_user_id=victim_id, + end_user_signature=_sign(secret, victim_id), + ) + task_id = _create_widget_task(victim, title="victim's private conversation") + + # No signature at all. + resp = _widget_auth(agent_id=agent_id, end_user_id=victim_id) + assert resp.status_code == 403, resp.text + + # A forged/garbage signature. + resp = _widget_auth( + agent_id=agent_id, end_user_id=victim_id, end_user_signature="0" * 64 + ) + assert resp.status_code == 403, resp.text + + # A signature that is valid, but for a different end_user_id (replay + # across identities): must not authenticate as the victim. + resp = _widget_auth( + agent_id=agent_id, + end_user_id=victim_id, + end_user_signature=_sign(secret, "attacker@example.com"), + ) + assert resp.status_code == 403, resp.text + + # Sending the victim's raw id as the *unverified* guest_id field (the + # anonymous-flow field, not end_user_id) must not resolve to the victim's + # signed task either -- otherwise signing would be pointless, since an + # attacker could just skip end_user_id entirely and pass the same string + # as guest_id to reach the same stored identity. + unverified = _widget_headers(agent_id=agent_id, guest_id=victim_id) + assert _latest_task_id(unverified) is None + + # Confirm the victim's task really is retrievable via the real signed + # flow, so the negative assertions above are meaningful and not just + # "nothing exists yet". + resp = _widget_auth( + agent_id=agent_id, + end_user_id=victim_id, + end_user_signature=_sign(secret, victim_id), + ) + assert resp.status_code == 200, resp.text + real_headers = {"Authorization": f"Bearer {resp.json()['access_token']}"} + assert _latest_task_id(real_headers) == task_id + + +def test_auth_requires_either_guest_id_or_end_user_id() -> None: + owner_headers = _admin_headers() + owner_id = _user_id_from(owner_headers) + agent_id = _create_widget_agent(owner_id) + + resp = client.post( + "/api/widget/auth", json={"widget_key": _widget_key_for(agent_id)} + ) + assert resp.status_code == 422, resp.text + + +def test_raw_guest_id_cannot_forge_into_the_verified_namespace() -> None: + """A client cannot bypass signing by directly setting guest_id to the + reserved "verified_end_user:" prefix the backend uses internally to mark + a signature-verified identity.""" + owner_headers = _admin_headers() + owner_id = _user_id_from(owner_headers) + agent_id = _create_widget_agent(owner_id) + + resp = _widget_auth(agent_id=agent_id, guest_id="verified_end_user:someone") + assert resp.status_code == 403, resp.text + + +def test_end_user_id_without_a_configured_secret_is_rejected() -> None: + """An agent that never had its end-user secret generated must reject + signed-identity attempts rather than silently accepting them.""" + owner_headers = _admin_headers() + owner_id = _user_id_from(owner_headers) + agent_id = _create_widget_agent(owner_id) + # Note: no call to _get_end_user_secret, so widget_end_user_secret is + # still NULL on this agent. + + resp = _widget_auth( + agent_id=agent_id, end_user_id="user-1", end_user_signature="a" * 64 + ) + assert resp.status_code == 403, resp.text + + +def test_rotating_the_end_user_secret_invalidates_old_signatures() -> None: + owner_headers = _admin_headers() + owner_id = _user_id_from(owner_headers) + agent_id = _create_widget_agent(owner_id) + old_secret = _get_end_user_secret(owner_headers, agent_id) + old_signature = _sign(old_secret, "user-1") + + rotate_resp = client.post( + f"/api/agents/{agent_id}/widget-end-user-secret/rotate", headers=owner_headers + ) + assert rotate_resp.status_code == 200, rotate_resp.text + assert rotate_resp.json()["widget_end_user_secret"] != old_secret + + resp = _widget_auth( + agent_id=agent_id, end_user_id="user-1", end_user_signature=old_signature + ) + assert resp.status_code == 403, resp.text + + +def _user_id_from(headers: dict[str, str]) -> int: + from xagent.web.models.user import User + + db = _direct_db_session() + try: + # The admin bootstrap fixture always creates a single "admin" user; + # resolving it this way (rather than parsing the JWT) keeps this + # helper independent of the token's internal shape. + user = db.query(User).filter(User.username == "admin").first() + assert user is not None + return int(user.id) + finally: + db.close() From 3a670a90685a6a59b10aa2d157f1d5d62d1a3dd0 Mon Sep 17 00:00:00 2001 From: yiboyasss <3359595624@qq.com> Date: Mon, 13 Jul 2026 10:45:55 +0800 Subject: [PATCH 09/11] fix(widget): address PR re-review findings on the end-user identity feature --- frontend/public/widget.js | 10 +- .../build/deploy-agent-dialog.test.tsx | 278 ++++++++++++++++++ .../components/build/deploy-agent-dialog.tsx | 20 +- frontend/src/i18n/locales/en.ts | 1 + frontend/src/i18n/locales/zh.ts | 1 + ...260710_add_agent_widget_end_user_secret.py | 4 +- src/xagent/web/api/agents.py | 16 +- src/xagent/web/api/widget.py | 12 +- src/xagent/web/services/agent_store.py | 19 +- 9 files changed, 330 insertions(+), 31 deletions(-) create mode 100644 frontend/src/components/build/deploy-agent-dialog.test.tsx diff --git a/frontend/public/widget.js b/frontend/public/widget.js index cc4ef172b..422c13926 100644 --- a/frontend/public/widget.js +++ b/frontend/public/widget.js @@ -119,6 +119,14 @@ var endUserId = (scriptTag.getAttribute('data-end-user-id') || '').trim(); var endUserSignature = (scriptTag.getAttribute('data-end-user-signature') || '').trim(); var guestId = null; + if (endUserId && endUserId.length > 256) { + // Silently truncating would still send the truncated id, but the + // embedding server signed the FULL id -- the signature would no longer + // match it, surfacing as a confusing "Invalid end-user signature" error + // instead of an actionable one here. + console.error('Xagent Widget: data-end-user-id exceeds 256 characters; ignoring it and falling back to an anonymous session.'); + endUserId = ''; + } if (endUserId && !endUserSignature) { console.error('Xagent Widget: data-end-user-id was provided without data-end-user-signature; ignoring it and falling back to an anonymous session. Sign end_user_id server-side with the agent\'s end-user secret (see App Widget settings).'); endUserId = ''; @@ -145,7 +153,7 @@ // means the embedded widget has no credential to fall back on. var url = host + '/widget/chat/' + token; if (endUserId) { - url += '?end_user_id=' + encodeURIComponent(endUserId.substring(0, 256)) + url += '?end_user_id=' + encodeURIComponent(endUserId) + '&end_user_signature=' + encodeURIComponent(endUserSignature); } else { url += '?guest_id=' + encodeURIComponent(guestId); diff --git a/frontend/src/components/build/deploy-agent-dialog.test.tsx b/frontend/src/components/build/deploy-agent-dialog.test.tsx new file mode 100644 index 000000000..33433de5b --- /dev/null +++ b/frontend/src/components/build/deploy-agent-dialog.test.tsx @@ -0,0 +1,278 @@ +/// +import React from "react" +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +const apiRequestMock = vi.hoisted(() => vi.fn()) +const toastErrorMock = vi.hoisted(() => vi.fn()) +const toastSuccessMock = vi.hoisted(() => vi.fn()) +const copyToClipboardMock = vi.hoisted(() => vi.fn()) + +vi.mock("@/lib/api-wrapper", () => ({ + apiRequest: apiRequestMock, +})) + +vi.mock("@/lib/utils", async () => { + const actual = await vi.importActual("@/lib/utils") + return { + ...actual, + getApiUrl: () => "http://api.local", + } +}) + +vi.mock("@/lib/browser-location", () => ({ + getBrowserLocationOrigin: () => "http://app.local", +})) + +vi.mock("@/lib/clipboard", () => ({ + copyToClipboard: copyToClipboardMock, +})) + +vi.mock("@/contexts/i18n-context", () => ({ + useI18n: () => ({ t: (key: string) => key }), +})) + +vi.mock("@/components/ui/sonner", () => ({ + toast: { + error: toastErrorMock, + success: toastSuccessMock, + }, +})) + +import { DeployAgentDialog, type Agent } from "./deploy-agent-dialog" + +function jsonResponse(body: unknown, init?: ResponseInit): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + ...init, + }) +} + +const baseAgent: Agent = { + id: 42, + name: "Widget Agent", + description: "test", + logo_url: null, + status: "published", + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + widget_enabled: true, + allowed_domains: ["example.com"], +} + +function renderDialog(props?: Partial>) { + const onUpdate = vi.fn() + render( + , + ) + return { onUpdate } +} + +async function openEmbedView() { + fireEvent.click(await screen.findByText("deploy_agent.options.embed.title")) +} + +async function expandAdvancedOptions() { + fireEvent.click(await screen.findByText("deploy_agent.embed_snippet.advanced_toggle")) +} + +describe("DeployAgentDialog embed view", () => { + beforeEach(() => { + apiRequestMock.mockReset() + toastErrorMock.mockReset() + toastSuccessMock.mockReset() + copyToClipboardMock.mockReset() + copyToClipboardMock.mockResolvedValue(true) + apiRequestMock.mockImplementation((url: string, options?: { body?: string }) => { + if (url.endsWith("/widget-end-user-secret/rotate")) { + return Promise.resolve( + jsonResponse({ agent_id: 42, widget_end_user_secret: "secret-rotated" }), + ) + } + if (url.endsWith("/widget-end-user-secret")) { + return Promise.resolve( + jsonResponse({ agent_id: 42, widget_end_user_secret: "secret-test-value" }), + ) + } + if (url.endsWith("/widget-key/rotate")) { + return Promise.resolve( + jsonResponse({ agent_id: 42, widget_enabled: true, widget_key: "wk-rotated-key" }), + ) + } + if (url.endsWith("/widget-key")) { + return Promise.resolve( + jsonResponse({ agent_id: 42, widget_enabled: true, widget_key: "wk-test-key" }), + ) + } + const updates = options?.body ? JSON.parse(options.body) : {} + return Promise.resolve( + jsonResponse({ ...baseAgent, ...updates }), + ) + }) + }) + + afterEach(() => { + cleanup() + }) + + it("fetches and displays the widget key and embed snippet using it", async () => { + renderDialog() + await openEmbedView() + + await waitFor(() => { + expect(screen.getByText("wk-test-key")).toBeInTheDocument() + }) + expect( + screen.getByText((content) => content.includes('data-widget-key="wk-test-key"')), + ).toBeInTheDocument() + }) + + it("renders advanced options collapsed by default and expands on click", async () => { + renderDialog() + await openEmbedView() + + await waitFor(() => { + expect(apiRequestMock).toHaveBeenCalledWith("http://api.local/api/agents/42/widget-key") + }) + const details = (await screen.findByText("appWidget.dialog.rotateWidgetKey")).closest("details") + expect(details).not.toHaveAttribute("open") + + await expandAdvancedOptions() + + expect(details).toHaveAttribute("open") + }) + + it("copies the widget key via copyToClipboard", async () => { + renderDialog() + await openEmbedView() + await expandAdvancedOptions() + + await waitFor(() => { + expect(screen.getByText("wk-test-key")).toBeInTheDocument() + }) + fireEvent.click(screen.getByTitle("appWidget.dialog.copyWidgetKey")) + + await waitFor(() => { + expect(copyToClipboardMock).toHaveBeenCalledWith("wk-test-key") + }) + expect(toastSuccessMock).toHaveBeenCalled() + }) + + it("rotates the widget key after confirmation", async () => { + const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true) + try { + renderDialog() + await openEmbedView() + await expandAdvancedOptions() + + await waitFor(() => { + expect(screen.getByText("wk-test-key")).toBeInTheDocument() + }) + fireEvent.click(screen.getByText("appWidget.dialog.rotateWidgetKey")) + + await waitFor(() => { + expect(apiRequestMock).toHaveBeenCalledWith( + "http://api.local/api/agents/42/widget-key/rotate", + { method: "POST" }, + ) + }) + await waitFor(() => { + expect(screen.getByText("wk-rotated-key")).toBeInTheDocument() + }) + } finally { + confirmSpy.mockRestore() + } + }) + + it("copies the end-user secret via copyToClipboard", async () => { + renderDialog() + await openEmbedView() + await expandAdvancedOptions() + + await waitFor(() => { + expect(screen.getByText("secret-test-value")).toBeInTheDocument() + }) + fireEvent.click(screen.getByTitle("appWidget.dialog.copyEndUserSecret")) + + await waitFor(() => { + expect(copyToClipboardMock).toHaveBeenCalledWith("secret-test-value") + }) + expect(toastSuccessMock).toHaveBeenCalled() + }) + + it("rotates the end-user secret after confirmation", async () => { + const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true) + try { + renderDialog() + await openEmbedView() + await expandAdvancedOptions() + + await waitFor(() => { + expect(screen.getByText("secret-test-value")).toBeInTheDocument() + }) + fireEvent.click(screen.getByText("appWidget.dialog.rotateEndUserSecret")) + + await waitFor(() => { + expect(apiRequestMock).toHaveBeenCalledWith( + "http://api.local/api/agents/42/widget-end-user-secret/rotate", + { method: "POST" }, + ) + }) + await waitFor(() => { + expect(screen.getByText("secret-rotated")).toBeInTheDocument() + }) + } finally { + confirmSpy.mockRestore() + } + }) + + it("does not rotate the end-user secret when the operator declines", async () => { + const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(false) + try { + renderDialog() + await openEmbedView() + await expandAdvancedOptions() + + await waitFor(() => { + expect(screen.getByText("secret-test-value")).toBeInTheDocument() + }) + fireEvent.click(screen.getByText("appWidget.dialog.rotateEndUserSecret")) + + expect(apiRequestMock).not.toHaveBeenCalledWith( + "http://api.local/api/agents/42/widget-end-user-secret/rotate", + { method: "POST" }, + ) + expect(screen.getByText("secret-test-value")).toBeInTheDocument() + } finally { + confirmSpy.mockRestore() + } + }) + + it("adds an allowed domain through the agent update endpoint", async () => { + renderDialog() + await openEmbedView() + + const input = await screen.findByPlaceholderText("deploy_agent.access_control.domain_placeholder") + fireEvent.change(input, { target: { value: "new-domain.com" } }) + fireEvent.click(screen.getByText("deploy_agent.access_control.add_btn")) + + await waitFor(() => { + expect(apiRequestMock).toHaveBeenCalledWith( + "http://api.local/api/agents/42", + expect.objectContaining({ method: "PUT" }), + ) + }) + const updateCall = apiRequestMock.mock.calls.find( + ([url, options]) => url === "http://api.local/api/agents/42" && options?.method === "PUT", + ) + expect(JSON.parse(updateCall?.[1]?.body as string)).toMatchObject({ + allowed_domains: ["example.com", "new-domain.com"], + }) + }) +}) diff --git a/frontend/src/components/build/deploy-agent-dialog.tsx b/frontend/src/components/build/deploy-agent-dialog.tsx index 2206a2d10..16ec13035 100644 --- a/frontend/src/components/build/deploy-agent-dialog.tsx +++ b/frontend/src/components/build/deploy-agent-dialog.tsx @@ -309,12 +309,15 @@ export function DeployAgentDialog({ deployAgent, onClose, onUpdate, onManageApiK setTimeout(() => setCopiedSnippet(false), 2000) } - const handleCopyEndUserSecret = () => { + const handleCopyEndUserSecret = async () => { if (!endUserSecret) return - navigator.clipboard.writeText(endUserSecret) - setCopiedEndUserSecret(true) - toast.success(t("deploy_agent.messages.copied") || "Copied to clipboard") - setTimeout(() => setCopiedEndUserSecret(false), 2000) + if (await copyToClipboard(endUserSecret)) { + setCopiedEndUserSecret(true) + toast.success(t("deploy_agent.messages.copied") || "Copied to clipboard") + setTimeout(() => setCopiedEndUserSecret(false), 2000) + } else { + toast.error(t("appWidget.messages.copyFailed")) + } } const handleRotateEndUserSecret = async () => { @@ -596,6 +599,9 @@ export function DeployAgentDialog({ deployAgent, onClose, onUpdate, onManageApiK
{t("deploy_agent.access_control.allowed_domains_desc") || "Restrict widget access to specific domains. Use * for any domain."}
+
+ {t("deploy_agent.access_control.allowed_domains_security_note") || "Allowed domains are a browser-level restriction only, not a security boundary against non-browser clients. The widget key is the real access gate — keep it private and regenerate it if it leaks."} +
setShowAdvancedWidgetOptions((e.target as HTMLDetailsElement).open)} > - + {t("deploy_agent.embed_snippet.advanced_toggle") || "Advanced options (custom identity, key rotation)"} @@ -716,7 +722,7 @@ export function DeployAgentDialog({ deployAgent, onClose, onUpdate, onManageApiK type="button" variant="secondary" size="icon" - onClick={handleCopyEndUserSecret} + onClick={() => void handleCopyEndUserSecret()} disabled={!endUserSecret} title={t("appWidget.dialog.copyEndUserSecret")} className="h-9 w-9 shrink-0" diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 1e005a804..3778b6560 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -3201,6 +3201,7 @@ Build when you need.`, widget_enabled_desc: "Allow this widget to be accessed externally.", allowed_domains: "Allowed Domains", allowed_domains_desc: "Restrict widget access to specific domains. Use * for any domain.", + allowed_domains_security_note: "Allowed domains are a browser-level restriction only, not a security boundary against non-browser clients. The widget key is the real access gate — keep it private and regenerate it if it leaks.", domain_placeholder: "e.g. example.com", add_btn: "Add", no_domains: "No domains configured. Widget will block all requests unless * is added." diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index ea55fd5cd..64efcd191 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -3201,6 +3201,7 @@ Build when you need.`, widget_enabled_desc: "允许外部访问此 Widget 组件。", allowed_domains: "允许的域名", allowed_domains_desc: "限制 Widget 只能在特定的域名上被访问。使用 * 代表所有域名。", + allowed_domains_security_note: "允许的域名仅为浏览器层面的限制,并不能防御非浏览器客户端。真正的访问凭证是 Widget 密钥——请妥善保管,泄露后请重新生成。", domain_placeholder: "例如 example.com", add_btn: "添加", no_domains: "尚未配置域名。除非添加 *,否则 Widget 将拦截所有外部请求。" diff --git a/src/xagent/migrations/versions/20260710_add_agent_widget_end_user_secret.py b/src/xagent/migrations/versions/20260710_add_agent_widget_end_user_secret.py index d540a2914..fdba0d003 100644 --- a/src/xagent/migrations/versions/20260710_add_agent_widget_end_user_secret.py +++ b/src/xagent/migrations/versions/20260710_add_agent_widget_end_user_secret.py @@ -1,7 +1,7 @@ """add agent widget end-user signing secret Revision ID: 20260710_add_widget_end_user_secret -Revises: 1c2ae61b5a6d +Revises: 20260711_add_trace_events_task_idx Create Date: 2026-07-10 00:00:00.000000 """ @@ -12,7 +12,7 @@ from alembic import op revision: str = "20260710_add_widget_end_user_secret" -down_revision: Union[str, tuple[str, str], None] = "1c2ae61b5a6d" +down_revision: Union[str, tuple[str, str], None] = "20260711_add_trace_events_task_idx" branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None diff --git a/src/xagent/web/api/agents.py b/src/xagent/web/api/agents.py index fde82c099..222330ad5 100644 --- a/src/xagent/web/api/agents.py +++ b/src/xagent/web/api/agents.py @@ -36,11 +36,7 @@ DuplicateAgentNameError, TemplateNotFoundError, ) -from ..services.agent_store import ( - AgentStore, - new_widget_end_user_secret, - new_widget_key, -) +from ..services.agent_store import AgentStore, new_widget_secret from ..services.api_keys import AgentApiKeyService, KeyRotationConflict from ..services.llm_utils import UserAwareModelStorage from ..tools.config import WebToolConfig @@ -657,7 +653,7 @@ async def update_agent( # Widget-enabled agents always carry an embed credential; heal # rows that predate the widget_key column. if agent_data.widget_enabled and not agent.widget_key: - updates["widget_key"] = new_widget_key() + updates["widget_key"] = new_widget_secret() if agent_data.allowed_domains is not None: updates["allowed_domains"] = agent_data.allowed_domains @@ -918,7 +914,7 @@ async def get_agent_widget_key( raise HTTPException(status_code=404, detail="Agent not found") if not agent.widget_key: agent = store.update_agent_fields( - user_id, agent_id, {"widget_key": new_widget_key()} + user_id, agent_id, {"widget_key": new_widget_secret()} ) if agent is None: raise HTTPException(status_code=404, detail="Agent not found") @@ -948,7 +944,7 @@ async def rotate_agent_widget_key( if store.get_owned_agent(user_id, agent_id) is None: raise HTTPException(status_code=404, detail="Agent not found") agent = store.update_agent_fields( - user_id, agent_id, {"widget_key": new_widget_key()} + user_id, agent_id, {"widget_key": new_widget_secret()} ) if agent is None: raise HTTPException(status_code=404, detail="Agent not found") @@ -988,7 +984,7 @@ async def get_agent_widget_end_user_secret( agent = store.update_agent_fields( user_id, agent_id, - {"widget_end_user_secret": new_widget_end_user_secret()}, + {"widget_end_user_secret": new_widget_secret()}, ) if agent is None: raise HTTPException(status_code=404, detail="Agent not found") @@ -1023,7 +1019,7 @@ async def rotate_agent_widget_end_user_secret( agent = store.update_agent_fields( user_id, agent_id, - {"widget_end_user_secret": new_widget_end_user_secret()}, + {"widget_end_user_secret": new_widget_secret()}, ) if agent is None: raise HTTPException(status_code=404, detail="Agent not found") diff --git a/src/xagent/web/api/widget.py b/src/xagent/web/api/widget.py index 5473366a5..69973fc12 100644 --- a/src/xagent/web/api/widget.py +++ b/src/xagent/web/api/widget.py @@ -55,7 +55,7 @@ ) # Namespace for guest_ids backed by a verified end-user identity (see -# _resolve_verified_guest_id). Reserved so a client can never forge a +# _resolve_authenticated_guest_id). Reserved so a client can never forge a # "verified" guest_id for someone else's end_user_id without producing a # valid HMAC signature for it -- request validation rejects any # client-supplied guest_id that starts with this prefix outright. @@ -68,7 +68,7 @@ class WidgetAuthRequest(BaseModel): # verification -- knowledge of this high-entropy value is itself the only # "credential" it ever carried. Reserved-prefix values are rejected below # since only a verified end_user_id may produce one (see - # _resolve_verified_guest_id). + # _resolve_authenticated_guest_id). guest_id: Optional[str] = Field(default=None, max_length=256) # A real end-user identity from the embedding page (data-end-user-id), # scoped to a specific user/tenant rather than an anonymous browser. Must @@ -275,6 +275,14 @@ def _resolve_authenticated_guest_id(agent: Agent, request: WidgetAuthRequest) -> before, except it may never itself forge into the reserved "verified" namespace: that would let a request skip signing by simply omitting end_user_id and passing the target guest_id directly. + + Known limitation: the signature is a static, non-expiring credential for + a given end_user_id (no timestamp/nonce binding) and travels in the + iframe URL/query string. A leaked (end_user_id, signature) pair is a + standing impersonation token until the owner rotates + widget_end_user_secret -- which invalidates every end-user's signature + at once, not just the leaked one. Acceptable v1 tradeoff; revisit with an + expiry embedded in the signed payload if per-user revocation is needed. """ if not request.guest_id and not request.end_user_id: raise HTTPException( diff --git a/src/xagent/web/services/agent_store.py b/src/xagent/web/services/agent_store.py index 4109ef0f1..df2c88c01 100644 --- a/src/xagent/web/services/agent_store.py +++ b/src/xagent/web/services/agent_store.py @@ -46,14 +46,15 @@ def clean_tool_categories(categories: Any) -> list[str]: return [c for c in (ensure_list(categories) or []) if c != "other"] -def new_widget_key() -> str: - """Generate an unguessable widget embed credential for an agent.""" - return secrets.token_urlsafe(32) - - -def new_widget_end_user_secret() -> str: - """Generate the HMAC secret used to verify signed end-user identity - claims. Owner-only: never returned by the embed-ticket/auth flow.""" +def new_widget_secret() -> str: + """Generate an unguessable per-agent widget secret. + + Used for both the widget embed key (widget_key -- public, distributed in + the embed snippet) and the end-user signing secret + (widget_end_user_secret -- owner-only, never returned by the + embed-ticket/auth flow). Same generation, different trust boundaries by + which column/endpoint exposes the result. + """ return secrets.token_urlsafe(32) @@ -256,7 +257,7 @@ def add_agent( published_at = datetime.now(timezone.utc) # Widget-enabled agents always carry an embed credential; agents # created disabled get one when the widget is first enabled. - widget_key = new_widget_key() if widget_enabled else None + widget_key = new_widget_secret() if widget_enabled else None agent = Agent( user_id=user_id, name=name, From 2858713d92cb601eaf1108e17f807efdce2947ed Mon Sep 17 00:00:00 2001 From: yiboyasss <3359595624@qq.com> Date: Mon, 13 Jul 2026 13:55:26 +0800 Subject: [PATCH 10/11] fix(widget): fix non-ASCII signature crash, dedupe secret endpoints, backfill tests --- .../build/deploy-agent-dialog.test.tsx | 36 ++++ .../components/build/deploy-agent-dialog.tsx | 30 ++-- .../widget/public-agent-chat-page.tsx | 6 +- frontend/src/lib/public-widget-script.test.ts | 154 ++++++++++++++++ src/xagent/web/api/agents.py | 165 +++++++++--------- src/xagent/web/api/widget.py | 7 +- .../web/api/test_widget_end_user_identity.py | 57 ++++++ 7 files changed, 357 insertions(+), 98 deletions(-) create mode 100644 frontend/src/lib/public-widget-script.test.ts diff --git a/frontend/src/components/build/deploy-agent-dialog.test.tsx b/frontend/src/components/build/deploy-agent-dialog.test.tsx index 33433de5b..6a0a94c58 100644 --- a/frontend/src/components/build/deploy-agent-dialog.test.tsx +++ b/frontend/src/components/build/deploy-agent-dialog.test.tsx @@ -275,4 +275,40 @@ describe("DeployAgentDialog embed view", () => { allowed_domains: ["example.com", "new-domain.com"], }) }) + + it("rejects an invalid domain without calling the update endpoint", async () => { + renderDialog() + await openEmbedView() + + const input = await screen.findByPlaceholderText("deploy_agent.access_control.domain_placeholder") + fireEvent.change(input, { target: { value: "https://not-a-bare-host.com/path" } }) + fireEvent.click(screen.getByText("deploy_agent.access_control.add_btn")) + + expect(toastErrorMock).toHaveBeenCalledWith("appWidget.dialog.invalidDomain") + expect(apiRequestMock).not.toHaveBeenCalledWith( + "http://api.local/api/agents/42", + expect.objectContaining({ method: "PUT" }), + ) + }) + + it("removes an allowed domain through the agent update endpoint", async () => { + renderDialog() + await openEmbedView() + + await screen.findByText("example.com") + fireEvent.click(screen.getByText("×")) + + await waitFor(() => { + expect(apiRequestMock).toHaveBeenCalledWith( + "http://api.local/api/agents/42", + expect.objectContaining({ method: "PUT" }), + ) + }) + const updateCall = apiRequestMock.mock.calls.find( + ([url, options]) => url === "http://api.local/api/agents/42" && options?.method === "PUT", + ) + expect(JSON.parse(updateCall?.[1]?.body as string)).toMatchObject({ + allowed_domains: [], + }) + }) }) diff --git a/frontend/src/components/build/deploy-agent-dialog.tsx b/frontend/src/components/build/deploy-agent-dialog.tsx index 16ec13035..dfc683d1a 100644 --- a/frontend/src/components/build/deploy-agent-dialog.tsx +++ b/frontend/src/components/build/deploy-agent-dialog.tsx @@ -299,14 +299,17 @@ export function DeployAgentDialog({ deployAgent, onClose, onUpdate, onManageApiK } } - const handleCopySnippet = () => { + const handleCopySnippet = async () => { if (!deployAgent) return const snippet = buildWidgetSnippet(widgetKey ?? "", appOrigin) if (!snippet) return - navigator.clipboard.writeText(snippet) - setCopiedSnippet(true) - toast.success(t("deploy_agent.messages.copied") || "Copied to clipboard") - setTimeout(() => setCopiedSnippet(false), 2000) + if (await copyToClipboard(snippet)) { + setCopiedSnippet(true) + toast.success(t("deploy_agent.messages.copied") || "Copied to clipboard") + setTimeout(() => setCopiedSnippet(false), 2000) + } else { + toast.error(t("appWidget.messages.copyFailed")) + } } const handleCopyEndUserSecret = async () => { @@ -341,12 +344,15 @@ export function DeployAgentDialog({ deployAgent, onClose, onUpdate, onManageApiK } } - const handleCopyShareLink = () => { + const handleCopyShareLink = async () => { if (!shareUrl) return - navigator.clipboard.writeText(shareUrl) - setCopiedShareLink(true) - toast.success(t("deploy_agent.messages.link_copied") || "Link copied to clipboard") - setTimeout(() => setCopiedShareLink(false), 2000) + if (await copyToClipboard(shareUrl)) { + setCopiedShareLink(true) + toast.success(t("deploy_agent.messages.link_copied") || "Link copied to clipboard") + setTimeout(() => setCopiedShareLink(false), 2000) + } else { + toast.error(t("appWidget.messages.copyFailed")) + } } const handleEnableShare = async () => { @@ -652,7 +658,7 @@ export function DeployAgentDialog({ deployAgent, onClose, onUpdate, onManageApiK variant="secondary" size="icon" className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity" - onClick={handleCopySnippet} + onClick={() => void handleCopySnippet()} title={t("deploy_agent.embed_snippet.copy_btn") || "Copy Snippet"} > {copiedSnippet ? : } @@ -772,7 +778,7 @@ export function DeployAgentDialog({ deployAgent, onClose, onUpdate, onManageApiK
- diff --git a/frontend/src/components/widget/public-agent-chat-page.tsx b/frontend/src/components/widget/public-agent-chat-page.tsx index 4ba96381c..5761154a2 100644 --- a/frontend/src/components/widget/public-agent-chat-page.tsx +++ b/frontend/src/components/widget/public-agent-chat-page.tsx @@ -46,7 +46,11 @@ function getOrCreateDirectFallbackGuestId(): string { const STORAGE_KEY = "xagent_direct_guest_id" let stored = window.localStorage.getItem(STORAGE_KEY) if (!stored) { - stored = "direct_" + Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15) + // crypto.randomUUID needs a secure context; fall back to Math.random for + // http:// dev/test embeds where it's unavailable. + stored = typeof crypto !== "undefined" && crypto.randomUUID + ? `direct_${crypto.randomUUID()}` + : "direct_" + Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15) window.localStorage.setItem(STORAGE_KEY, stored) } return stored diff --git a/frontend/src/lib/public-widget-script.test.ts b/frontend/src/lib/public-widget-script.test.ts new file mode 100644 index 000000000..53dd739b0 --- /dev/null +++ b/frontend/src/lib/public-widget-script.test.ts @@ -0,0 +1,154 @@ +/** + * Behavioral tests for the standalone embed script (frontend/public/widget.js). + * + * The file is vanilla JS, not a module -- it's an IIFE meant to run once at + *