Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61cb192134 |
@@ -117,17 +117,6 @@ PROXY=
|
||||
# Endpoint: https://api.hanzo.ai/v1
|
||||
HANZO_API_KEY=
|
||||
|
||||
# Canonical Hanzo Cloud agents (/v1/agents). Lets signed-in users run their own
|
||||
# cloud agents from chat via `/agent <name>` or the @mention picker. The chat
|
||||
# backend proxies /api/agents/cloud/* to this host, forwarding the user's
|
||||
# hanzo.id token server-side (never to the browser); cloud scopes to their org.
|
||||
# Optional: if unset, derived from OPENAI_BASE_URL (its host, minus /v1).
|
||||
# HANZO_CLOUD_URL=https://api.hanzo.ai
|
||||
# A cloud-agent run is a real billable completion; these bound abuse of the proxy.
|
||||
# CLOUD_AGENT_USER_MAX=30 # max cloud-agent requests per user per window
|
||||
# CLOUD_AGENT_WINDOW=1 # rate-limit window, minutes
|
||||
# CLOUD_AGENT_MAX_CONCURRENT=50 # process-wide in-flight ceiling to cloud (503 past it)
|
||||
|
||||
#===================================#
|
||||
# Known Endpoints - chat.yaml #
|
||||
#===================================#
|
||||
@@ -474,21 +463,6 @@ ALLOW_REGISTRATION=true
|
||||
ALLOW_SOCIAL_LOGIN=false
|
||||
ALLOW_SOCIAL_REGISTRATION=false
|
||||
ALLOW_PASSWORD_RESET=false
|
||||
|
||||
#=====================================================#
|
||||
# Guest Chat (anonymous preview) #
|
||||
#=====================================================#
|
||||
# Off by default. When enabled, unauthenticated visitors get a small free
|
||||
# quota on the free Zen model routed through api.hanzo.ai. After the quota is
|
||||
# spent the UI surfaces the existing OpenID/hanzo.id login. Guests are scoped
|
||||
# server-side to the free model only and rejected from every other route.
|
||||
ALLOW_GUEST_CHAT=false
|
||||
# GUEST_MESSAGE_MAX=3 # free messages per IP before login is required
|
||||
# GUEST_ENDPOINT=Hanzo # custom endpoint name from librechat.yaml (api.hanzo.ai)
|
||||
# GUEST_MODEL=zen3-nano # free Zen model id guests are pinned to
|
||||
# GUEST_TOKEN_EXPIRY=3600000 # guest JWT lifetime in ms (default 1h)
|
||||
# GUEST_TOKEN_MAX=20 # max guest sessions issued per IP per window
|
||||
# GUEST_TOKEN_WINDOW=60 # guest-session issuance window in minutes
|
||||
# ALLOW_ACCOUNT_DELETION=true # note: enabled by default if omitted/commented out
|
||||
ALLOW_UNVERIFIED_EMAIL_LOGIN=true
|
||||
|
||||
@@ -836,26 +810,31 @@ OPENWEATHER_API_KEY=
|
||||
# Hanzo Chat Code Interpreter API #
|
||||
#====================================#
|
||||
|
||||
# "Run Code" routes through the Hanzo unified backend (api.hanzo.ai/v1/exec),
|
||||
# which executes in a Hanzo sandbox. execute_code POSTs {BASEURL}/exec with
|
||||
# X-API-Key. https://hanzo.ai/docs/chat/code-interpreter
|
||||
# LIBRECHAT_CODE_BASEURL=https://api.hanzo.ai/v1
|
||||
# LIBRECHAT_CODE_API_KEY=your-key # prod: KMS chat-secrets/LIBRECHAT_CODE_API_KEY
|
||||
# https://hanzo.ai/docs/chat/code-interpreter
|
||||
# LIBRECHAT_CODE_API_KEY=your-key
|
||||
|
||||
#======================#
|
||||
# Web Search #
|
||||
#======================#
|
||||
|
||||
# Web Search routes through the Hanzo unified backend ONLY — no external SaaS.
|
||||
# The searxng provider + firecrawl scraper both point at api.hanzo.ai/v1/websearch
|
||||
# (Hanzo metasearch + Hanzo Crawl). See librechat.yaml `webSearch`.
|
||||
# Note: All of the following variable names can be customized.
|
||||
# Omit values to allow user to provide them.
|
||||
|
||||
# For more information on configuration values, see:
|
||||
# https://hanzo.ai/docs/chat/features/web_search
|
||||
# SEARXNG_INSTANCE_URL=https://api.hanzo.ai/v1/websearch
|
||||
# FIRECRAWL_API_URL=https://api.hanzo.ai/v1/websearch
|
||||
# WEBSEARCH_API_KEY=your-key # prod: KMS chat-secrets/WEBSEARCH_API_KEY
|
||||
#
|
||||
# Do NOT set SERPER_API_KEY / TAVILY_API_KEY / JINA_API_KEY / COHERE_API_KEY:
|
||||
# external providers are intentionally disabled (one way, unified backend).
|
||||
|
||||
# Search Provider (Required)
|
||||
# SERPER_API_KEY=your_serper_api_key
|
||||
|
||||
# Scraper (Required)
|
||||
# FIRECRAWL_API_KEY=your_firecrawl_api_key
|
||||
# Optional: Custom Firecrawl API URL
|
||||
# FIRECRAWL_API_URL=your_firecrawl_api_url
|
||||
|
||||
# Reranker (Required)
|
||||
# JINA_API_KEY=your_jina_api_key
|
||||
# or
|
||||
# COHERE_API_KEY=your_cohere_api_key
|
||||
|
||||
#======================#
|
||||
# MCP Configuration #
|
||||
|
||||
@@ -5,19 +5,9 @@
|
||||
OPENAI_API_KEY=sk-hanzo-your-api-key-here
|
||||
OPENAI_BASE_URL=https://api.hanzo.ai/v1
|
||||
|
||||
# Code Interpreter ("Run Code") — Hanzo unified backend.
|
||||
# LibreChat's execute_code tool POSTs {LIBRECHAT_CODE_BASEURL}/exec with header
|
||||
# X-API-Key. cloud-api serves /v1/exec (-> sandboxed executor). The key is
|
||||
# KMS-sourced (chat-secrets/LIBRECHAT_CODE_API_KEY). These are the ONLY vars
|
||||
# LibreChat reads — the old CODE_EXECUTION_ENDPOINT/RUNTIME_API_KEY were dead.
|
||||
LIBRECHAT_CODE_BASEURL=https://api.hanzo.ai/v1
|
||||
LIBRECHAT_CODE_API_KEY=set-in-KMS-chat-secrets
|
||||
|
||||
# Web Search — Hanzo unified backend (searxng + firecrawl compat over Hanzo
|
||||
# search + crawl). See librechat.yaml `webSearch`. One shared service key.
|
||||
SEARXNG_INSTANCE_URL=https://api.hanzo.ai/v1/websearch
|
||||
FIRECRAWL_API_URL=https://api.hanzo.ai/v1/websearch
|
||||
WEBSEARCH_API_KEY=set-in-KMS-chat-secrets
|
||||
# Runtime Execution (Code Interpreter)
|
||||
CODE_EXECUTION_ENDPOINT=https://api.hanzo.ai/v1/execute
|
||||
RUNTIME_API_KEY=${OPENAI_API_KEY}
|
||||
|
||||
# ====== BRANDING ======
|
||||
APP_TITLE=Hanzo Chat
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="640" viewBox="0 0 1280 640" role="img" aria-label="chat">
|
||||
<rect width="1280" height="640" fill="#0A0A0A"/>
|
||||
<svg x="96" y="215" width="210" height="210" viewBox="0 0 67 67"><path d="M22.21 67V44.6369H0V67H22.21Z" fill="#fff"/><path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z" fill="#fff"/><path d="M22.21 0H0V22.3184H22.21V0Z" fill="#fff"/><path d="M66.7198 0H44.5098V22.3184H66.7198V0Z" fill="#fff"/><path d="M66.7198 67V44.6369H44.5098V67H66.7198Z" fill="#fff"/></svg>
|
||||
<text x="378" y="276" font-family="Inter,system-ui,-apple-system,sans-serif" font-size="78" font-weight="800" letter-spacing="-2" fill="#ffffff">chat</text>
|
||||
<text x="378" y="322" font-family="Inter,system-ui,sans-serif" font-size="30" fill="#ffffff" opacity=".66">AI chat with MCP integration and multi-provider support</text>
|
||||
<rect x="378" y="338" width="806" height="3" rx="1.5" fill="#ffffff" opacity=".9"/>
|
||||
<text x="378" y="390" font-family="Inter,system-ui,sans-serif" font-size="24" font-weight="600" fill="#ffffff" opacity=".5">github.com/hanzoai</text>
|
||||
<text x="1184" y="390" text-anchor="end" font-family="Inter,system-ui,sans-serif" font-size="24" font-weight="600" fill="#ffffff" opacity=".5">hanzo.ai</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -42,15 +42,6 @@ RUN \
|
||||
|
||||
COPY --chown=node:node . .
|
||||
|
||||
# Bake the Umami analytics website id into the Vite build so the index.html
|
||||
# %VITE_ANALYTICS_SITE_ID% placeholder is substituted at build time. Vite's HTML
|
||||
# env replacement (loadEnv, envPrefix includes VITE_) picks this up from process.env.
|
||||
# Without it the tracker POSTs a literal "%VITE_ANALYTICS_SITE_ID%" as the website
|
||||
# id and analytics.hanzo.ai/api/send answers 400. Public site identifier (it appears
|
||||
# in the served HTML), safe to default here; override with --build-arg if needed.
|
||||
ARG VITE_ANALYTICS_SITE_ID=2f72b944-f1f8-4d2d-8f6c-26063bde0d1a
|
||||
ENV VITE_ANALYTICS_SITE_ID=$VITE_ANALYTICS_SITE_ID
|
||||
|
||||
RUN \
|
||||
# React client build with configurable memory
|
||||
NODE_OPTIONS="--max-old-space-size=${NODE_MAX_OLD_SPACE_SIZE}" pnpm run frontend; \
|
||||
|
||||
@@ -5,7 +5,6 @@ Portions of this software are licensed as follows:
|
||||
---
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2023 LibreChat (Danny Avila)
|
||||
Copyright (c) 2023 Hanzo AI Inc
|
||||
Originally based on LibreChat by Berri AI.
|
||||
|
||||
|
||||
@@ -90,191 +90,6 @@ CREDS_KEY= CREDS_IV= # Credential encryption
|
||||
- `uv` bundled for MCP server support
|
||||
- `dompurify` must be in `client/package.json` (externalized by bundler)
|
||||
|
||||
## Guest Chat (anonymous preview)
|
||||
|
||||
Off by default (`ALLOW_GUEST_CHAT=false`). When enabled, unauthenticated
|
||||
visitors get a per-IP free quota (`GUEST_MESSAGE_MAX`, default 3) on the free
|
||||
Zen model (`GUEST_MODEL`, default `zen3-nano`) via the `Hanzo` custom endpoint
|
||||
(`api.hanzo.ai`). Exhausting the quota returns `402 {type:'GUEST_LIMIT'}` and
|
||||
the client opens the existing OpenID/hanzo.id login.
|
||||
|
||||
Security model (fail-closed, server-enforced):
|
||||
- `POST /api/auth/guest` issues a short-lived guest JWT (`{guest:true}`,
|
||||
per-token random id) signed with `JWT_SECRET`. Rate-limited per IP.
|
||||
- `requireGuestOrJwtAuth` (chat-completion route ONLY) accepts guest tokens;
|
||||
the standard `jwt` strategy rejects them everywhere else (no DB user), so
|
||||
every other route stays closed. `enforceGuestScope` pins endpoint+model and
|
||||
strips agents/tools/files/spec/preset. `guestMessageLimiter` (reuses the
|
||||
Redis `limiterCache`) enforces the per-IP quota.
|
||||
- Key files: `api/server/services/guestConfig.js`,
|
||||
`api/server/controllers/auth/GuestController.js`,
|
||||
`api/server/middleware/{requireGuestOrJwtAuth,enforceGuestScope}.js`,
|
||||
`api/server/middleware/limiters/{guestLimiters,guestMessageLimiter}.js`,
|
||||
router wiring in `api/server/routes/agents/index.js`. Client:
|
||||
`client/src/hooks/useGuestAuth.ts`, `AuthContext.tsx`,
|
||||
`components/Auth/GuestLimitDialog.tsx`.
|
||||
- Env: `ALLOW_GUEST_CHAT`, `GUEST_MESSAGE_MAX`, `GUEST_ENDPOINT`, `GUEST_MODEL`,
|
||||
`GUEST_TOKEN_EXPIRY`, `GUEST_TOKEN_MAX`, `GUEST_TOKEN_WINDOW`. Requires
|
||||
`HANZO_API_KEY` (the free publishable gateway key) and `USE_REDIS` for the
|
||||
shared per-IP quota across replicas.
|
||||
|
||||
## Cloud Agents (canonical /v1/agents)
|
||||
|
||||
Chat can RUN a user's canonical Hanzo Cloud agents (cloud `/v1/agents`, the ONE
|
||||
production agent registry) from the thread — alongside the LibreChat-legacy local
|
||||
agent builder, which is untouched.
|
||||
|
||||
- Two surfaces, ONE run path: the `/agent <name> [prompt]` slash command and the
|
||||
@mention picker (cloud agents appear as a `cloudAgent` type). Both funnel
|
||||
through `useRunCloudAgent` → `POST /api/agents/cloud/:name/run`. The @mention /
|
||||
`/agent` picker arms `/agent <name> ` in the composer; submit is intercepted in
|
||||
`ChatForm` (`parseAgentCommand`) and dispatched to the run path.
|
||||
- Server proxy + auth (token never reaches the browser): the chat backend reads
|
||||
the user's hanzo.id token from the server-side session
|
||||
(`req.session.openidTokens.idToken`, then `accessToken`, then the httpOnly
|
||||
cookies) and forwards it as `Authorization: Bearer` to cloud. Cloud's
|
||||
`SanitizeIdentity` (HIP-0026) validates it and pins `X-Org-Id` from the `owner`
|
||||
claim, so a user only ever reaches their OWN org's agents — chat never asserts
|
||||
an org. `requireJwtAuth` gates the proxy (guests rejected); missing token →
|
||||
honest 401, never a service-token fallback (fail-secure). Agent name is
|
||||
validated against cloud's handle grammar (traversal/SSRF guard); it is NOT an
|
||||
open proxy (three fixed endpoints). Principal guard: the on-behalf-of decision
|
||||
keys off the VALIDATED principal (`req.user.provider==='openid'`), not the
|
||||
mutable `token_provider` refresh-strategy cookie, so a local user (who never
|
||||
carries a hanzo.id token) can't run under a stale OpenID session. EVERY
|
||||
forwarded token — id_token preferred, access_token fallback, from session or
|
||||
the httpOnly cookie — must pass `isForwardableToken`: a decodable JWT whose
|
||||
`sub` EQUALS `req.user.openidId` (binding MANDATORY — absent `openidId`/`sub`
|
||||
or a mismatch ⇒ no forward, no fail-open) and is unexpired. Decode-only is
|
||||
sound because cloud does the authoritative JWKS validation over the SAME claims,
|
||||
so it runs as exactly that `sub`; the gate only ever removes a token. An
|
||||
unbindable/expired/foreign token yields an honest 401, never a fabricated or
|
||||
wrong-principal run.
|
||||
- id_token persistence is DECOUPLED from the refresh strategy: OIDC login ALWAYS
|
||||
persists the on-behalf-of BEARER (id_token + access_token) to
|
||||
`req.session.openidTokens` (server-side only), regardless of
|
||||
`OPENID_REUSE_TOKENS`. It does NOT persist the OIDC refresh credential in the
|
||||
decoupled default — the session refreshes via the local JWT cookie, so
|
||||
`session.openidTokens.refreshToken` is written ONLY in REUSE mode (where
|
||||
`refreshController`/`logoutController` read it). That keeps login, refresh AND
|
||||
logout on the local-JWT path byte-identical to a non-OpenID login; that flag
|
||||
SOLELY gates whether `/api/auth/refresh` performs the OIDC refresh-grant.
|
||||
The ~1h id_token is used while valid; durable refresh (hanzo.id/Casdoor OIDC
|
||||
refresh or an RFC-8693 token-exchange from the chat session) is a tracked
|
||||
FOLLOW-UP — the login-breaking refresh-grant is NOT enabled here.
|
||||
- Abuse limits (a run is a real billable completion): a per-user rate limiter
|
||||
(`cloudAgentLimiter`, `CLOUD_AGENT_USER_MAX`/`CLOUD_AGENT_WINDOW`) guards the
|
||||
whole `/cloud` router; the client caps input by UTF-8 **bytes** (128 KiB), caps
|
||||
the buffered response (4 MiB → 502), and sheds load past a process-wide in-flight
|
||||
ceiling (`CLOUD_AGENT_MAX_CONCURRENT`, 503).
|
||||
- Key files: backend `api/server/services/CloudAgentsClient.js`,
|
||||
`api/server/routes/agents/cloud.js` (mounted `/cloud` in
|
||||
`api/server/routes/agents/index.js`); data layer
|
||||
`packages/data-provider/src/{types/cloudAgents.ts,api-endpoints.ts,data-service.ts}`;
|
||||
client `client/src/hooks/Agents/useRunCloudAgent.ts`,
|
||||
`client/src/utils/agentCommand.ts`,
|
||||
`client/src/components/Chat/Input/AgentsCommand.tsx`, and the @mention wiring in
|
||||
`client/src/hooks/Input/useMentions.ts` + `Mention.tsx`.
|
||||
- Env: `HANZO_CLOUD_URL` (optional; falls back to the `OPENAI_BASE_URL` host).
|
||||
- Convergence path (later): chat's LibreChat-legacy `/api/agents` CRUD should
|
||||
converge onto cloud `/v1/agents`; this step only ADDS cloud-agent RUN.
|
||||
|
||||
## Unified cloud architecture (2026-07) — investigate-before-ripping map
|
||||
|
||||
hanzo.chat is the **chat view** of the Hanzo AI cloud (sibling to hanzo.app =
|
||||
builder, console = admin). This section is the honest map of what is ALREADY
|
||||
unified onto the Go backend (`api.hanzo.ai/v1`) vs the one real seam that is not.
|
||||
Verified by full call-graph + route-table trace; do NOT rip blind.
|
||||
|
||||
### What already routes through the Go backend `api.hanzo.ai/v1` (no shadow LLM)
|
||||
|
||||
- **Chat completions**: client `useSSE` → `POST /api/agents/chat/Hanzo` (all
|
||||
chat, incl. plain-model, goes through the agents framework) → custom-endpoint
|
||||
resolver (`packages/api/src/endpoints/custom/initialize.ts`) reads
|
||||
`HANZO_API_KEY` + literal `baseURL https://api.hanzo.ai/v1` from the loaded
|
||||
config → LangChain OpenAI client → **`POST https://api.hanzo.ai/v1/chat/completions`**
|
||||
(SSE stream, resumable via `GenerationJobManager`). Per-user `hk-` key +
|
||||
per-org Commerce debit; fail-closed 402. THIS is the one inference path.
|
||||
- **Code interpreter** → `LIBRECHAT_CODE_BASEURL` = cloud `/v1/exec`.
|
||||
- **Web search** → `webSearch` block (searxng+firecrawl contracts) = cloud
|
||||
`/v1/websearch`.
|
||||
- **Cloud agents** → `POST /api/agents/cloud/:name/run` server-proxies to cloud
|
||||
`/v1/agents` with the user's hanzo.id bearer (see "Cloud Agents" section).
|
||||
- **Model list**: curated **zen-only** (`fetch:false`) in the loaded config —
|
||||
NO raw upstream names (brand policy). Authoritative prod list lives in the
|
||||
`chat-config` ConfigMap (`universe infra/k8s/chat/configmap.yaml`); repo
|
||||
`librechat.yaml` mirrors it (one way).
|
||||
|
||||
### DEAD residue — do NOT treat as a live backend
|
||||
|
||||
- `config.yaml` (LiteLLM `model_list`/`litellm_params`), `docker/Dockerfile.{simple,dev,custom_ui}`
|
||||
(`CMD litellm`), `deploy/migrations/*` (`LiteLLM_*` Prisma tables),
|
||||
`CONTRIBUTING.md` (upstream LiteLLM's), `scripts/cleanup-{litellm,hanzo-chat}.sh`:
|
||||
all **unreferenced** by any compose/k8s/helm/Dockerfile.multi. Prod runs
|
||||
`node server/index.js` and hits `api.hanzo.ai/v1` directly — NO local litellm
|
||||
sidecar. This is upstream merge residue; safe to delete in a dedicated sweep.
|
||||
|
||||
### The ONE real parallel store (FLAG — needs a Go-backend home)
|
||||
|
||||
LibreChat's Express backend owns, in **MongoDB** (`HanzoChat` DB), all of:
|
||||
`convos`, `messages`, `presets`, `prompts`/`promptGroups`, `users`, `balances`/
|
||||
`transactions`, `files`, `sessions` (refresh-token hashes), plus agents/assistants/
|
||||
memory/RBAC. Schemas: `packages/data-schemas/src/schema/*`. This is the shadow
|
||||
store that is NOT on the Go backend.
|
||||
|
||||
- The Go backend (`hanzoai/ai`, mounted at bare `/v1/*` in cloud) DOES have a
|
||||
persistence home, but under **casibase names** (`/v1/get-chats`, `/v1/get-chat`,
|
||||
`/v1/add-chat`, `/v1/get-messages`, `/v1/add-message`, `/v1/get-usages`) — a
|
||||
different schema/shape than LibreChat's Mongo.
|
||||
- The canonical OpenAPI repo has `chat/openapi.yaml` describing the INTENDED
|
||||
LibreChat-shaped REST surface (`/v1/chat/convos`, `/v1/chat/messages`,
|
||||
`/v1/chat/presets`, `/v1/chat/balance`, `/v1/chat/auth/*`) — but the Go binary
|
||||
**does not implement it yet**, and `ai/openapi.yaml` under-documents the real
|
||||
casibase routes.
|
||||
- To truly kill the parallel store WITHOUT breaking live chat: the Go backend
|
||||
(or Base) must implement `chat/openapi.yaml` (conversations/messages/presets),
|
||||
then repoint chat's data layer at it behind a flag and dual-write during
|
||||
cutover. Until then Mongo stays (ripping it = data loss + dead chat).
|
||||
**Coordinate with the openapi agent** (canonical spec + SDK regen).
|
||||
|
||||
### IAM-native auth (HIP-0111) — federated to hanzo.id, LIVE
|
||||
|
||||
- **Prod (backend-proxied)**: LibreChat passport `openid-client` strategy,
|
||||
OIDC **discovery** from `${OPENID_ISSUER}` = `https://hanzo.id`
|
||||
(`/.well-known/openid-configuration`; discovery fetched via in-cluster
|
||||
`iam.hanzo.svc` to dodge the CF hairpin), client_id **`hanzo-chat`**, callback
|
||||
`/oauth/openid/callback`. Local email/password is OFF in prod
|
||||
(`ALLOW_EMAIL_LOGIN=false`, `ALLOW_REGISTRATION=false`); social OIDC only.
|
||||
Files: `api/strategies/openidStrategy.js`, `api/server/socialLogins.js`,
|
||||
`api/server/routes/oauth.js`. This IS IAM-native (federated), just not the
|
||||
console `@hanzo/iam-js-sdk` shape.
|
||||
- **Static/IAM SPA mode** (`Dockerfile.static`, not the live prod deploy): browser
|
||||
`@hanzo/iam` `BrowserIamSdk` PKCE straight to hanzo.id
|
||||
(`client/src/utils/iam.ts`, `OAuthCallback.tsx`). ⚠️ INCONSISTENCY: uses
|
||||
client_id **`app-chat`** while prod uses `hanzo-chat` — align to `hanzo-chat`.
|
||||
`@hanzo/iam` is pinned `^0.4.0` (HIP-0111 wants ≥0.11.0); this path is dormant.
|
||||
|
||||
### One brand system, but pick the RIGHT one for a Tailwind app
|
||||
|
||||
`@hanzo/ui` and `@hanzo/gui` are **two different, non-overlapping** design systems:
|
||||
- **`@hanzo/ui`** = shadcn/ui + Tailwind + Radix (multi-framework). chat is
|
||||
Vite + React 18 + Tailwind, so THIS is the correct shared lib. Already used:
|
||||
`client/src/components/Nav/HanzoHeader.tsx` mounts `@hanzo/ui/navigation`
|
||||
`HanzoHeader` for cross-app chrome. Monochrome rebrand already done (grey ramp,
|
||||
H mark, favicon = hanzo.app set).
|
||||
- **`@hanzo/gui`** = a **Tamagui** fork (Next.js 15 / React 19, RN-web) — console's
|
||||
stack. Forcing it into the Vite/React18 LibreChat client = a ground-up rewrite
|
||||
of a live product; NOT done. Unify by widening `@hanzo/ui` adoption + matching
|
||||
console's monochrome tokens, NOT by swapping component frameworks.
|
||||
|
||||
### Config filename caveat
|
||||
|
||||
`loadCustomConfig.js` defaults to **`chat.yaml`** (`CONFIG_PATH || <root>/chat.yaml`).
|
||||
Prod sets `CONFIG_PATH=/app/chat.yaml` (ConfigMap mount). Repo ships
|
||||
`librechat.yaml` (reference); a deploy that doesn't set `CONFIG_PATH` to it (or
|
||||
provide `chat.yaml`) falls back to the built-in `openAI` endpoint. `OPENAI_BASE_URL`
|
||||
in `compose.prod.yml` is inert here (built-in openAI reads `OPENAI_REVERSE_PROXY`).
|
||||
|
||||
## Internal Package Names
|
||||
|
||||
These are kept as-is from upstream (npm deps, not worth renaming):
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
chat
|
||||
Copyright (c) 2023 Hanzo AI Inc.
|
||||
|
||||
This product includes software from LibreChat (https://github.com/danny-avila/LibreChat), licensed under MIT:
|
||||
|
||||
Copyright (c) 2023 LibreChat (Danny Avila)
|
||||
@@ -1,5 +1,3 @@
|
||||
<p align="center"><img src=".github/hero.svg" alt="chat" width="880"></p>
|
||||
|
||||
# Hanzo AI Chat
|
||||
|
||||
AI-powered chat platform with enterprise features, using Hanzo's cloud API or local deployment.
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { ViolationTypes } = require('librechat-data-provider');
|
||||
const { billingSubject } = require('@hanzochat/api');
|
||||
const { createAutoRefillTransaction } = require('./Transaction');
|
||||
const { logViolation } = require('~/cache');
|
||||
const { getMultiplier } = require('./tx');
|
||||
@@ -23,21 +22,6 @@ function getMinBalance() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimum balance in COMMERCE cents (commerce balances are in cents).
|
||||
* Derived from HANZO_MIN_BALANCE (USD). Used by the commerce-first money gate.
|
||||
*/
|
||||
function getMinBalanceCents() {
|
||||
const envVal = process.env.HANZO_MIN_BALANCE;
|
||||
if (envVal != null && envVal !== '') {
|
||||
const usd = parseFloat(envVal);
|
||||
if (!isNaN(usd) && usd > 0) {
|
||||
return Math.round(usd * 100);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function isInvalidDate(date) {
|
||||
return isNaN(date);
|
||||
}
|
||||
@@ -66,24 +50,10 @@ const checkModelAccess = async function (userId, model) {
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates token cost and decides whether the request may proceed.
|
||||
*
|
||||
* MONEY GATE — Commerce-first and FAIL CLOSED (this is the money path; better to
|
||||
* block a user than bleed). When Commerce is configured and we can identify the
|
||||
* user's billing org (their IAM `organization`), Commerce is AUTHORITATIVE:
|
||||
* - sufficient org balance (>= HANZO_MIN_BALANCE) → allowed (decisive; we do
|
||||
* NOT fall through to the local tokenCredits gate, so startBalance:0 never
|
||||
* false-blocks a Commerce-funded user);
|
||||
* - insufficient / unreachable / no billing identity → BLOCKED.
|
||||
* The cloud gateway separately debits the user's own hk- key against this same
|
||||
* org, so this check is the pre-flight that yields a clean "claim credit" UX
|
||||
* instead of a raw gateway 402.
|
||||
*
|
||||
* When Commerce is not configured (or no billing identity), falls back to the
|
||||
* legacy local tokenCredits gate.
|
||||
* Simple check method that calculates token cost and returns balance info.
|
||||
* Integrates with Commerce balance gate when configured (fail-open).
|
||||
*/
|
||||
const checkBalanceRecord = async function ({
|
||||
req,
|
||||
user,
|
||||
model,
|
||||
endpoint,
|
||||
@@ -95,79 +65,7 @@ const checkBalanceRecord = async function ({
|
||||
const multiplier = getMultiplier({ valueKey, tokenType, model, endpoint, endpointTokenConfig });
|
||||
const tokenCost = amount * multiplier;
|
||||
|
||||
// Guests (anonymous preview) are NOT balance-gated here. Their spend is bounded
|
||||
// two ways, neither of which is an authed user's org balance: (1) the per-IP
|
||||
// guest message limiter (GUEST_MESSAGE_MAX, default 3) and (2) the separate,
|
||||
// small-capped, NON-exempt guest key (HANZO_API_KEY) whose own org's Commerce
|
||||
// balance the cloud gateway debits and 402s when empty. Running them through the
|
||||
// Commerce/local gate (startBalance:0) would block the free tier entirely.
|
||||
if (req?.user?.guest === true) {
|
||||
return { canSpend: true, balance: 0, tokenCost };
|
||||
}
|
||||
|
||||
const commerceClient = getCommerceClient();
|
||||
const billingOrg = (req?.user?.organization ?? '').toString().trim();
|
||||
// Per-user billing subject — prefer the one resolveHanzoCloudKey stamped from
|
||||
// the authoritative IAM identity; otherwise derive it from organization + email
|
||||
// (name == email for individual signups). This keys the gate on the SAME
|
||||
// account the gateway debits (per-user for the shared "hanzo" catch-all), not
|
||||
// the shared org balance.
|
||||
const subject =
|
||||
(req?.user?.billingSubject ?? '').toString().trim() ||
|
||||
billingSubject(billingOrg, req?.user?.email);
|
||||
|
||||
// Commerce-first authoritative gate (per-subject, fail closed).
|
||||
if (commerceClient && subject) {
|
||||
let commerceBalance;
|
||||
try {
|
||||
commerceBalance = await commerceClient.checkBalance(subject);
|
||||
} catch (err) {
|
||||
logger.error('[Balance.check] Commerce unreachable — blocking (fail-closed)', {
|
||||
user,
|
||||
subject,
|
||||
error: err.message,
|
||||
});
|
||||
return { canSpend: false, balance: 0, tokenCost, reason: 'commerce_unavailable' };
|
||||
}
|
||||
|
||||
const available = commerceBalance.available || 0;
|
||||
const minCents = Math.max(getMinBalanceCents(), 1);
|
||||
if (available < minCents) {
|
||||
logger.debug('[Balance.check] Commerce balance insufficient', {
|
||||
user,
|
||||
subject,
|
||||
available,
|
||||
minCents,
|
||||
});
|
||||
return { canSpend: false, balance: available, tokenCost, reason: 'commerce_insufficient' };
|
||||
}
|
||||
|
||||
// Tier/model access (fail-open: a tier hiccup must not block a funded user).
|
||||
if (model) {
|
||||
try {
|
||||
const modelAccess = await commerceClient.isModelAllowed(subject, model);
|
||||
if (!modelAccess.allowed) {
|
||||
return {
|
||||
canSpend: false,
|
||||
balance: available,
|
||||
tokenCost,
|
||||
reason: 'model_not_allowed',
|
||||
tier: modelAccess.tier,
|
||||
allowedModels: modelAccess.allowedModels,
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('[Balance.check] Tier check failed, allowing (balance ok)', {
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Decisive: Commerce says funded → allow. Do not consult local credits.
|
||||
return { canSpend: true, balance: available, tokenCost };
|
||||
}
|
||||
|
||||
// ── Legacy local-credit gate (Commerce not configured / no billing identity) ──
|
||||
// Retrieve the balance record
|
||||
let record = await Balance.findOne({ user }).lean();
|
||||
if (!record) {
|
||||
logger.debug('[Balance.check] No balance record found for user', { user });
|
||||
@@ -178,6 +76,46 @@ const checkBalanceRecord = async function ({
|
||||
};
|
||||
}
|
||||
|
||||
// Commerce balance gate: if configured, check Commerce first (fail-open)
|
||||
const commerceClient = getCommerceClient();
|
||||
if (commerceClient && record.commerceUserId) {
|
||||
try {
|
||||
const commerceBalance = await commerceClient.checkBalance(record.commerceUserId);
|
||||
if (!commerceBalance.sufficient) {
|
||||
logger.debug('[Balance.check] Commerce balance insufficient', {
|
||||
user,
|
||||
commerceUserId: record.commerceUserId,
|
||||
available: commerceBalance.available,
|
||||
});
|
||||
return { canSpend: false, balance: 0, tokenCost, reason: 'commerce_insufficient' };
|
||||
}
|
||||
|
||||
// Check model access via Commerce tier
|
||||
if (model) {
|
||||
const modelAccess = await commerceClient.isModelAllowed(record.commerceUserId, model);
|
||||
if (!modelAccess.allowed) {
|
||||
logger.debug('[Balance.check] Model not allowed for tier', {
|
||||
user,
|
||||
model,
|
||||
tier: modelAccess.tier,
|
||||
allowedModels: modelAccess.allowedModels,
|
||||
});
|
||||
return {
|
||||
canSpend: false,
|
||||
balance: record.tokenCredits,
|
||||
tokenCost,
|
||||
reason: 'model_not_allowed',
|
||||
tier: modelAccess.tier,
|
||||
allowedModels: modelAccess.allowedModels,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Fail-open: Commerce error → fall through to local check
|
||||
logger.warn('[Balance.check] Commerce check failed, falling back to local', { error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
let balance = record.tokenCredits;
|
||||
|
||||
// Check if credits have expired
|
||||
@@ -286,7 +224,7 @@ const addIntervalToDate = (date, value, unit) => {
|
||||
* @throws {Error} Throws an error if there's an issue with the balance check.
|
||||
*/
|
||||
const checkBalance = async ({ req, res, txData }) => {
|
||||
const result = await checkBalanceRecord({ ...txData, req });
|
||||
const result = await checkBalanceRecord(txData);
|
||||
if (result.canSpend) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
// Money-gate decision matrix for the Commerce-first, FAIL-CLOSED balance gate
|
||||
// (per-org via service token + X-Hanzo-Org; the cloud gateway debits the user's
|
||||
// own hk- key against the SAME org). Proves both directions: unmetered AI never
|
||||
// leaks (block on any ambiguity), and funded users + capped guests are never
|
||||
// wrongly blocked.
|
||||
|
||||
const mockClient = {
|
||||
checkBalance: jest.fn(),
|
||||
isModelAllowed: jest.fn(),
|
||||
};
|
||||
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() },
|
||||
}));
|
||||
jest.mock('~/server/services/CommerceClient', () => ({
|
||||
getCommerceClient: jest.fn(() => mockClient),
|
||||
}));
|
||||
jest.mock('~/cache', () => ({ logViolation: jest.fn().mockResolvedValue(undefined) }));
|
||||
jest.mock('./tx', () => ({ getMultiplier: () => 1 }));
|
||||
jest.mock('./Transaction', () => ({ createAutoRefillTransaction: jest.fn() }));
|
||||
jest.mock('~/db/models', () => ({ Balance: { findOne: jest.fn() } }));
|
||||
jest.mock('librechat-data-provider', () => ({
|
||||
ViolationTypes: { TOKEN_BALANCE: 'token_balance' },
|
||||
}));
|
||||
|
||||
const { checkBalance } = require('./balanceMethods');
|
||||
const { getCommerceClient } = require('~/server/services/CommerceClient');
|
||||
const { Balance } = require('~/db/models');
|
||||
|
||||
const argsFor = (user) => ({
|
||||
req: { user },
|
||||
res: {},
|
||||
txData: { user: user?.id ?? 'u1', tokenType: 'prompt', amount: 100, model: 'zen4-mini' },
|
||||
});
|
||||
|
||||
// checkBalance resolves `true` when allowed, throws a JSON error (carrying
|
||||
// `reason`) when blocked.
|
||||
const decide = async (user = { id: 'u1', organization: 'acme' }) => {
|
||||
try {
|
||||
const ok = await checkBalance(argsFor(user));
|
||||
return { allowed: ok, reason: null };
|
||||
} catch (err) {
|
||||
let reason = null;
|
||||
try {
|
||||
reason = JSON.parse(err.message).reason;
|
||||
} catch (_) {
|
||||
reason = err.message;
|
||||
}
|
||||
return { allowed: false, reason };
|
||||
}
|
||||
};
|
||||
|
||||
describe('checkBalance — Commerce-first money gate (fail closed)', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
getCommerceClient.mockReturnValue(mockClient);
|
||||
mockClient.isModelAllowed.mockResolvedValue({ allowed: true, tier: 't', allowedModels: ['*'] });
|
||||
process.env.HANZO_MIN_BALANCE = '1.00'; // 100 cents minimum
|
||||
});
|
||||
|
||||
it('ALLOWS a guest WITHOUT consulting Commerce (capped by IP limiter + guest key)', async () => {
|
||||
const { allowed } = await decide({ id: 'g1', guest: true });
|
||||
expect(allowed).toBe(true);
|
||||
expect(mockClient.checkBalance).not.toHaveBeenCalled();
|
||||
expect(Balance.findOne).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ALLOWS a funded org (available >= min)', async () => {
|
||||
mockClient.checkBalance.mockResolvedValue({ sufficient: true, available: 500 });
|
||||
const { allowed, reason } = await decide();
|
||||
expect(allowed).toBe(true);
|
||||
expect(reason).toBeNull();
|
||||
expect(mockClient.checkBalance).toHaveBeenCalledWith('acme');
|
||||
});
|
||||
|
||||
it('BLOCKS commerce_insufficient when balance below minimum ($0 → claim $5)', async () => {
|
||||
mockClient.checkBalance.mockResolvedValue({ sufficient: false, available: 0 });
|
||||
const { allowed, reason } = await decide();
|
||||
expect(allowed).toBe(false);
|
||||
expect(reason).toBe('commerce_insufficient');
|
||||
});
|
||||
|
||||
it('BLOCKS commerce_insufficient when balance is a tiny non-zero below min', async () => {
|
||||
mockClient.checkBalance.mockResolvedValue({ sufficient: true, available: 50 }); // $0.50 < $1
|
||||
const { allowed, reason } = await decide();
|
||||
expect(allowed).toBe(false);
|
||||
expect(reason).toBe('commerce_insufficient');
|
||||
});
|
||||
|
||||
it('FAILS CLOSED (commerce_unavailable) when Commerce throws — never bleeds', async () => {
|
||||
mockClient.checkBalance.mockRejectedValue(new Error('ECONNREFUSED'));
|
||||
const { allowed, reason } = await decide();
|
||||
expect(allowed).toBe(false);
|
||||
expect(reason).toBe('commerce_unavailable');
|
||||
});
|
||||
|
||||
it('BLOCKS model_not_allowed for a funded org whose tier forbids the model', async () => {
|
||||
mockClient.checkBalance.mockResolvedValue({ sufficient: true, available: 500 });
|
||||
mockClient.isModelAllowed.mockResolvedValue({
|
||||
allowed: false,
|
||||
tier: 'free',
|
||||
allowedModels: ['zen3-nano'],
|
||||
});
|
||||
const { allowed, reason } = await decide();
|
||||
expect(allowed).toBe(false);
|
||||
expect(reason).toBe('model_not_allowed');
|
||||
});
|
||||
|
||||
it('ALLOWS a funded org even if the tier check itself errors (fail-open tier, balance ok)', async () => {
|
||||
mockClient.checkBalance.mockResolvedValue({ sufficient: true, available: 500 });
|
||||
mockClient.isModelAllowed.mockRejectedValue(new Error('tier-check 500'));
|
||||
const { allowed } = await decide();
|
||||
expect(allowed).toBe(true);
|
||||
});
|
||||
|
||||
it('FAILS CLOSED for an authed user with NO billing org (no commerce branch, no local credits)', async () => {
|
||||
Balance.findOne.mockReturnValue({ lean: () => Promise.resolve(null) });
|
||||
const { allowed } = await decide({ id: 'u2', organization: '' });
|
||||
expect(allowed).toBe(false);
|
||||
expect(mockClient.checkBalance).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,6 @@
|
||||
const { getEndpointsConfig } = require('~/server/services/Config');
|
||||
const { buildGuestEndpointsConfig } = require('~/server/services/guestConfig');
|
||||
|
||||
async function endpointController(req, res) {
|
||||
if (req.user?.guest === true) {
|
||||
return res.send(JSON.stringify(buildGuestEndpointsConfig()));
|
||||
}
|
||||
const endpointsConfig = await getEndpointsConfig(req);
|
||||
res.send(JSON.stringify(endpointsConfig));
|
||||
}
|
||||
|
||||
@@ -72,9 +72,6 @@ const updateFavoritesController = async (req, res) => {
|
||||
|
||||
const getFavoritesController = async (req, res) => {
|
||||
try {
|
||||
if (req.user?.guest === true) {
|
||||
return res.status(200).json([]);
|
||||
}
|
||||
const userId = req.user.id;
|
||||
const user = await getUserById(userId, 'favorites');
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { CacheKeys } = require('librechat-data-provider');
|
||||
const { loadDefaultModels, loadConfigModels } = require('~/server/services/Config');
|
||||
const { buildGuestModelsConfig } = require('~/server/services/guestConfig');
|
||||
const { getLogStores } = require('~/cache');
|
||||
|
||||
/**
|
||||
@@ -40,9 +39,6 @@ async function loadModels(req) {
|
||||
|
||||
async function modelController(req, res) {
|
||||
try {
|
||||
if (req.user?.guest === true) {
|
||||
return res.send(buildGuestModelsConfig());
|
||||
}
|
||||
const modelConfig = await loadModels(req);
|
||||
res.send(modelConfig);
|
||||
} catch (error) {
|
||||
|
||||
@@ -40,16 +40,12 @@ const { invalidateCachedTools } = require('~/server/services/Config/getCachedToo
|
||||
const { needsRefresh, getNewS3URL } = require('~/server/services/Files/S3/crud');
|
||||
const { processDeleteRequest } = require('~/server/services/Files/process');
|
||||
const { getAppConfig } = require('~/server/services/Config');
|
||||
const { buildGuestUser } = require('~/server/services/guestConfig');
|
||||
const { deleteToolCalls } = require('~/models/ToolCall');
|
||||
const { deleteUserPrompts } = require('~/models/Prompt');
|
||||
const { deleteUserAgents } = require('~/models/Agent');
|
||||
const { getLogStores } = require('~/cache');
|
||||
|
||||
const getUserController = async (req, res) => {
|
||||
if (req.user?.guest === true) {
|
||||
return res.status(200).send(buildGuestUser(req.user));
|
||||
}
|
||||
const appConfig = await getAppConfig({ role: req.user?.role });
|
||||
/** @type {IUser} */
|
||||
const userData = req.user.toObject != null ? req.user.toObject() : { ...req.user };
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
/**
|
||||
* Guest-scoped bootstrap controllers: verify that a guest principal receives
|
||||
* safe, scoped data (and never triggers a DB lookup) on the read-only endpoints
|
||||
* the chat composer hard-requires.
|
||||
*/
|
||||
|
||||
jest.mock('@hanzochat/api', () => ({
|
||||
isEnabled: (value) => value === 'true' || value === true,
|
||||
}));
|
||||
jest.mock('librechat-data-provider', () => ({
|
||||
EModelEndpoint: { custom: 'custom' },
|
||||
CacheKeys: { CONFIG_STORE: 'CONFIG_STORE', MODELS_CONFIG: 'MODELS_CONFIG' },
|
||||
Tools: {},
|
||||
Constants: {},
|
||||
FileSources: { local: 'local', s3: 's3' },
|
||||
}));
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
logger: { error: jest.fn(), warn: jest.fn(), debug: jest.fn(), info: jest.fn() },
|
||||
webSearchKeys: [],
|
||||
}));
|
||||
|
||||
// --- EndpointController deps ---
|
||||
jest.mock('~/server/services/Config', () => ({
|
||||
getEndpointsConfig: jest.fn(),
|
||||
loadDefaultModels: jest.fn(),
|
||||
loadConfigModels: jest.fn(),
|
||||
getAppConfig: jest.fn(),
|
||||
}));
|
||||
jest.mock('~/cache', () => ({ getLogStores: jest.fn(() => ({ get: jest.fn(), set: jest.fn() })) }));
|
||||
|
||||
const { getEndpointsConfig } = require('~/server/services/Config');
|
||||
|
||||
const endpointController = require('~/server/controllers/EndpointController');
|
||||
const { modelController } = require('~/server/controllers/ModelController');
|
||||
|
||||
const guestReq = (overrides = {}) => ({
|
||||
user: { id: 'guest_abc', role: 'GUEST', name: 'Guest', guest: true },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const mockRes = () => {
|
||||
const res = {};
|
||||
res.status = jest.fn().mockReturnValue(res);
|
||||
res.send = jest.fn().mockReturnValue(res);
|
||||
res.json = jest.fn().mockReturnValue(res);
|
||||
return res;
|
||||
};
|
||||
|
||||
describe('guest bootstrap controllers', () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
process.env.ALLOW_GUEST_CHAT = 'true';
|
||||
process.env.GUEST_ENDPOINT = 'Hanzo';
|
||||
process.env.GUEST_MODEL = 'zen5-mini';
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
describe('GET /api/endpoints', () => {
|
||||
it('returns ONLY the guest endpoint, no DB/config read', async () => {
|
||||
const req = guestReq();
|
||||
const res = mockRes();
|
||||
await endpointController(req, res);
|
||||
expect(getEndpointsConfig).not.toHaveBeenCalled();
|
||||
const payload = JSON.parse(res.send.mock.calls[0][0]);
|
||||
expect(Object.keys(payload)).toEqual(['Hanzo']);
|
||||
expect(payload.Hanzo).toMatchObject({ type: 'custom', userProvide: false });
|
||||
});
|
||||
|
||||
it('falls through to the real config for non-guest users', async () => {
|
||||
getEndpointsConfig.mockResolvedValue({ Hanzo: {}, openAI: {} });
|
||||
const req = { user: { id: 'real', role: 'USER' } };
|
||||
const res = mockRes();
|
||||
await endpointController(req, res);
|
||||
expect(getEndpointsConfig).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/models', () => {
|
||||
it('returns ONLY the single guest model under the guest endpoint', async () => {
|
||||
const req = guestReq();
|
||||
const res = mockRes();
|
||||
await modelController(req, res);
|
||||
expect(res.send).toHaveBeenCalledWith({ Hanzo: ['zen5-mini'] });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -891,20 +891,9 @@ class AgentClient extends BaseClient {
|
||||
config.signal = null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Capture before running the graph. `run.processStream` (via
|
||||
* `@librechat/agents`) treats the passed `config` as owned and, during its
|
||||
* post-stream cleanup, sets `config.configurable = undefined` to break the
|
||||
* reference chain that keeps heavy graph state (base64 images/PDFs) alive.
|
||||
* Reading `config.configurable.hide_sequential_outputs` AFTER `runAgents`
|
||||
* therefore throws `Cannot read properties of undefined`, which surfaces as
|
||||
* the post-reply error banner. Hoisting the read keeps the deprecated Agent
|
||||
* Chain filter working without depending on post-run config state.
|
||||
*/
|
||||
const hideSequentialOutputs = config.configurable.hide_sequential_outputs;
|
||||
await runAgents(initialMessages);
|
||||
/** @deprecated Agent Chain */
|
||||
if (hideSequentialOutputs) {
|
||||
if (config.configurable.hide_sequential_outputs) {
|
||||
this.contentParts = this.contentParts.filter((part, index) => {
|
||||
// Include parts that are either:
|
||||
// 1. At or after the finalContentStart index
|
||||
|
||||
@@ -15,7 +15,6 @@ jest.mock('@hanzochat/api', () => ({
|
||||
checkAccess: jest.fn(),
|
||||
initializeAgent: jest.fn(),
|
||||
createMemoryProcessor: jest.fn(),
|
||||
createRun: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/models/Agent', () => ({
|
||||
@@ -2258,122 +2257,3 @@ describe('AgentClient - titleConvo', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('AgentClient - chatCompletion post-reply banner regression', () => {
|
||||
/**
|
||||
* Regression for the post-reply error banner:
|
||||
* "Cannot read properties of undefined (reading 'hide_sequential_outputs')".
|
||||
*
|
||||
* `run.processStream` (from `@librechat/agents` >= 3.1.52) treats the passed
|
||||
* `config` as owned and, in its post-stream cleanup, sets
|
||||
* `config.configurable = undefined` to break the AsyncLocalStorage reference
|
||||
* chain that keeps heavy graph state (base64 images/PDFs) alive. The
|
||||
* controller must therefore NOT read `config.configurable.hide_sequential_outputs`
|
||||
* AFTER the stream completes, or it throws and pushes an ERROR content part
|
||||
* (the red banner) which also aborts the post-stream finalize (auto-titling).
|
||||
* This test reproduces that mutation and asserts no ERROR part is produced.
|
||||
*/
|
||||
const { createRun } = require('@hanzochat/api');
|
||||
const { ContentTypes } = require('librechat-data-provider');
|
||||
let processStreamMock;
|
||||
|
||||
const makeClient = (agentOverrides = {}) => {
|
||||
const agent = {
|
||||
id: 'agent-123',
|
||||
endpoint: EModelEndpoint.openAI,
|
||||
provider: EModelEndpoint.openAI,
|
||||
model_parameters: { model: 'gpt-4' },
|
||||
...agentOverrides,
|
||||
};
|
||||
const req = {
|
||||
user: { id: 'user-123' },
|
||||
body: {},
|
||||
config: { endpoints: {} },
|
||||
};
|
||||
const client = new AgentClient({
|
||||
req,
|
||||
res: {},
|
||||
agent,
|
||||
endpoint: EModelEndpoint.openAI,
|
||||
contentParts: [],
|
||||
collectedUsage: [],
|
||||
artifactPromises: [],
|
||||
endpointTokenConfig: {},
|
||||
});
|
||||
client.conversationId = 'convo-123';
|
||||
client.responseMessageId = 'response-123';
|
||||
client.parentMessageId = '00000000-0000-0000-0000-000000000000';
|
||||
client.user = 'user-123';
|
||||
// Avoid DB / memory side effects unrelated to this regression.
|
||||
client.recordCollectedUsage = jest.fn().mockResolvedValue();
|
||||
client.runMemory = jest.fn().mockResolvedValue(undefined);
|
||||
client.awaitMemoryWithTimeout = jest.fn().mockResolvedValue(undefined);
|
||||
return client;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
// Faithfully mimic `@librechat/agents` Run.processStream cleanup: it nulls
|
||||
// the caller's `config.configurable` after the stream finishes.
|
||||
processStreamMock = jest.fn(async (_inputs, config) => {
|
||||
config.configurable = undefined;
|
||||
return [];
|
||||
});
|
||||
createRun.mockResolvedValue({
|
||||
Graph: {},
|
||||
processStream: processStreamMock,
|
||||
});
|
||||
});
|
||||
|
||||
const errorParts = (client) =>
|
||||
(client.getContentParts() || []).filter((p) => p && p.type === ContentTypes.ERROR);
|
||||
|
||||
it('does not push an error content part when processStream nulls config.configurable', async () => {
|
||||
const client = makeClient();
|
||||
|
||||
await expect(
|
||||
client.sendCompletion('hello', { abortController: new AbortController() }),
|
||||
).resolves.toBeDefined();
|
||||
|
||||
expect(processStreamMock).toHaveBeenCalledTimes(1);
|
||||
// The config passed to processStream had configurable nulled (reproduction sanity check).
|
||||
const passedConfig = processStreamMock.mock.calls[0][1];
|
||||
expect(passedConfig.configurable).toBeUndefined();
|
||||
// The fix: no banner-producing ERROR part despite the nulled configurable.
|
||||
expect(errorParts(client)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('still applies the deprecated hide_sequential_outputs filter using the pre-stream value', async () => {
|
||||
const client = makeClient({ hide_sequential_outputs: true });
|
||||
// Seed multiple content parts; the filter keeps only the last part plus
|
||||
// tool_call parts. With the value captured before the stream, the filter
|
||||
// must run even though config.configurable is undefined afterwards.
|
||||
client.contentParts = [
|
||||
{ type: 'text', text: 'intermediate-1' },
|
||||
{ type: 'text', text: 'intermediate-2' },
|
||||
{ type: 'text', text: 'final' },
|
||||
];
|
||||
|
||||
await client.sendCompletion('hello', { abortController: new AbortController() });
|
||||
|
||||
expect(errorParts(client)).toHaveLength(0);
|
||||
// hide_sequential_outputs collapses to the final part only (no tool calls present).
|
||||
expect(client.contentParts).toEqual([{ type: 'text', text: 'final' }]);
|
||||
});
|
||||
|
||||
it('leaves content parts intact when hide_sequential_outputs is falsy', async () => {
|
||||
const client = makeClient({ hide_sequential_outputs: false });
|
||||
client.contentParts = [
|
||||
{ type: 'text', text: 'a' },
|
||||
{ type: 'text', text: 'b' },
|
||||
];
|
||||
|
||||
await client.sendCompletion('hello', { abortController: new AbortController() });
|
||||
|
||||
expect(errorParts(client)).toHaveLength(0);
|
||||
expect(client.contentParts).toEqual([
|
||||
{ type: 'text', text: 'a' },
|
||||
{ type: 'text', text: 'b' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
const { randomUUID } = require('node:crypto');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { getGuestConfig, GUEST_ROLE } = require('~/server/services/guestConfig');
|
||||
|
||||
/**
|
||||
* Issues a short-lived guest token for anonymous preview chat.
|
||||
*
|
||||
* The token is signed with `JWT_SECRET` and carries a `guest: true` claim plus a
|
||||
* per-token random id, so guest principals are ephemeral and isolated from each
|
||||
* other. It is rejected by the standard `jwt` strategy (no matching DB user),
|
||||
* which keeps every non-chat route closed to guests.
|
||||
*
|
||||
* @param {ServerRequest} req
|
||||
* @param {ServerResponse} res
|
||||
*/
|
||||
const guestTokenController = async (req, res) => {
|
||||
const config = getGuestConfig();
|
||||
|
||||
if (!config.enabled) {
|
||||
return res.status(404).json({ message: 'Guest chat is not enabled' });
|
||||
}
|
||||
|
||||
if (!process.env.JWT_SECRET) {
|
||||
logger.error('[guestTokenController] JWT_SECRET is not configured');
|
||||
return res.status(500).json({ message: 'Server misconfiguration' });
|
||||
}
|
||||
|
||||
const id = `guest_${randomUUID()}`;
|
||||
const expiresInSeconds = Math.floor(config.tokenExpiryMs / 1000);
|
||||
|
||||
const token = jwt.sign({ id, guest: true, role: GUEST_ROLE }, process.env.JWT_SECRET, {
|
||||
expiresIn: expiresInSeconds,
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
token,
|
||||
expiresIn: expiresInSeconds,
|
||||
endpoint: config.endpoint,
|
||||
model: config.model,
|
||||
messageMax: config.messageMax,
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = { guestTokenController };
|
||||
@@ -7,11 +7,7 @@ const {
|
||||
generateAdminExchangeCode,
|
||||
} = require('@hanzochat/api');
|
||||
const { syncUserEntraGroupMemberships } = require('~/server/services/PermissionService');
|
||||
const {
|
||||
setAuthTokens,
|
||||
setOpenIDAuthTokens,
|
||||
persistOpenIDTokensToSession,
|
||||
} = require('~/server/services/AuthService');
|
||||
const { setAuthTokens, setOpenIDAuthTokens } = require('~/server/services/AuthService');
|
||||
const getLogStores = require('~/cache/getLogStores');
|
||||
const { checkBan } = require('~/server/middleware');
|
||||
const { generateToken } = require('~/models');
|
||||
@@ -60,32 +56,13 @@ function createOAuthHandler(redirectUri = domains.client) {
|
||||
}
|
||||
|
||||
/** Standard OAuth flow - set cookies and redirect */
|
||||
if (req.user && req.user.provider == 'openid') {
|
||||
if (isEnabled(process.env.OPENID_REUSE_TOKENS) === true) {
|
||||
/**
|
||||
* REUSE path: the OIDC tokenset drives BOTH the app auth token and the
|
||||
* refresh grant (`/api/auth/refresh` performs an OIDC refresh). Unchanged.
|
||||
* setOpenIDAuthTokens already persists the id_token to the session.
|
||||
*/
|
||||
await syncUserEntraGroupMemberships(req.user, req.user.tokenset.access_token);
|
||||
setOpenIDAuthTokens(req.user.tokenset, req, res, req.user._id.toString());
|
||||
} else {
|
||||
/**
|
||||
* Decoupled path (the live default, REUSE disabled). Keep refresh on the
|
||||
* local-JWT path so login/refresh cookies stay byte-identical, but ALSO
|
||||
* persist the id_token server-side so downstream on-behalf-of cloud calls
|
||||
* (POST /api/agents/cloud/:name/run -> cloud /v1/agents) can run as this
|
||||
* hanzo.id principal. OPENID_REUSE_TOKENS still SOLELY gates the OIDC
|
||||
* refresh-grant; it no longer gates id_token persistence.
|
||||
*/
|
||||
await setAuthTokens(req.user._id, res);
|
||||
try {
|
||||
persistOpenIDTokensToSession(req, req.user.tokenset);
|
||||
} catch (persistErr) {
|
||||
/** On-behalf-of is best-effort; NEVER let it break the login redirect. */
|
||||
logger.warn('[OAuth] Failed to persist OpenID tokens for on-behalf-of:', persistErr);
|
||||
}
|
||||
}
|
||||
if (
|
||||
req.user &&
|
||||
req.user.provider == 'openid' &&
|
||||
isEnabled(process.env.OPENID_REUSE_TOKENS) === true
|
||||
) {
|
||||
await syncUserEntraGroupMemberships(req.user, req.user.tokenset.access_token);
|
||||
setOpenIDAuthTokens(req.user.tokenset, req, res, req.user._id.toString());
|
||||
} else {
|
||||
await setAuthTokens(req.user._id, res);
|
||||
}
|
||||
|
||||
@@ -5,11 +5,10 @@ const mockGenerateAdminExchangeCode = jest.fn();
|
||||
const mockSyncUserEntraGroupMemberships = jest.fn();
|
||||
const mockSetAuthTokens = jest.fn();
|
||||
const mockSetOpenIDAuthTokens = jest.fn();
|
||||
const mockPersistOpenIDTokensToSession = jest.fn();
|
||||
const mockGetLogStores = jest.fn();
|
||||
const mockCheckBan = jest.fn();
|
||||
const mockGenerateToken = jest.fn();
|
||||
const mockLogger = { info: jest.fn(), error: jest.fn(), warn: jest.fn() };
|
||||
const mockLogger = { info: jest.fn(), error: jest.fn() };
|
||||
|
||||
jest.mock('librechat-data-provider', () => ({
|
||||
CacheKeys: { ADMIN_OAUTH_EXCHANGE: 'admin-oauth-exchange' },
|
||||
@@ -34,7 +33,6 @@ jest.mock('~/server/services/PermissionService', () => ({
|
||||
jest.mock('~/server/services/AuthService', () => ({
|
||||
setAuthTokens: (...args) => mockSetAuthTokens(...args),
|
||||
setOpenIDAuthTokens: (...args) => mockSetOpenIDAuthTokens(...args),
|
||||
persistOpenIDTokensToSession: (...args) => mockPersistOpenIDTokensToSession(...args),
|
||||
}));
|
||||
|
||||
jest.mock(
|
||||
@@ -150,87 +148,4 @@ describe('createOAuthHandler', () => {
|
||||
expect(mockSetAuthTokens).not.toHaveBeenCalled();
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// The decouple: id_token persistence for downstream on-behalf-of is split from
|
||||
// the refresh strategy. OPENID_REUSE_TOKENS still SOLELY gates the OIDC
|
||||
// refresh-grant; it no longer gates whether the id_token is persisted.
|
||||
describe('standard OpenID flow — id_token persistence decouple', () => {
|
||||
beforeEach(() => {
|
||||
/** Not an admin cross-origin redirect — exercise the cookie/session path. */
|
||||
mockIsAdminPanelRedirect.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it('REUSE_TOKENS=false: keeps local-JWT refresh AND persists the id_token to session', async () => {
|
||||
process.env.OPENID_REUSE_TOKENS = 'false';
|
||||
const handler = createOAuthHandler('http://localhost:3080');
|
||||
const req = buildReq();
|
||||
const res = buildRes();
|
||||
const next = jest.fn();
|
||||
|
||||
await handler(req, res, next);
|
||||
|
||||
/** Refresh stays on the local-JWT path — login/refresh cookies unchanged. */
|
||||
expect(mockSetAuthTokens).toHaveBeenCalledWith(req.user._id, res);
|
||||
/** id_token IS persisted for on-behalf-of, regardless of REUSE_TOKENS. */
|
||||
expect(mockPersistOpenIDTokensToSession).toHaveBeenCalledWith(req, req.user.tokenset);
|
||||
/** The OIDC-reuse writer (which flips refresh to the OIDC grant) is NOT used. */
|
||||
expect(mockSetOpenIDAuthTokens).not.toHaveBeenCalled();
|
||||
expect(res.redirect).toHaveBeenCalledWith('http://localhost:3080');
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('REUSE_TOKENS=true: uses the OIDC writer (refresh path unchanged); no separate persist call', async () => {
|
||||
process.env.OPENID_REUSE_TOKENS = 'true';
|
||||
const handler = createOAuthHandler('http://localhost:3080');
|
||||
const req = buildReq();
|
||||
const res = buildRes();
|
||||
const next = jest.fn();
|
||||
|
||||
await handler(req, res, next);
|
||||
|
||||
expect(mockSyncUserEntraGroupMemberships).toHaveBeenCalledWith(
|
||||
req.user,
|
||||
'openid-access-token',
|
||||
);
|
||||
expect(mockSetOpenIDAuthTokens).toHaveBeenCalledWith(req.user.tokenset, req, res, 'user-123');
|
||||
/** setOpenIDAuthTokens already persists the id_token; no separate call. */
|
||||
expect(mockPersistOpenIDTokensToSession).not.toHaveBeenCalled();
|
||||
expect(mockSetAuthTokens).not.toHaveBeenCalled();
|
||||
expect(res.redirect).toHaveBeenCalledWith('http://localhost:3080');
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('local user: setAuthTokens only, never persists an OpenID token', async () => {
|
||||
process.env.OPENID_REUSE_TOKENS = 'false';
|
||||
const handler = createOAuthHandler('http://localhost:3080');
|
||||
const req = buildReq({ user: { _id: 'local-1', provider: 'local', email: 'l@example.com' } });
|
||||
const res = buildRes();
|
||||
const next = jest.fn();
|
||||
|
||||
await handler(req, res, next);
|
||||
|
||||
expect(mockSetAuthTokens).toHaveBeenCalledWith('local-1', res);
|
||||
expect(mockPersistOpenIDTokensToSession).not.toHaveBeenCalled();
|
||||
expect(mockSetOpenIDAuthTokens).not.toHaveBeenCalled();
|
||||
expect(res.redirect).toHaveBeenCalledWith('http://localhost:3080');
|
||||
});
|
||||
|
||||
it('login redirect survives a persistence failure (login is paramount)', async () => {
|
||||
process.env.OPENID_REUSE_TOKENS = 'false';
|
||||
mockPersistOpenIDTokensToSession.mockImplementation(() => {
|
||||
throw new Error('session store down');
|
||||
});
|
||||
const handler = createOAuthHandler('http://localhost:3080');
|
||||
const req = buildReq();
|
||||
const res = buildRes();
|
||||
const next = jest.fn();
|
||||
|
||||
await handler(req, res, next);
|
||||
|
||||
/** The redirect still happens; the error is swallowed (best-effort). */
|
||||
expect(mockSetAuthTokens).toHaveBeenCalledWith(req.user._id, res);
|
||||
expect(res.redirect).toHaveBeenCalledWith('http://localhost:3080');
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -136,7 +136,7 @@ const startServer = async () => {
|
||||
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
res.setHeader(
|
||||
'Content-Security-Policy',
|
||||
"default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://analytics.hanzo.ai https://static.cloudflareinsights.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:; media-src 'self' data: blob:; connect-src 'self' https://*.hanzo.ai https://*.hanzo.chat wss://*.hanzo.chat https://static.cloudflareinsights.com https://cloudflareinsights.com; frame-ancestors 'none';",
|
||||
"default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; connect-src 'self' https://*.hanzo.ai https://*.hanzo.chat wss://*.hanzo.chat; frame-ancestors 'none';",
|
||||
);
|
||||
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
|
||||
next();
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
logger: { error: jest.fn(), warn: jest.fn(), debug: jest.fn(), info: jest.fn() },
|
||||
}));
|
||||
|
||||
jest.mock('librechat-data-provider', () => ({
|
||||
EModelEndpoint: { custom: 'custom', agents: 'agents' },
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/guestConfig', () => ({
|
||||
getGuestConfig: jest.fn(),
|
||||
}));
|
||||
|
||||
const { getGuestConfig } = require('~/server/services/guestConfig');
|
||||
const enforceGuestScope = require('../enforceGuestScope');
|
||||
|
||||
const mockRes = () => ({ status: jest.fn().mockReturnThis(), json: jest.fn().mockReturnThis() });
|
||||
|
||||
describe('enforceGuestScope', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
getGuestConfig.mockReturnValue({
|
||||
enabled: true,
|
||||
endpoint: 'Hanzo',
|
||||
model: 'zen3-nano',
|
||||
messageMax: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('passes non-guest requests through untouched', () => {
|
||||
const req = { user: { id: 'u1', role: 'USER' }, body: { endpoint: 'OpenAI', model: 'gpt-4o' } };
|
||||
const next = jest.fn();
|
||||
enforceGuestScope(req, mockRes(), next);
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
expect(req.body.endpoint).toBe('OpenAI');
|
||||
expect(req.body.model).toBe('gpt-4o');
|
||||
});
|
||||
|
||||
it('rejects a guest naming a different endpoint (403)', () => {
|
||||
const req = { user: { id: 'g1', guest: true }, body: { endpoint: 'OpenAI' } };
|
||||
const res = mockRes();
|
||||
const next = jest.fn();
|
||||
enforceGuestScope(req, res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a guest naming a different (paid) model (403)', () => {
|
||||
const req = { user: { id: 'g1', guest: true }, body: { endpoint: 'Hanzo', model: 'zen4-max' } };
|
||||
const res = mockRes();
|
||||
const next = jest.fn();
|
||||
enforceGuestScope(req, res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('pins endpoint, type, and model for a compliant guest request', () => {
|
||||
const req = {
|
||||
user: { id: 'g1', guest: true },
|
||||
body: { endpoint: 'Hanzo', model: 'zen3-nano', text: 'hi' },
|
||||
};
|
||||
const next = jest.fn();
|
||||
enforceGuestScope(req, mockRes(), next);
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
expect(req.body.endpoint).toBe('Hanzo');
|
||||
expect(req.body.endpointType).toBe('custom');
|
||||
expect(req.body.model).toBe('zen3-nano');
|
||||
});
|
||||
|
||||
it('pins endpoint/model even when the guest omits them', () => {
|
||||
const req = { user: { id: 'g1', guest: true }, body: { text: 'hi' } };
|
||||
const next = jest.fn();
|
||||
enforceGuestScope(req, mockRes(), next);
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
expect(req.body.endpoint).toBe('Hanzo');
|
||||
expect(req.body.model).toBe('zen3-nano');
|
||||
});
|
||||
|
||||
it('strips every privileged capability from a guest request', () => {
|
||||
const req = {
|
||||
user: { id: 'g1', guest: true },
|
||||
body: {
|
||||
endpoint: 'Hanzo',
|
||||
model: 'zen3-nano',
|
||||
agent_id: 'agent_evil',
|
||||
spec: 'paid-spec',
|
||||
preset: { foo: 'bar' },
|
||||
files: [{ file_id: 'f1' }],
|
||||
tools: ['execute_code'],
|
||||
tool_resources: { x: 1 },
|
||||
resendFiles: true,
|
||||
promptPrefix: 'jailbreak',
|
||||
web_search: true,
|
||||
},
|
||||
};
|
||||
const next = jest.fn();
|
||||
enforceGuestScope(req, mockRes(), next);
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
expect(req.body.agent_id).toBeUndefined();
|
||||
expect(req.body.spec).toBeUndefined();
|
||||
expect(req.body.preset).toBeUndefined();
|
||||
expect(req.body.files).toBeUndefined();
|
||||
expect(req.body.tools).toBeUndefined();
|
||||
expect(req.body.tool_resources).toBeUndefined();
|
||||
expect(req.body.resendFiles).toBeUndefined();
|
||||
expect(req.body.promptPrefix).toBeUndefined();
|
||||
expect(req.body.web_search).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns 404 for a guest when guest chat is disabled mid-flight', () => {
|
||||
getGuestConfig.mockReturnValue({ enabled: false, endpoint: 'Hanzo', model: 'zen3-nano' });
|
||||
const req = { user: { id: 'g1', guest: true }, body: {} };
|
||||
const res = mockRes();
|
||||
const next = jest.fn();
|
||||
enforceGuestScope(req, res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(404);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,132 +0,0 @@
|
||||
/**
|
||||
* Integration test for the guest chat middleware chain composition:
|
||||
* guest auth -> scope enforcement -> quota, plus the reserved-subpath guard
|
||||
* that keeps management routes (abort/active/...) on the JWT-only parent router.
|
||||
*/
|
||||
const express = require('express');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const request = require('supertest');
|
||||
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
logger: { error: jest.fn(), warn: jest.fn(), debug: jest.fn(), info: jest.fn() },
|
||||
}));
|
||||
|
||||
jest.mock('@hanzochat/api', () => ({
|
||||
isEnabled: (value) => value === 'true' || value === true,
|
||||
limiterCache: () => undefined,
|
||||
}));
|
||||
|
||||
jest.mock('librechat-data-provider', () => ({
|
||||
EModelEndpoint: { custom: 'custom', agents: 'agents' },
|
||||
}));
|
||||
|
||||
jest.mock('~/server/utils', () => ({
|
||||
removePorts: (req) => req.headers['x-test-ip'] || req.ip,
|
||||
}));
|
||||
|
||||
jest.mock('../requireJwtAuth', () =>
|
||||
jest.fn((req, res, next) => {
|
||||
req.user = { id: 'real-user', role: 'USER' };
|
||||
next();
|
||||
}),
|
||||
);
|
||||
|
||||
const requireGuestOrJwtAuth = require('../requireGuestOrJwtAuth');
|
||||
const enforceGuestScope = require('../enforceGuestScope');
|
||||
const { guestMessageLimiter } = require('../limiters/guestMessageLimiter');
|
||||
|
||||
const JWT_SECRET = 'test-guest-secret';
|
||||
|
||||
const buildRouter = () => {
|
||||
const router = express.Router();
|
||||
const RESERVED = new Set(['abort', 'active']);
|
||||
|
||||
const chatRouter = express.Router();
|
||||
chatRouter.use((req, res, next) => {
|
||||
const subpath = req.path.split('/').filter(Boolean)[0];
|
||||
if (RESERVED.has(subpath)) {
|
||||
return next('router');
|
||||
}
|
||||
return next();
|
||||
});
|
||||
chatRouter.use(requireGuestOrJwtAuth);
|
||||
chatRouter.use(enforceGuestScope);
|
||||
chatRouter.use(guestMessageLimiter);
|
||||
chatRouter.post('/', (req, res) =>
|
||||
res
|
||||
.status(200)
|
||||
.json({ endpoint: req.body.endpoint, model: req.body.model, role: req.user.role }),
|
||||
);
|
||||
router.use('/chat', chatRouter);
|
||||
|
||||
// JWT-only management route on the parent (after the guest router)
|
||||
router.post('/chat/abort', require('../requireJwtAuth'), (req, res) =>
|
||||
res.status(200).json({ aborted: true, role: req.user.role }),
|
||||
);
|
||||
|
||||
return router;
|
||||
};
|
||||
|
||||
const buildApp = () => {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/agents', buildRouter());
|
||||
return app;
|
||||
};
|
||||
|
||||
const guestToken = () => jwt.sign({ id: 'guest_1', guest: true, role: 'GUEST' }, JWT_SECRET);
|
||||
|
||||
describe('guest chat middleware chain', () => {
|
||||
beforeEach(() => {
|
||||
process.env.JWT_SECRET = JWT_SECRET;
|
||||
process.env.ALLOW_GUEST_CHAT = 'true';
|
||||
process.env.GUEST_MESSAGE_MAX = '2';
|
||||
process.env.GUEST_ENDPOINT = 'Hanzo';
|
||||
process.env.GUEST_MODEL = 'zen3-nano';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.ALLOW_GUEST_CHAT;
|
||||
delete process.env.GUEST_MESSAGE_MAX;
|
||||
delete process.env.GUEST_ENDPOINT;
|
||||
delete process.env.GUEST_MODEL;
|
||||
});
|
||||
|
||||
it('lets a guest reach the completion route, pinned to the free endpoint/model', async () => {
|
||||
const res = await request(buildApp())
|
||||
.post('/api/agents/chat')
|
||||
.set('Authorization', `Bearer ${guestToken()}`)
|
||||
.set('x-test-ip', '10.1.0.1')
|
||||
.send({ endpoint: 'Hanzo', model: 'zen3-nano', text: 'hi' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ endpoint: 'Hanzo', model: 'zen3-nano', role: 'GUEST' });
|
||||
});
|
||||
|
||||
it('returns 402 GUEST_LIMIT after the quota is exhausted', async () => {
|
||||
const app = buildApp();
|
||||
const ip = '10.1.0.2';
|
||||
const send = () =>
|
||||
request(app)
|
||||
.post('/api/agents/chat')
|
||||
.set('Authorization', `Bearer ${guestToken()}`)
|
||||
.set('x-test-ip', ip)
|
||||
.send({ endpoint: 'Hanzo', model: 'zen3-nano', text: 'hi' });
|
||||
|
||||
expect((await send()).status).toBe(200);
|
||||
expect((await send()).status).toBe(200);
|
||||
const blocked = await send();
|
||||
expect(blocked.status).toBe(402);
|
||||
expect(blocked.body.type).toBe('GUEST_LIMIT');
|
||||
});
|
||||
|
||||
it('routes reserved /chat/abort to the JWT-only handler, not the guest router', async () => {
|
||||
const res = await request(buildApp())
|
||||
.post('/api/agents/chat/abort')
|
||||
.set('Authorization', `Bearer ${guestToken()}`)
|
||||
.set('x-test-ip', '10.1.0.3')
|
||||
.send({ streamId: 'x' });
|
||||
// requireJwtAuth mock forces a real USER; guest never reaches abort.
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ aborted: true, role: 'USER' });
|
||||
});
|
||||
});
|
||||
@@ -1,86 +0,0 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
logger: { error: jest.fn(), warn: jest.fn(), debug: jest.fn(), info: jest.fn() },
|
||||
}));
|
||||
|
||||
jest.mock('../requireJwtAuth', () => jest.fn((req, res, next) => next('jwt-fallback')));
|
||||
|
||||
const requireJwtAuth = require('../requireJwtAuth');
|
||||
const requireGuestOrJwtAuth = require('../requireGuestOrJwtAuth');
|
||||
|
||||
const JWT_SECRET = 'test-guest-secret';
|
||||
|
||||
const mockRes = () => ({ status: jest.fn().mockReturnThis(), json: jest.fn().mockReturnThis() });
|
||||
|
||||
const guestToken = (claims = {}) =>
|
||||
jwt.sign({ id: 'guest_abc', guest: true, role: 'GUEST', ...claims }, JWT_SECRET);
|
||||
|
||||
describe('requireGuestOrJwtAuth', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
process.env.JWT_SECRET = JWT_SECRET;
|
||||
process.env.ALLOW_GUEST_CHAT = 'true';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.ALLOW_GUEST_CHAT;
|
||||
});
|
||||
|
||||
it('delegates to requireJwtAuth when guest chat is disabled', () => {
|
||||
process.env.ALLOW_GUEST_CHAT = 'false';
|
||||
const req = { headers: { authorization: `Bearer ${guestToken()}` } };
|
||||
const next = jest.fn();
|
||||
requireGuestOrJwtAuth(req, mockRes(), next);
|
||||
expect(requireJwtAuth).toHaveBeenCalledTimes(1);
|
||||
expect(req.user).toBeUndefined();
|
||||
});
|
||||
|
||||
it('authenticates a valid guest token as an ephemeral GUEST principal', () => {
|
||||
const req = { headers: { authorization: `Bearer ${guestToken()}` } };
|
||||
const next = jest.fn();
|
||||
requireGuestOrJwtAuth(req, mockRes(), next);
|
||||
expect(requireJwtAuth).not.toHaveBeenCalled();
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
expect(req.user).toEqual({ id: 'guest_abc', role: 'GUEST', name: 'Guest', guest: true });
|
||||
});
|
||||
|
||||
it('delegates to requireJwtAuth when no bearer token is present', () => {
|
||||
const req = { headers: {} };
|
||||
requireGuestOrJwtAuth(req, mockRes(), jest.fn());
|
||||
expect(requireJwtAuth).toHaveBeenCalledTimes(1);
|
||||
expect(req.user).toBeUndefined();
|
||||
});
|
||||
|
||||
it('delegates to requireJwtAuth for a non-guest (regular) JWT', () => {
|
||||
const userToken = jwt.sign({ id: 'real-user' }, JWT_SECRET);
|
||||
const req = { headers: { authorization: `Bearer ${userToken}` } };
|
||||
requireGuestOrJwtAuth(req, mockRes(), jest.fn());
|
||||
expect(requireJwtAuth).toHaveBeenCalledTimes(1);
|
||||
expect(req.user).toBeUndefined();
|
||||
});
|
||||
|
||||
it('delegates to requireJwtAuth for a token signed with the wrong secret (fail closed)', () => {
|
||||
const forged = jwt.sign({ id: 'guest_x', guest: true }, 'wrong-secret');
|
||||
const req = { headers: { authorization: `Bearer ${forged}` } };
|
||||
requireGuestOrJwtAuth(req, mockRes(), jest.fn());
|
||||
expect(requireJwtAuth).toHaveBeenCalledTimes(1);
|
||||
expect(req.user).toBeUndefined();
|
||||
});
|
||||
|
||||
it('delegates to requireJwtAuth when guest claim is missing', () => {
|
||||
const token = jwt.sign({ id: 'guest_x' }, JWT_SECRET);
|
||||
const req = { headers: { authorization: `Bearer ${token}` } };
|
||||
requireGuestOrJwtAuth(req, mockRes(), jest.fn());
|
||||
expect(requireJwtAuth).toHaveBeenCalledTimes(1);
|
||||
expect(req.user).toBeUndefined();
|
||||
});
|
||||
|
||||
it('delegates to requireJwtAuth when guest token lacks an id', () => {
|
||||
const token = jwt.sign({ guest: true }, JWT_SECRET);
|
||||
const req = { headers: { authorization: `Bearer ${token}` } };
|
||||
requireGuestOrJwtAuth(req, mockRes(), jest.fn());
|
||||
expect(requireJwtAuth).toHaveBeenCalledTimes(1);
|
||||
expect(req.user).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,81 +0,0 @@
|
||||
/**
|
||||
* Regression: a logged-in user in "My Agents" with no agent created/selected
|
||||
* could type "hi" and get `400 {message:'agent_id is required in request body'}`.
|
||||
*
|
||||
* A missing agent_id on the agents endpoint is an ad-hoc (ephemeral) chat, not an
|
||||
* error. This hermetic test locks the access-middleware fix point (no MongoDB /
|
||||
* winston): canAccessAgentFromBody skips the per-agent ACL and calls next().
|
||||
* The downstream build-options fix point is covered by
|
||||
* ../../services/Endpoints/agents/build.spec.js.
|
||||
*/
|
||||
|
||||
// Mock externals so neither winston (data-schemas) nor MongoDB (~/models/Agent) load.
|
||||
jest.mock('@librechat/data-schemas', () => ({ logger: { error: jest.fn(), debug: jest.fn() } }));
|
||||
|
||||
jest.mock('librechat-data-provider', () => ({
|
||||
Constants: { EPHEMERAL_AGENT_ID: 'ephemeral' },
|
||||
ResourceType: { AGENT: 'agent' },
|
||||
// Real predicate semantics (mirrors packages/data-provider/src):
|
||||
isAgentsEndpoint: (endpoint) => endpoint === 'agents',
|
||||
isEphemeralAgentId: (agentId) => !agentId?.startsWith?.('agent_'),
|
||||
}));
|
||||
|
||||
const mockCanAccessResource = jest.fn(() => (req, res, next) => {
|
||||
req.__aclChecked = true;
|
||||
return next();
|
||||
});
|
||||
jest.mock('./canAccessResource', () => ({ canAccessResource: mockCanAccessResource }));
|
||||
|
||||
jest.mock('~/models/Agent', () => ({ getAgent: jest.fn() }));
|
||||
|
||||
const { canAccessAgentFromBody } = require('./canAccessAgentFromBody');
|
||||
|
||||
const makeReqRes = (body) => {
|
||||
const req = { body, params: {} };
|
||||
const res = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
json: jest.fn().mockReturnThis(),
|
||||
};
|
||||
const next = jest.fn();
|
||||
return { req, res, next };
|
||||
};
|
||||
|
||||
describe('agent_id missing → ephemeral fallback (no 400)', () => {
|
||||
const mw = canAccessAgentFromBody({ requiredPermission: 1 });
|
||||
|
||||
test('agents endpoint with NO agent_id proceeds without a 400', async () => {
|
||||
const { req, res, next } = makeReqRes({ endpoint: 'agents', text: 'hi' });
|
||||
await mw(req, res, next);
|
||||
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
expect(mockCanAccessResource).not.toHaveBeenCalled(); // no per-agent ACL for ephemeral
|
||||
});
|
||||
|
||||
test('non-agents endpoint with no agent_id proceeds (ephemeral)', async () => {
|
||||
const { req, res, next } = makeReqRes({ endpoint: 'openAI', text: 'hi' });
|
||||
await mw(req, res, next);
|
||||
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('explicit ephemeral id proceeds', async () => {
|
||||
const { req, res, next } = makeReqRes({ endpoint: 'agents', agent_id: 'ephemeral_primary' });
|
||||
await mw(req, res, next);
|
||||
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('a real agent_id still runs the per-agent ACL check', async () => {
|
||||
const { req, res, next } = makeReqRes({ endpoint: 'agents', agent_id: 'agent_abc123' });
|
||||
await mw(req, res, next);
|
||||
|
||||
// The middleware builds the ACL check with the real agent id as resourceIdParam,
|
||||
// then runs it (which here calls next via the mock).
|
||||
expect(mockCanAccessResource).toHaveBeenCalledTimes(1);
|
||||
expect(mockCanAccessResource.mock.calls[0][0]).toMatchObject({ resourceType: 'agent' });
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -60,11 +60,15 @@ const canAccessAgentFromBody = (options) => {
|
||||
agentId = Constants.EPHEMERAL_AGENT_ID;
|
||||
}
|
||||
|
||||
// A missing agent_id on the agents endpoint is an ad-hoc (ephemeral) chat,
|
||||
// not an error: "type a message and get a reply" must work before the user
|
||||
// has created or selected an agent. Ephemeral agents carry no per-agent ACL,
|
||||
// so skip the permission check and fall through to plain chat. A missing id
|
||||
// is ephemeral by the canonical predicate (isEphemeralAgentId(undefined) === true).
|
||||
if (!agentId) {
|
||||
return res.status(400).json({
|
||||
error: 'Bad Request',
|
||||
message: 'agent_id is required in request body',
|
||||
});
|
||||
}
|
||||
|
||||
// Skip permission checks for ephemeral agents
|
||||
// Real agent IDs always start with "agent_", so anything else is ephemeral
|
||||
if (isEphemeralAgentId(agentId)) {
|
||||
return next();
|
||||
}
|
||||
|
||||
@@ -104,15 +104,13 @@ describe('canAccessAgentFromBody middleware', () => {
|
||||
});
|
||||
|
||||
describe('primary agent checks', () => {
|
||||
test('proceeds (ephemeral fallback) when agent_id is missing on agents endpoint', async () => {
|
||||
// No agent created/selected yet → ad-hoc chat. "Type hi and get a reply"
|
||||
// must work out of the box; a missing id is ephemeral, not a 400.
|
||||
test('returns 400 when agent_id is missing on agents endpoint', async () => {
|
||||
req.body.agent_id = undefined;
|
||||
const middleware = canAccessAgentFromBody({ requiredPermission: 1 });
|
||||
await middleware(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
});
|
||||
|
||||
test('proceeds for ephemeral primary agent without addedConvo', async () => {
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { EModelEndpoint } = require('librechat-data-provider');
|
||||
const { getGuestConfig } = require('~/server/services/guestConfig');
|
||||
|
||||
/**
|
||||
* Server-side capability scope for guest principals.
|
||||
*
|
||||
* Runs after guest authentication and before `buildEndpointOption`. For guest
|
||||
* requests it pins the endpoint and model to the configured free Zen endpoint and
|
||||
* strips every capability a guest must not reach (paid models, agents, tools,
|
||||
* files, model specs, presets). The client is never trusted: any guest request
|
||||
* that names a different endpoint/model is rejected rather than silently rewritten.
|
||||
*
|
||||
* Non-guest requests pass through untouched.
|
||||
*
|
||||
* @param {ServerRequest} req
|
||||
* @param {ServerResponse} res
|
||||
* @param {import('express').NextFunction} next
|
||||
*/
|
||||
const enforceGuestScope = (req, res, next) => {
|
||||
if (req.user?.guest !== true) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const config = getGuestConfig();
|
||||
if (!config.enabled) {
|
||||
return res.status(404).json({ message: 'Guest chat is not enabled' });
|
||||
}
|
||||
|
||||
const body = req.body || {};
|
||||
const { endpoint, model } = body;
|
||||
|
||||
if (endpoint != null && endpoint !== config.endpoint) {
|
||||
logger.warn(`[enforceGuestScope] Guest ${req.user.id} rejected endpoint: ${endpoint}`);
|
||||
return res.status(403).json({ message: 'Guests may only use the free preview model' });
|
||||
}
|
||||
|
||||
if (model != null && model !== config.model) {
|
||||
logger.warn(`[enforceGuestScope] Guest ${req.user.id} rejected model: ${model}`);
|
||||
return res.status(403).json({ message: 'Guests may only use the free preview model' });
|
||||
}
|
||||
|
||||
body.endpoint = config.endpoint;
|
||||
body.endpointType = EModelEndpoint.custom;
|
||||
body.model = config.model;
|
||||
|
||||
delete body.agent_id;
|
||||
delete body.spec;
|
||||
delete body.preset;
|
||||
delete body.files;
|
||||
delete body.tools;
|
||||
delete body.tool_resources;
|
||||
delete body.resendFiles;
|
||||
delete body.promptPrefix;
|
||||
delete body.web_search;
|
||||
|
||||
req.body = body;
|
||||
return next();
|
||||
};
|
||||
|
||||
module.exports = enforceGuestScope;
|
||||
@@ -10,8 +10,6 @@ const requireLdapAuth = require('./requireLdapAuth');
|
||||
const abortMiddleware = require('./abortMiddleware');
|
||||
const checkInviteUser = require('./checkInviteUser');
|
||||
const requireJwtAuth = require('./requireJwtAuth');
|
||||
const requireGuestOrJwtAuth = require('./requireGuestOrJwtAuth');
|
||||
const enforceGuestScope = require('./enforceGuestScope');
|
||||
const configMiddleware = require('./config/app');
|
||||
const validateModel = require('./validateModel');
|
||||
const moderateText = require('./moderateText');
|
||||
@@ -38,8 +36,6 @@ module.exports = {
|
||||
moderateText,
|
||||
validateModel,
|
||||
requireJwtAuth,
|
||||
requireGuestOrJwtAuth,
|
||||
enforceGuestScope,
|
||||
checkInviteUser,
|
||||
requireLdapAuth,
|
||||
requireLocalAuth,
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const { limiterCache } = require('@hanzochat/api');
|
||||
const { ViolationTypes } = require('librechat-data-provider');
|
||||
const logViolation = require('~/cache/logViolation');
|
||||
|
||||
/**
|
||||
* Per-user rate limiter for the cloud-agents proxy (`/api/agents/cloud/*`). A run
|
||||
* is a real, billable cloud chat completion that holds an upstream socket for up
|
||||
* to 30s, yet it bypasses the message limiters that guard the sibling completion
|
||||
* path. This caps a signed-in user to CLOUD_AGENT_USER_MAX requests per window so
|
||||
* a loop cannot degrade the shared backend or run up denial-of-wallet cost.
|
||||
* Keyed by user id (the route is JWT-only; guests never reach it).
|
||||
*/
|
||||
const {
|
||||
CLOUD_AGENT_USER_MAX = 30,
|
||||
CLOUD_AGENT_WINDOW = 1,
|
||||
CLOUD_AGENT_VIOLATION_SCORE: score,
|
||||
} = process.env;
|
||||
|
||||
const windowMs = CLOUD_AGENT_WINDOW * 60 * 1000;
|
||||
const max = Number(CLOUD_AGENT_USER_MAX);
|
||||
|
||||
const handler = async (req, res) => {
|
||||
const type = ViolationTypes.TOOL_CALL_LIMIT;
|
||||
const errorMessage = {
|
||||
type,
|
||||
max,
|
||||
limiter: 'user',
|
||||
windowInMinutes: windowMs / 60000,
|
||||
};
|
||||
|
||||
await logViolation(req, res, type, errorMessage, score);
|
||||
res.status(429).json({ message: 'Too many cloud agent requests. Try again later.' });
|
||||
};
|
||||
|
||||
const cloudAgentLimiter = rateLimit({
|
||||
windowMs,
|
||||
max,
|
||||
handler,
|
||||
keyGenerator: (req) => req.user?.id,
|
||||
store: limiterCache('cloud_agent_limiter'),
|
||||
});
|
||||
|
||||
module.exports = cloudAgentLimiter;
|
||||
@@ -1,28 +0,0 @@
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const { limiterCache } = require('@hanzochat/api');
|
||||
const { removePorts } = require('~/server/utils');
|
||||
|
||||
const { GUEST_TOKEN_WINDOW = 60, GUEST_TOKEN_MAX = 20 } = process.env;
|
||||
|
||||
const windowMs = GUEST_TOKEN_WINDOW * 60 * 1000;
|
||||
const max = GUEST_TOKEN_MAX;
|
||||
const windowInMinutes = windowMs / 60000;
|
||||
|
||||
const handler = (req, res) => {
|
||||
return res.status(429).json({
|
||||
message: `Too many guest sessions, please try again after ${windowInMinutes} minutes.`,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-IP rate limiter for guest token issuance.
|
||||
*/
|
||||
const guestTokenLimiter = rateLimit({
|
||||
windowMs,
|
||||
max,
|
||||
handler,
|
||||
keyGenerator: removePorts,
|
||||
store: limiterCache('guest_token_limiter'),
|
||||
});
|
||||
|
||||
module.exports = { guestTokenLimiter };
|
||||
@@ -1,32 +0,0 @@
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const { limiterCache } = require('@hanzochat/api');
|
||||
const { removePorts } = require('~/server/utils');
|
||||
const { getGuestConfig } = require('~/server/services/guestConfig');
|
||||
|
||||
const GUEST_LIMIT_WINDOW_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
const handler = (req, res) => {
|
||||
return res.status(402).json({
|
||||
type: 'GUEST_LIMIT',
|
||||
message: 'Guest message limit reached. Log in to continue.',
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-IP quota limiter for anonymous guest chat completions.
|
||||
*
|
||||
* Reuses the shared Redis-backed `limiterCache` infrastructure (same store as the
|
||||
* message limiters). It only counts requests made by an authenticated guest
|
||||
* principal; logged-in users skip the counter entirely. On exhaustion it returns
|
||||
* a `402 { type: 'GUEST_LIMIT' }` signal the client maps to the login gate.
|
||||
*/
|
||||
const guestMessageLimiter = rateLimit({
|
||||
windowMs: GUEST_LIMIT_WINDOW_MS,
|
||||
max: () => getGuestConfig().messageMax,
|
||||
handler,
|
||||
keyGenerator: removePorts,
|
||||
skip: (req) => req.user?.guest !== true,
|
||||
store: limiterCache('guest_message_limiter'),
|
||||
});
|
||||
|
||||
module.exports = { guestMessageLimiter };
|
||||
@@ -1,76 +0,0 @@
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
jest.mock('@hanzochat/api', () => ({
|
||||
limiterCache: () => undefined,
|
||||
}));
|
||||
|
||||
jest.mock('~/server/utils', () => ({
|
||||
removePorts: (req) => req.headers['x-test-ip'] || req.ip,
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/guestConfig', () => ({
|
||||
getGuestConfig: jest.fn(),
|
||||
}));
|
||||
|
||||
const { getGuestConfig } = require('~/server/services/guestConfig');
|
||||
const { guestMessageLimiter } = require('./guestMessageLimiter');
|
||||
|
||||
const buildApp = (user) => {
|
||||
const app = express();
|
||||
app.use((req, _res, next) => {
|
||||
req.user = user;
|
||||
next();
|
||||
});
|
||||
app.use(guestMessageLimiter);
|
||||
app.post('/chat', (_req, res) => res.status(200).json({ ok: true }));
|
||||
return app;
|
||||
};
|
||||
|
||||
describe('guestMessageLimiter', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
getGuestConfig.mockReturnValue({
|
||||
enabled: true,
|
||||
messageMax: 3,
|
||||
endpoint: 'Hanzo',
|
||||
model: 'zen3-nano',
|
||||
});
|
||||
});
|
||||
|
||||
it('allows up to GUEST_MESSAGE_MAX guest messages then returns 402 GUEST_LIMIT', async () => {
|
||||
const app = buildApp({ id: 'g1', guest: true });
|
||||
const ip = '10.0.0.1';
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const res = await request(app).post('/chat').set('x-test-ip', ip);
|
||||
expect(res.status).toBe(200);
|
||||
}
|
||||
|
||||
const blocked = await request(app).post('/chat').set('x-test-ip', ip);
|
||||
expect(blocked.status).toBe(402);
|
||||
expect(blocked.body.type).toBe('GUEST_LIMIT');
|
||||
});
|
||||
|
||||
it('counts quota per IP independently', async () => {
|
||||
const app = buildApp({ id: 'g1', guest: true });
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await request(app).post('/chat').set('x-test-ip', '10.0.0.2');
|
||||
}
|
||||
const blocked = await request(app).post('/chat').set('x-test-ip', '10.0.0.2');
|
||||
expect(blocked.status).toBe(402);
|
||||
|
||||
const fresh = await request(app).post('/chat').set('x-test-ip', '10.0.0.3');
|
||||
expect(fresh.status).toBe(200);
|
||||
});
|
||||
|
||||
it('never counts or blocks authenticated (non-guest) users', async () => {
|
||||
const app = buildApp({ id: 'real-user', role: 'USER' });
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const res = await request(app).post('/chat').set('x-test-ip', '10.0.0.4');
|
||||
expect(res.status).toBe(200);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -2,14 +2,11 @@ const createTTSLimiters = require('./ttsLimiters');
|
||||
const createSTTLimiters = require('./sttLimiters');
|
||||
|
||||
const loginLimiter = require('./loginLimiter');
|
||||
const { guestTokenLimiter } = require('./guestLimiters');
|
||||
const { guestMessageLimiter } = require('./guestMessageLimiter');
|
||||
const importLimiters = require('./importLimiters');
|
||||
const uploadLimiters = require('./uploadLimiters');
|
||||
const forkLimiters = require('./forkLimiters');
|
||||
const registerLimiter = require('./registerLimiter');
|
||||
const toolCallLimiter = require('./toolCallLimiter');
|
||||
const cloudAgentLimiter = require('./cloudAgentLimiter');
|
||||
const messageLimiters = require('./messageLimiters');
|
||||
const verifyEmailLimiter = require('./verifyEmailLimiter');
|
||||
const resetPasswordLimiter = require('./resetPasswordLimiter');
|
||||
@@ -20,11 +17,8 @@ module.exports = {
|
||||
...messageLimiters,
|
||||
...forkLimiters,
|
||||
loginLimiter,
|
||||
guestTokenLimiter,
|
||||
guestMessageLimiter,
|
||||
registerLimiter,
|
||||
toolCallLimiter,
|
||||
cloudAgentLimiter,
|
||||
createTTSLimiters,
|
||||
createSTTLimiters,
|
||||
verifyEmailLimiter,
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const requireJwtAuth = require('./requireJwtAuth');
|
||||
const { getGuestConfig, buildGuestPrincipal } = require('~/server/services/guestConfig');
|
||||
|
||||
/**
|
||||
* Extracts a bearer token from the Authorization header.
|
||||
* @param {ServerRequest} req
|
||||
* @returns {string|null}
|
||||
*/
|
||||
const getBearerToken = (req) => {
|
||||
const header = req.headers?.authorization;
|
||||
if (!header || !header.startsWith('Bearer ')) {
|
||||
return null;
|
||||
}
|
||||
return header.slice('Bearer '.length).trim();
|
||||
};
|
||||
|
||||
/**
|
||||
* Guest-aware authentication, applied ONLY to chat-completion routes.
|
||||
*
|
||||
* When `ALLOW_GUEST_CHAT` is enabled and the bearer token is a valid guest token
|
||||
* (`guest: true`, signed with `JWT_SECRET`), an ephemeral guest principal is set
|
||||
* on `req.user` and the request proceeds. Otherwise the request falls through to
|
||||
* the standard `requireJwtAuth`, which rejects guest tokens everywhere else
|
||||
* (the `jwt` strategy requires a matching DB user). Fail closed by construction.
|
||||
*
|
||||
* @param {ServerRequest} req
|
||||
* @param {ServerResponse} res
|
||||
* @param {import('express').NextFunction} next
|
||||
*/
|
||||
const requireGuestOrJwtAuth = (req, res, next) => {
|
||||
const config = getGuestConfig();
|
||||
if (!config.enabled) {
|
||||
return requireJwtAuth(req, res, next);
|
||||
}
|
||||
|
||||
const token = getBearerToken(req);
|
||||
if (!token) {
|
||||
return requireJwtAuth(req, res, next);
|
||||
}
|
||||
|
||||
let payload;
|
||||
try {
|
||||
payload = jwt.verify(token, process.env.JWT_SECRET);
|
||||
} catch (_err) {
|
||||
return requireJwtAuth(req, res, next);
|
||||
}
|
||||
|
||||
if (payload?.guest !== true || !payload.id) {
|
||||
return requireJwtAuth(req, res, next);
|
||||
}
|
||||
|
||||
req.user = buildGuestPrincipal(payload.id);
|
||||
logger.debug(`[requireGuestOrJwtAuth] Guest principal authenticated: ${payload.id}`);
|
||||
return next();
|
||||
};
|
||||
|
||||
module.exports = requireGuestOrJwtAuth;
|
||||
@@ -1,136 +0,0 @@
|
||||
/**
|
||||
* End-to-end auth chain for guest bootstrap: real passport `jwt` strategy +
|
||||
* real `requireGuestOrJwtAuth` / `requireJwtAuth` + real controllers, driven by
|
||||
* a real signed guest JWT.
|
||||
*
|
||||
* Proves:
|
||||
* - a guest token on a JWT-only route returns a clean 401 (NOT 500/CastError),
|
||||
* - /api/endpoints, /api/user, /api/convos return guest-scoped data,
|
||||
* - a non-bootstrap protected route still 401s for a guest.
|
||||
*/
|
||||
|
||||
const jwt = require('jsonwebtoken');
|
||||
const express = require('express');
|
||||
const passport = require('passport');
|
||||
const request = require('supertest');
|
||||
|
||||
jest.mock('@hanzochat/api', () => ({
|
||||
isEnabled: (value) => value === 'true' || value === true,
|
||||
}));
|
||||
jest.mock('librechat-data-provider', () => ({
|
||||
EModelEndpoint: { custom: 'custom' },
|
||||
SystemRoles: { USER: 'USER' },
|
||||
}));
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
logger: { error: jest.fn(), warn: jest.fn(), debug: jest.fn(), info: jest.fn() },
|
||||
}));
|
||||
|
||||
// getUserById would throw a Mongoose CastError on a guest id; assert it is never
|
||||
// reached for a guest, and returns null (→ clean 401) for a real-but-missing id.
|
||||
jest.mock('~/models', () => ({
|
||||
getUserById: jest.fn(async (id) => {
|
||||
if (String(id).startsWith('guest_')) {
|
||||
throw new Error('CastError: Cast to ObjectId failed for value "' + id + '"');
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
updateUser: jest.fn(),
|
||||
}));
|
||||
|
||||
const { getUserById } = require('~/models');
|
||||
const jwtLogin = require('~/strategies/jwtStrategy');
|
||||
const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
|
||||
const requireGuestOrJwtAuth = require('~/server/middleware/requireGuestOrJwtAuth');
|
||||
const { buildGuestEndpointsConfig, buildGuestUser } = require('~/server/services/guestConfig');
|
||||
|
||||
const JWT_SECRET = 'integration-guest-secret';
|
||||
|
||||
const buildApp = () => {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(passport.initialize());
|
||||
passport.use(jwtLogin());
|
||||
|
||||
// Bootstrap (guest-aware) routes.
|
||||
app.get('/api/endpoints', requireGuestOrJwtAuth, (req, res) => {
|
||||
if (req.user?.guest === true) {
|
||||
return res.send(JSON.stringify(buildGuestEndpointsConfig()));
|
||||
}
|
||||
return res.send(JSON.stringify({ openAI: {}, Hanzo: {} }));
|
||||
});
|
||||
app.get('/api/user', requireGuestOrJwtAuth, (req, res) => {
|
||||
if (req.user?.guest === true) {
|
||||
return res.status(200).send(buildGuestUser(req.user));
|
||||
}
|
||||
return res.status(200).send(req.user);
|
||||
});
|
||||
app.get('/api/convos', requireGuestOrJwtAuth, (req, res) => {
|
||||
if (req.user?.guest === true) {
|
||||
return res.status(200).json({ conversations: [], nextCursor: null });
|
||||
}
|
||||
return res.status(200).json({ conversations: ['real'] });
|
||||
});
|
||||
|
||||
// Non-bootstrap protected route (JWT-only): must reject guests.
|
||||
app.get('/api/prompts', requireJwtAuth, (req, res) => res.status(200).json({ ok: true }));
|
||||
|
||||
return app;
|
||||
};
|
||||
|
||||
describe('guest bootstrap auth chain (integration)', () => {
|
||||
let app;
|
||||
const guestToken = jwt.sign({ id: 'guest_abc-123', guest: true, role: 'GUEST' }, JWT_SECRET);
|
||||
|
||||
beforeAll(() => {
|
||||
process.env.JWT_SECRET = JWT_SECRET;
|
||||
process.env.ALLOW_GUEST_CHAT = 'true';
|
||||
process.env.GUEST_ENDPOINT = 'Hanzo';
|
||||
process.env.GUEST_MODEL = 'zen5-mini';
|
||||
app = buildApp();
|
||||
});
|
||||
|
||||
afterEach(() => jest.clearAllMocks());
|
||||
|
||||
it('GET /api/endpoints → 200 with ONLY the guest endpoint', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/endpoints')
|
||||
.set('Authorization', `Bearer ${guestToken}`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = JSON.parse(res.text);
|
||||
expect(Object.keys(body)).toEqual(['Hanzo']);
|
||||
expect(getUserById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('GET /api/user → 200 ephemeral guest principal (no email, no DB lookup)', async () => {
|
||||
const res = await request(app).get('/api/user').set('Authorization', `Bearer ${guestToken}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({ id: 'guest_abc-123', role: 'GUEST', guest: true });
|
||||
expect(res.body).not.toHaveProperty('email');
|
||||
expect(getUserById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('GET /api/convos → 200 empty list for a guest', async () => {
|
||||
const res = await request(app).get('/api/convos').set('Authorization', `Bearer ${guestToken}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ conversations: [], nextCursor: null });
|
||||
});
|
||||
|
||||
it('GET /api/prompts (JWT-only) → clean 401 for a guest, NEVER 500/CastError', async () => {
|
||||
const res = await request(app).get('/api/prompts').set('Authorization', `Bearer ${guestToken}`);
|
||||
expect(res.status).toBe(401);
|
||||
// The guest id must never reach getUserById (that is what caused the CastError → 500).
|
||||
expect(getUserById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('GET /api/prompts → clean 401 for a real-but-missing user (no 500)', async () => {
|
||||
const realToken = jwt.sign({ id: '64b2f0c0c0c0c0c0c0c0c0c0' }, JWT_SECRET);
|
||||
const res = await request(app).get('/api/prompts').set('Authorization', `Bearer ${realToken}`);
|
||||
expect(res.status).toBe(401);
|
||||
expect(getUserById).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('GET /api/prompts → 401 with no token (fail closed)', async () => {
|
||||
const res = await request(app).get('/api/prompts');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -1,230 +0,0 @@
|
||||
/**
|
||||
* Regression test for the OAuth login rate-limiter scoping fix.
|
||||
*
|
||||
* Background: `loginLimiter` (LOGIN_MAX per 5 min, keyed by IP) used to be
|
||||
* applied as blanket router middleware (`router.use(loginLimiter)`), which also
|
||||
* covered the OIDC/social callback routes. Behind a shared LB/CDN IP, with
|
||||
* OPENID_AUTO_REDIRECT firing `/oauth/openid` on every page load, the per-IP
|
||||
* budget was exhausted instantly and the IdP code-exchange callback got 429 -
|
||||
* aborting the token exchange and breaking login.
|
||||
*
|
||||
* The fix scopes `loginLimiter` to the human-initiated entry points (the GETs
|
||||
* that REDIRECT to the IdP) and leaves the machine-driven callback routes
|
||||
* (the IdP returning a one-time code) un-limited.
|
||||
*
|
||||
* This test mounts the REAL oauth router with the REAL `loginLimiter` and only
|
||||
* stubs the heavy leaf deps (passport, openid-client, controllers, config, db),
|
||||
* mirroring server/routes/__tests__/convos-duplicate-ratelimit.spec.js.
|
||||
*/
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
// passport.authenticate(...) -> a middleware that signals "reached the handler"
|
||||
// (in prod this would redirect to the IdP for initiation, or exchange the code
|
||||
// for callbacks). Returning 200 lets us detect whether the limiter intercepted
|
||||
// the request with 429 BEFORE control reached the auth handler.
|
||||
jest.mock('passport', () => ({
|
||||
authenticate: jest.fn(() => (req, res) => res.status(200).json({ reached: true })),
|
||||
}));
|
||||
|
||||
jest.mock('openid-client', () => ({
|
||||
randomState: jest.fn(() => 'test-state'),
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
logger: { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() },
|
||||
}));
|
||||
|
||||
jest.mock('librechat-data-provider', () => ({
|
||||
ErrorTypes: { AUTH_FAILED: 'AUTH_FAILED' },
|
||||
ViolationTypes: { LOGINS: 'logins' },
|
||||
}));
|
||||
|
||||
// `limiterCache: () => undefined` makes express-rate-limit fall back to its
|
||||
// built-in in-process MemoryStore (no Redis), same as the convos limiter test.
|
||||
jest.mock('@hanzochat/api', () => ({
|
||||
createSetBalanceConfig: jest.fn(() => (req, res, next) => next()),
|
||||
limiterCache: jest.fn(() => undefined),
|
||||
}));
|
||||
|
||||
// loginLimiter.js does `require('~/cache')` (the index, which also pulls
|
||||
// getLogStores) and `require('~/server/utils')` (index, which pulls
|
||||
// sendEmail/files/queue). Mock both indexes to the minimal real/inert surface
|
||||
// the limiter actually uses: a no-op violation logger and the dependency-free
|
||||
// `removePorts` IP key generator.
|
||||
jest.mock('~/cache', () => ({
|
||||
logViolation: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
jest.mock('~/server/utils', () => ({
|
||||
removePorts: jest.requireActual('~/server/utils/removePorts'),
|
||||
}));
|
||||
|
||||
// oauth.js imports { checkDomainAllowed, loginLimiter, logHeaders } from the
|
||||
// middleware index, whose full require graph is too heavy for a hermetic unit
|
||||
// test. Mock the index but expose the REAL `loginLimiter` (the unit under test)
|
||||
// via requireActual of the leaf file — mirroring how
|
||||
// convos-duplicate-ratelimit.spec.js pulls the real forkLimiters. The other two
|
||||
// are inert passthroughs (not under test here).
|
||||
jest.mock('~/server/middleware', () => {
|
||||
const loginLimiter = jest.requireActual('~/server/middleware/limiters/loginLimiter');
|
||||
return {
|
||||
loginLimiter,
|
||||
logHeaders: (req, res, next) => next(),
|
||||
checkDomainAllowed: (req, res, next) => next(),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('~/server/controllers/auth/oauth', () => ({
|
||||
createOAuthHandler: jest.fn(() => (req, res) => res.status(200).json({ handler: true })),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Config', () => ({
|
||||
getAppConfig: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/db/models', () => ({
|
||||
Balance: {},
|
||||
}));
|
||||
|
||||
const LOGIN_INITIATION = '/oauth/openid';
|
||||
const LOGIN_CALLBACK = '/oauth/openid/callback';
|
||||
|
||||
describe('OAuth router — loginLimiter scoping', () => {
|
||||
const savedEnv = {};
|
||||
|
||||
beforeAll(() => {
|
||||
savedEnv.LOGIN_MAX = process.env.LOGIN_MAX;
|
||||
savedEnv.LOGIN_WINDOW = process.env.LOGIN_WINDOW;
|
||||
savedEnv.DOMAIN_CLIENT = process.env.DOMAIN_CLIENT;
|
||||
savedEnv.DOMAIN_SERVER = process.env.DOMAIN_SERVER;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
for (const key of Object.keys(savedEnv)) {
|
||||
if (savedEnv[key] === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = savedEnv[key];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// The limiter reads LOGIN_MAX/LOGIN_WINDOW at module-load time, so build the
|
||||
// app inside isolateModules AFTER setting env to get a fresh limiter+store.
|
||||
const buildApp = () => {
|
||||
let oauthRouter;
|
||||
jest.isolateModules(() => {
|
||||
oauthRouter = require('../oauth');
|
||||
});
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/oauth', oauthRouter);
|
||||
return app;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
process.env.LOGIN_MAX = '1';
|
||||
process.env.LOGIN_WINDOW = '5';
|
||||
process.env.DOMAIN_CLIENT = 'http://localhost:3080';
|
||||
process.env.DOMAIN_SERVER = 'http://localhost:3080';
|
||||
});
|
||||
|
||||
describe('initiation route (redirect-to-IdP) IS rate-limited', () => {
|
||||
it('returns 429 once an initiation route exceeds LOGIN_MAX', async () => {
|
||||
const app = buildApp();
|
||||
const max = parseInt(process.env.LOGIN_MAX, 10);
|
||||
|
||||
for (let i = 0; i < max; i++) {
|
||||
const ok = await request(app).get(LOGIN_INITIATION);
|
||||
expect(ok.status).toBe(200);
|
||||
expect(ok.body.reached).toBe(true);
|
||||
}
|
||||
|
||||
const limited = await request(app).get(LOGIN_INITIATION);
|
||||
expect(limited.status).toBe(429);
|
||||
expect(limited.body.message).toMatch(/too many/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('callback route (IdP-returns-code) is NOT rate-limited', () => {
|
||||
it('never returns 429 even when hammered well past LOGIN_MAX', async () => {
|
||||
const app = buildApp();
|
||||
const hits = parseInt(process.env.LOGIN_MAX, 10) + 5;
|
||||
|
||||
for (let i = 0; i < hits; i++) {
|
||||
const res = await request(app).get(LOGIN_CALLBACK);
|
||||
// The callback may legitimately 200/redirect/fail auth — it must just
|
||||
// never be throttled with 429.
|
||||
expect(res.status).not.toBe(429);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not consume the initiation budget (callback hits never trip the limiter)', async () => {
|
||||
const app = buildApp();
|
||||
|
||||
// Hammer the callback far past the limit first.
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const res = await request(app).get(LOGIN_CALLBACK);
|
||||
expect(res.status).not.toBe(429);
|
||||
}
|
||||
|
||||
// The single allowed initiation request must still succeed: callbacks and
|
||||
// initiations do not share a (broken) blanket budget.
|
||||
const ok = await request(app).get(LOGIN_INITIATION);
|
||||
expect(ok.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('router wiring (defense-in-depth structural assertion)', () => {
|
||||
// Even independent of runtime behavior, assert the limiter is present in the
|
||||
// initiation route's middleware stack and absent from every callback stack.
|
||||
const limiterStackByPath = () => {
|
||||
let oauthRouter;
|
||||
let loginLimiter;
|
||||
jest.isolateModules(() => {
|
||||
oauthRouter = require('../oauth');
|
||||
// Same instance the router received (the mocked index re-exports the
|
||||
// real leaf limiter), so reference-equality in the stack is meaningful.
|
||||
({ loginLimiter } = require('~/server/middleware'));
|
||||
});
|
||||
const byPath = {};
|
||||
for (const layer of oauthRouter.stack) {
|
||||
if (!layer.route) continue; // skip router.use(logHeaders) etc.
|
||||
const handles = layer.route.stack.map((s) => s.handle);
|
||||
byPath[layer.route.path] = handles.includes(loginLimiter);
|
||||
}
|
||||
return byPath;
|
||||
};
|
||||
|
||||
it('applies loginLimiter to every initiation route and to NO callback route', () => {
|
||||
const limited = limiterStackByPath();
|
||||
|
||||
const initiationRoutes = [
|
||||
'/google',
|
||||
'/facebook',
|
||||
'/openid',
|
||||
'/github',
|
||||
'/discord',
|
||||
'/apple',
|
||||
'/saml',
|
||||
];
|
||||
const callbackRoutes = [
|
||||
'/google/callback',
|
||||
'/facebook/callback',
|
||||
'/openid/callback',
|
||||
'/github/callback',
|
||||
'/discord/callback',
|
||||
'/apple/callback',
|
||||
'/saml/callback',
|
||||
];
|
||||
|
||||
for (const path of initiationRoutes) {
|
||||
expect(limited[path]).toBe(true);
|
||||
}
|
||||
for (const path of callbackRoutes) {
|
||||
expect(limited[path]).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,266 +0,0 @@
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
logger: { debug: jest.fn(), error: jest.fn(), info: jest.fn(), warn: jest.fn() },
|
||||
}));
|
||||
|
||||
// The proxy keys the on-behalf-of decision off the VALIDATED principal
|
||||
// (`req.user.provider`), not a cookie. requireJwtAuth is exercised elsewhere;
|
||||
// here it simply installs the principal under test so the token-resolution +
|
||||
// honest-error logic is isolated. Set `mockPrincipal` per test (the `mock`
|
||||
// prefix is required for a jest.mock factory to reference it).
|
||||
let mockPrincipal;
|
||||
jest.mock('~/server/middleware', () => ({
|
||||
requireJwtAuth: (req, _res, next) => {
|
||||
req.user = mockPrincipal;
|
||||
next();
|
||||
},
|
||||
// Rate limiter is exercised in its own layer; here it is a pass-through so the
|
||||
// proxy logic (token resolution, honest errors, name guard) is under test.
|
||||
cloudAgentLimiter: (_req, _res, next) => next(),
|
||||
}));
|
||||
|
||||
const mockClient = {
|
||||
list: jest.fn(),
|
||||
get: jest.fn(),
|
||||
run: jest.fn(),
|
||||
};
|
||||
jest.mock('~/server/services/CloudAgentsClient', () => ({
|
||||
getCloudAgentsClient: jest.fn(() => mockClient),
|
||||
// Boundary name validation in the route uses the real grammar.
|
||||
AGENT_NAME_RE: /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/,
|
||||
}));
|
||||
|
||||
const jwt = require('jsonwebtoken');
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
const { getCloudAgentsClient } = require('~/server/services/CloudAgentsClient');
|
||||
const cloudRouter = require('../cloud');
|
||||
|
||||
const OPENID_SUB = 'sub-abc-123';
|
||||
const OPENID_USER = { id: 'u_openid', provider: 'openid', openidId: OPENID_SUB };
|
||||
const LOCAL_USER = { id: 'u_local', provider: 'local' };
|
||||
|
||||
/**
|
||||
* Mint a decodable id_token. The signature is irrelevant to the proxy — it
|
||||
* decode-only checks `exp` + `sub`; cloud performs the authoritative JWKS
|
||||
* validation. So a throwaway secret is exactly right here.
|
||||
*/
|
||||
function mintIdToken({ sub = OPENID_SUB, exp = Math.floor(Date.now() / 1000) + 3600 } = {}) {
|
||||
return jwt.sign({ sub, exp }, 'test-only-not-verified');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an app whose session carries (or omits) the OpenID tokens the proxy
|
||||
* forwards to cloud. The authenticated principal is `mockPrincipal` (default: an
|
||||
* OpenID user); `cookie` models the httpOnly no-session fallback.
|
||||
*/
|
||||
function buildApp(session, cookie) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
req.session = session;
|
||||
if (cookie != null && req.headers.cookie == null) {
|
||||
req.headers.cookie = cookie;
|
||||
}
|
||||
next();
|
||||
});
|
||||
app.use('/api/agents/cloud', cloudRouter);
|
||||
return app;
|
||||
}
|
||||
|
||||
const VALID_ID = mintIdToken();
|
||||
const withToken = { openidTokens: { idToken: VALID_ID, accessToken: 'ACC' } };
|
||||
|
||||
describe('cloud agents proxy route', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockPrincipal = OPENID_USER;
|
||||
getCloudAgentsClient.mockReturnValue(mockClient);
|
||||
});
|
||||
|
||||
describe('token handling (no leak, fail-secure)', () => {
|
||||
it('401s when the openid principal has no hanzo.id session token', async () => {
|
||||
const res = await request(buildApp({})).get('/api/agents/cloud');
|
||||
expect(res.status).toBe(401);
|
||||
expect(mockClient.list).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('forwards the session id_token (never the browser) to cloud', async () => {
|
||||
mockClient.list.mockResolvedValue({ agents: [{ name: 'researcher' }] });
|
||||
const res = await request(buildApp(withToken)).get('/api/agents/cloud');
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockClient.list).toHaveBeenCalledWith(VALID_ID);
|
||||
expect(res.body).toEqual({ agents: [{ name: 'researcher' }], enabled: true });
|
||||
});
|
||||
|
||||
it('falls back to a principal-bound access_token JWT when there is no id_token', async () => {
|
||||
mockClient.list.mockResolvedValue({ agents: [] });
|
||||
// hanzo.id issues JWT access tokens too; the fallback stays principal-bound.
|
||||
const accJwt = mintIdToken();
|
||||
const app = buildApp({ openidTokens: { accessToken: accJwt } });
|
||||
await request(app).get('/api/agents/cloud');
|
||||
expect(mockClient.list).toHaveBeenCalledWith(accJwt);
|
||||
});
|
||||
|
||||
it('401s (never forwards) an opaque access_token that cannot be principal-bound', async () => {
|
||||
// A non-JWT access_token has no `sub` to bind against — fail-secure, do not
|
||||
// forward. (This is the path a selective `openid_access_token` cookie
|
||||
// injection would take; the binding requirement closes it.)
|
||||
const app = buildApp({ openidTokens: { accessToken: 'OPAQUE_NO_BINDING' } });
|
||||
const res = await request(app).get('/api/agents/cloud');
|
||||
expect(res.status).toBe(401);
|
||||
expect(mockClient.list).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('401s (never forwards) an access_token JWT that names a different principal', async () => {
|
||||
const foreignAcc = mintIdToken({ sub: 'sub-someone-else' });
|
||||
const app = buildApp({ openidTokens: { accessToken: foreignAcc } });
|
||||
const res = await request(app).get('/api/agents/cloud');
|
||||
expect(res.status).toBe(401);
|
||||
expect(mockClient.list).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reads the httpOnly openid_id_token cookie when the session is empty', async () => {
|
||||
mockClient.list.mockResolvedValue({ agents: [] });
|
||||
const app = buildApp({}, `openid_id_token=${VALID_ID}`);
|
||||
await request(app).get('/api/agents/cloud');
|
||||
expect(mockClient.list).toHaveBeenCalledWith(VALID_ID);
|
||||
});
|
||||
});
|
||||
|
||||
describe('honest expiry + principal binding (never a fabricated session)', () => {
|
||||
it('honest 401 when the id_token is past its own exp (no forward)', async () => {
|
||||
const expired = mintIdToken({ exp: Math.floor(Date.now() / 1000) - 60 });
|
||||
const app = buildApp({ openidTokens: { idToken: expired } });
|
||||
const res = await request(app).get('/api/agents/cloud');
|
||||
expect(res.status).toBe(401);
|
||||
expect(mockClient.list).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('honest 401 when the id_token names a different principal (sub mismatch)', async () => {
|
||||
const foreign = mintIdToken({ sub: 'sub-someone-else' });
|
||||
const app = buildApp({ openidTokens: { idToken: foreign } });
|
||||
const res = await request(app).get('/api/agents/cloud');
|
||||
expect(res.status).toBe(401);
|
||||
expect(mockClient.list).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('honest 401 when the id_token is not a decodable JWT', async () => {
|
||||
const app = buildApp({ openidTokens: { idToken: 'not-a-jwt' } });
|
||||
const res = await request(app).get('/api/agents/cloud');
|
||||
expect(res.status).toBe(401);
|
||||
expect(mockClient.list).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('principal guard (no confused deputy)', () => {
|
||||
it('401s and forwards nothing for a LOCAL principal, even with a valid openid session', async () => {
|
||||
// A local-JWT user whose browser still holds a valid prior openid session.
|
||||
// Forwarding those tokens would run as the wrong principal — deny at the
|
||||
// identity layer (req.user.provider), independent of any cookie.
|
||||
mockPrincipal = LOCAL_USER;
|
||||
const res = await request(buildApp(withToken)).get('/api/agents/cloud');
|
||||
expect(res.status).toBe(401);
|
||||
expect(mockClient.list).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('401s for a principal that carries no provider', async () => {
|
||||
mockPrincipal = { id: 'u_unknown' };
|
||||
const res = await request(buildApp(withToken)).get('/api/agents/cloud');
|
||||
expect(res.status).toBe(401);
|
||||
expect(mockClient.list).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('401s for an openid principal with no openidId to bind against (no fail-open)', async () => {
|
||||
// provider==='openid' but the record has no openidId: the binding cannot be
|
||||
// asserted, so NO token is forwarded — even one whose sub would have matched
|
||||
// a normal user. Fail-secure closes the null-binding gap.
|
||||
mockPrincipal = { id: 'u_openid_no_sub', provider: 'openid' };
|
||||
const res = await request(buildApp(withToken)).get('/api/agents/cloud');
|
||||
expect(res.status).toBe(401);
|
||||
expect(mockClient.list).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('401s when a foreign id_token is injected via cookie with an empty session (confused-deputy denied)', async () => {
|
||||
// Attacker pairs their own openid app-token (req.user) with a victim's
|
||||
// id_token in the openid_id_token cookie and an empty session. The sub
|
||||
// binding rejects it — the forwarded principal can only ever be req.user.
|
||||
const foreign = mintIdToken({ sub: 'victim-sub-xyz' });
|
||||
const app = buildApp({}, `openid_id_token=${foreign}`);
|
||||
const res = await request(app).get('/api/agents/cloud');
|
||||
expect(res.status).toBe(401);
|
||||
expect(mockClient.list).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('disabled deployment', () => {
|
||||
it('returns an empty, disabled list when cloud agents are not configured', async () => {
|
||||
getCloudAgentsClient.mockReturnValue(null);
|
||||
const res = await request(buildApp(withToken)).get('/api/agents/cloud');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ agents: [], enabled: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('run', () => {
|
||||
it('forwards {input} and returns the RunResult', async () => {
|
||||
mockClient.run.mockResolvedValue({ id: 'run_1', status: 'ok', output: 'done' });
|
||||
const res = await request(buildApp(withToken))
|
||||
.post('/api/agents/cloud/researcher/run')
|
||||
.send({ input: 'summarize' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockClient.run).toHaveBeenCalledWith(VALID_ID, 'researcher', 'summarize');
|
||||
expect(res.body.output).toBe('done');
|
||||
});
|
||||
|
||||
it('surfaces an upstream error body with its status (honest failure)', async () => {
|
||||
const err = Object.assign(new Error('bad gateway'), {
|
||||
status: 502,
|
||||
body: { status: 'error', error: 'model down' },
|
||||
});
|
||||
mockClient.run.mockRejectedValue(err);
|
||||
const res = await request(buildApp(withToken))
|
||||
.post('/api/agents/cloud/researcher/run')
|
||||
.send({ input: 'x' });
|
||||
expect(res.status).toBe(502);
|
||||
expect(res.body).toEqual({ status: 'error', error: 'model down' });
|
||||
});
|
||||
|
||||
it('maps a client-side validation error (bad name) to 400', async () => {
|
||||
const err = Object.assign(new Error('invalid agent name'), { status: 400 });
|
||||
mockClient.run.mockRejectedValue(err);
|
||||
const res = await request(buildApp(withToken))
|
||||
.post('/api/agents/cloud/researcher/run')
|
||||
.send({ input: 'x' });
|
||||
// simulate the client rejecting after the boundary let a valid name through
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('boundary name validation (traversal / injection guard)', () => {
|
||||
// Each decodes (per Express) to a value outside cloud's handle grammar; the
|
||||
// route must reject at the boundary BEFORE constructing any client call.
|
||||
const smuggles = [
|
||||
'..%2Fetc', // -> ../etc
|
||||
'%2e%2e%2fadmin', // -> ../admin
|
||||
'..%5Cevil', // -> ..\evil
|
||||
'a%00b', // -> null byte
|
||||
'a%0dHost', // -> CR injection
|
||||
'a%20b', // -> space
|
||||
];
|
||||
for (const name of smuggles) {
|
||||
it(`rejects "${name}" with 400 and never calls the client`, async () => {
|
||||
const res = await request(buildApp(withToken))
|
||||
.post(`/api/agents/cloud/${name}/run`)
|
||||
.send({ input: 'x' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(mockClient.run).not.toHaveBeenCalled();
|
||||
});
|
||||
}
|
||||
|
||||
it('rejects a bad name on GET /:name too', async () => {
|
||||
const res = await request(buildApp(withToken)).get('/api/agents/cloud/..%2Fetc');
|
||||
expect(res.status).toBe(400);
|
||||
expect(mockClient.get).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,201 +0,0 @@
|
||||
const cookies = require('cookie');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const express = require('express');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { requireJwtAuth, cloudAgentLimiter } = require('~/server/middleware');
|
||||
const { getCloudAgentsClient, AGENT_NAME_RE } = require('~/server/services/CloudAgentsClient');
|
||||
|
||||
/**
|
||||
* Cloud agents router — lets a signed-in chat user RUN their own canonical Hanzo
|
||||
* Cloud agents (`/v1/agents`) from the chat thread. Mounted at `/api/agents/cloud`.
|
||||
*
|
||||
* GET /api/agents/cloud list the caller's cloud agents
|
||||
* GET /api/agents/cloud/:name one agent's detail + recent runs
|
||||
* POST /api/agents/cloud/:name/run run the agent {input} -> RunResult
|
||||
*
|
||||
* Auth: `requireJwtAuth` gates every route (guests are rejected). The chat
|
||||
* backend then forwards the user's hanzo.id id_token to cloud as a Bearer;
|
||||
* cloud validates it and scopes to the user's org (see CloudAgentsClient). The
|
||||
* token is read from the server-side session and NEVER returned to the browser.
|
||||
*/
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* Is this token safe to forward on-behalf-of `user`? Fail-secure gates, ALL
|
||||
* mandatory — the function only ever REMOVES a token from consideration, never
|
||||
* admits one:
|
||||
* - Decodable JWT: an opaque/garbage token cannot be principal-bound, so it is
|
||||
* never forwarded. hanzo.id — the only cloud IdP — always issues JWTs.
|
||||
* - Principal binding (MANDATORY): the token must NAME the authenticated user
|
||||
* (`sub === user.openidId`). If we cannot ASSERT the binding — no `openidId`
|
||||
* on the principal, no `sub` on the token, or a mismatch — we do NOT forward.
|
||||
* This keeps "forwarded principal == req.user" true by construction, closing
|
||||
* the credential-mixing confused deputy (a session/cookie carrying a
|
||||
* different user's token) with no fail-open when binding data is absent.
|
||||
* - Expiry: never forward a token past its own `exp`.
|
||||
*
|
||||
* Decode-only (no signature verification): cloud performs the authoritative
|
||||
* JWKS + claim validation over the SAME claims, so it runs as exactly this
|
||||
* `sub`. A forged/tampered token gains nothing here — changing `sub` fails the
|
||||
* binding; an intact `sub` is rejected by cloud on signature.
|
||||
*
|
||||
* @param {string|undefined} token
|
||||
* @param {{openidId?: string}} user
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isForwardableToken(token, user) {
|
||||
if (!token || !user?.openidId) {
|
||||
return false;
|
||||
}
|
||||
const claims = jwt.decode(token);
|
||||
if (!claims || typeof claims !== 'object') {
|
||||
return false;
|
||||
}
|
||||
if (claims.sub !== user.openidId) {
|
||||
return false;
|
||||
}
|
||||
if (typeof claims.exp === 'number' && claims.exp * 1000 <= Date.now()) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the caller's hanzo.id bearer for the on-behalf-of call to cloud.
|
||||
*
|
||||
* Keyed off the VALIDATED principal (`req.user.provider === 'openid'` — the
|
||||
* authoritative DB user loaded by requireJwtAuth), NOT any cookie. A local user
|
||||
* never carries a hanzo.id token, so a stale OpenID session left in the browser
|
||||
* can never be forwarded under a local identity — the confused deputy is denied
|
||||
* at the identity layer.
|
||||
*
|
||||
* EVERY candidate — session first, then the httpOnly no-session cookie fallback;
|
||||
* id_token preferred, access_token second — must pass `isForwardableToken`:
|
||||
* principal-bound to req.user and unexpired. Returns null — for an honest 401,
|
||||
* never a wrong-principal, expired, unbound, or fabricated call — otherwise.
|
||||
*
|
||||
* @param {import('express').Request} req
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function getUserCloudBearer(req) {
|
||||
if (req.user?.provider !== 'openid') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = req.headers.cookie ? cookies.parse(req.headers.cookie) : {};
|
||||
const session = req.session?.openidTokens;
|
||||
|
||||
const idToken = session?.idToken || parsed.openid_id_token;
|
||||
if (isForwardableToken(idToken, req.user)) {
|
||||
return idToken;
|
||||
}
|
||||
/**
|
||||
* access_token fallback (rare: the session/cookie carries no id_token).
|
||||
* hanzo.id issues JWT access tokens too, so this stays principal-bound; an
|
||||
* opaque or foreign access_token fails the gate and is never forwarded.
|
||||
*/
|
||||
const accessToken = session?.accessToken || parsed.openid_access_token;
|
||||
if (isForwardableToken(accessToken, req.user)) {
|
||||
return accessToken;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
router.use(requireJwtAuth);
|
||||
|
||||
/**
|
||||
* Per-user rate limit. A run is a real, billable cloud completion holding an
|
||||
* upstream socket; without this it escapes the throttle that guards the sibling
|
||||
* chat-completion path. Applies to every cloud route (list/get/run) so no proxy
|
||||
* op can be looped to degrade the shared backend. Runs after requireJwtAuth so
|
||||
* the limiter keys on a real user id.
|
||||
*/
|
||||
router.use(cloudAgentLimiter);
|
||||
|
||||
/**
|
||||
* Validate the :name path segment at the HTTP boundary — the same cloud handle
|
||||
* grammar the client enforces, applied here so a malformed/decoded name
|
||||
* (traversal, null byte, CRLF, backslash) is rejected before any client call is
|
||||
* even constructed. Defense at the boundary, not only in the client.
|
||||
*/
|
||||
router.param('name', (req, res, next, name) => {
|
||||
if (!AGENT_NAME_RE.test((name ?? '').toString().trim())) {
|
||||
return res.status(400).json({ error: 'invalid agent name' });
|
||||
}
|
||||
return next();
|
||||
});
|
||||
|
||||
/**
|
||||
* Map a CloudAgentsClient error to an HTTP response. Upstream failures that
|
||||
* carry a run body (cloud's 502 with a recorded error run) are passed through so
|
||||
* the client can render the honest failure; everything else is normalized.
|
||||
* @param {import('express').Response} res
|
||||
* @param {Error & {status?: number, body?: any}} err
|
||||
* @param {string} action
|
||||
*/
|
||||
function sendCloudError(res, err, action) {
|
||||
const status = err.status && err.status >= 400 && err.status < 600 ? err.status : 502;
|
||||
if (status >= 500) {
|
||||
logger.warn(`[cloudAgents] ${action} failed`, { status, message: err.message });
|
||||
}
|
||||
if (err.body && typeof err.body === 'object') {
|
||||
return res.status(status).json(err.body);
|
||||
}
|
||||
return res.status(status).json({ error: err.message || 'cloud agents request failed' });
|
||||
}
|
||||
|
||||
/** GET /api/agents/cloud — list the caller's cloud agents. */
|
||||
router.get('/', async (req, res) => {
|
||||
const client = getCloudAgentsClient();
|
||||
if (!client) {
|
||||
return res.json({ agents: [], enabled: false });
|
||||
}
|
||||
const bearer = getUserCloudBearer(req);
|
||||
if (!bearer) {
|
||||
return res.status(401).json({ error: 'cloud agents require hanzo.id sign-in' });
|
||||
}
|
||||
try {
|
||||
const data = await client.list(bearer);
|
||||
return res.json({ ...data, enabled: true });
|
||||
} catch (err) {
|
||||
return sendCloudError(res, err, 'list');
|
||||
}
|
||||
});
|
||||
|
||||
/** GET /api/agents/cloud/:name — one agent's detail + recent runs. */
|
||||
router.get('/:name', async (req, res) => {
|
||||
const client = getCloudAgentsClient();
|
||||
if (!client) {
|
||||
return res.status(404).json({ error: 'cloud agents not configured' });
|
||||
}
|
||||
const bearer = getUserCloudBearer(req);
|
||||
if (!bearer) {
|
||||
return res.status(401).json({ error: 'cloud agents require hanzo.id sign-in' });
|
||||
}
|
||||
try {
|
||||
const data = await client.get(bearer, req.params.name);
|
||||
return res.json(data);
|
||||
} catch (err) {
|
||||
return sendCloudError(res, err, 'get');
|
||||
}
|
||||
});
|
||||
|
||||
/** POST /api/agents/cloud/:name/run — run the agent, return its RunResult. */
|
||||
router.post('/:name/run', async (req, res) => {
|
||||
const client = getCloudAgentsClient();
|
||||
if (!client) {
|
||||
return res.status(404).json({ error: 'cloud agents not configured' });
|
||||
}
|
||||
const bearer = getUserCloudBearer(req);
|
||||
if (!bearer) {
|
||||
return res.status(401).json({ error: 'cloud agents require hanzo.id sign-in' });
|
||||
}
|
||||
try {
|
||||
const run = await client.run(bearer, req.params.name, req.body?.input ?? '');
|
||||
return res.json(run);
|
||||
} catch (err) {
|
||||
return sendCloudError(res, err, 'run');
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -8,15 +8,11 @@ const {
|
||||
messageIpLimiter,
|
||||
configMiddleware,
|
||||
messageUserLimiter,
|
||||
enforceGuestScope,
|
||||
guestMessageLimiter,
|
||||
requireGuestOrJwtAuth,
|
||||
} = require('~/server/middleware');
|
||||
const { saveMessage } = require('~/models');
|
||||
const openai = require('./openai');
|
||||
const responses = require('./responses');
|
||||
const { v1 } = require('./v1');
|
||||
const cloud = require('./cloud');
|
||||
const chat = require('./chat');
|
||||
|
||||
const { LIMIT_MESSAGE_IP, LIMIT_MESSAGE_USER } = process.env ?? {};
|
||||
@@ -37,76 +33,10 @@ router.use('/v1/responses', responses);
|
||||
*/
|
||||
router.use('/v1', openai);
|
||||
|
||||
/**
|
||||
* Guest-capable chat completion router.
|
||||
*
|
||||
* Mounted before the JWT-only routes below so anonymous guests (when
|
||||
* `ALLOW_GUEST_CHAT` is enabled) can reach ONLY the completion endpoints. Auth
|
||||
* here accepts either a guest token or a real JWT; `enforceGuestScope` then pins
|
||||
* guests to the free Zen endpoint/model and strips every other capability, and
|
||||
* `guestMessageLimiter` enforces the per-IP guest quota. Every other agents route
|
||||
* (management, CRUD, files) remains gated by the strict `requireJwtAuth` below,
|
||||
* which rejects guest tokens.
|
||||
*/
|
||||
const RESERVED_CHAT_SUBPATHS = new Set(['stream', 'active', 'status', 'abort']);
|
||||
|
||||
const chatRouter = express.Router();
|
||||
/**
|
||||
* Defer reserved management subpaths (stream/active/status/abort) to the
|
||||
* JWT-only handlers defined on the parent router. Without this guard the
|
||||
* completion route's `POST /:endpoint` would shadow `POST /chat/abort`.
|
||||
*/
|
||||
chatRouter.use((req, res, next) => {
|
||||
const subpath = req.path.split('/').filter(Boolean)[0];
|
||||
if (RESERVED_CHAT_SUBPATHS.has(subpath)) {
|
||||
return next('router');
|
||||
}
|
||||
return next();
|
||||
});
|
||||
chatRouter.use(requireGuestOrJwtAuth);
|
||||
chatRouter.use(checkBan);
|
||||
chatRouter.use(uaParser);
|
||||
chatRouter.use(configMiddleware);
|
||||
chatRouter.use(enforceGuestScope);
|
||||
chatRouter.use(guestMessageLimiter);
|
||||
|
||||
if (isEnabled(LIMIT_MESSAGE_IP)) {
|
||||
chatRouter.use(messageIpLimiter);
|
||||
}
|
||||
|
||||
if (isEnabled(LIMIT_MESSAGE_USER)) {
|
||||
chatRouter.use(messageUserLimiter);
|
||||
}
|
||||
|
||||
chatRouter.use('/', chat);
|
||||
|
||||
router.use('/chat', chatRouter);
|
||||
|
||||
/**
|
||||
* Guest-safe active-jobs poll. Guests never own a generation job, so this
|
||||
* returns an empty set without a DB/user lookup. Registered BEFORE the strict
|
||||
* JWT guard below so the composer's bootstrap poll doesn't 401-loop for guests;
|
||||
* every other agents route stays JWT-only and rejects guest tokens.
|
||||
*/
|
||||
router.get('/chat/active', requireGuestOrJwtAuth, async (req, res, next) => {
|
||||
if (req.user?.guest === true) {
|
||||
return res.json({ activeJobIds: [] });
|
||||
}
|
||||
return next();
|
||||
});
|
||||
|
||||
router.use(requireJwtAuth);
|
||||
router.use(checkBan);
|
||||
router.use(uaParser);
|
||||
|
||||
/**
|
||||
* Canonical Hanzo Cloud agents (`/v1/agents`). Mounted BEFORE the legacy `/`
|
||||
* (v1) router so `/cloud/*` is not shadowed by v1's `GET /:id`. The cloud router
|
||||
* carries its own `requireJwtAuth` and forwards the user's hanzo.id bearer to
|
||||
* cloud server-side (see cloud.js). Legacy `/api/agents` CRUD stays untouched.
|
||||
*/
|
||||
router.use('/cloud', cloud);
|
||||
|
||||
router.use('/', v1);
|
||||
|
||||
/**
|
||||
@@ -339,4 +269,18 @@ router.post('/chat/abort', async (req, res) => {
|
||||
return res.status(404).json({ error: 'Job not found', streamId: jobStreamId });
|
||||
});
|
||||
|
||||
const chatRouter = express.Router();
|
||||
chatRouter.use(configMiddleware);
|
||||
|
||||
if (isEnabled(LIMIT_MESSAGE_IP)) {
|
||||
chatRouter.use(messageIpLimiter);
|
||||
}
|
||||
|
||||
if (isEnabled(LIMIT_MESSAGE_USER)) {
|
||||
chatRouter.use(messageUserLimiter);
|
||||
}
|
||||
|
||||
chatRouter.use('/', chat);
|
||||
router.use('/chat', chatRouter);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -17,7 +17,6 @@ const {
|
||||
const { verify2FAWithTempToken } = require('~/server/controllers/auth/TwoFactorAuthController');
|
||||
const { logoutController } = require('~/server/controllers/auth/LogoutController');
|
||||
const { loginController } = require('~/server/controllers/auth/LoginController');
|
||||
const { guestTokenController } = require('~/server/controllers/auth/GuestController');
|
||||
const { getAppConfig } = require('~/server/services/Config');
|
||||
const middleware = require('~/server/middleware');
|
||||
const { Balance } = require('~/db/models');
|
||||
@@ -42,7 +41,6 @@ router.post(
|
||||
loginController,
|
||||
);
|
||||
router.post('/refresh', refreshController);
|
||||
router.post('/guest', middleware.guestTokenLimiter, middleware.checkBan, guestTokenController);
|
||||
router.post(
|
||||
'/register',
|
||||
middleware.registerLimiter,
|
||||
|
||||
@@ -5,7 +5,6 @@ const { isEnabled, getBalanceConfig } = require('@hanzochat/api');
|
||||
const { Constants, CacheKeys, defaultSocialLogins } = require('librechat-data-provider');
|
||||
const { getLdapConfig } = require('~/server/services/Config/ldap');
|
||||
const { getAppConfig } = require('~/server/services/Config/app');
|
||||
const { getGuestConfig } = require('~/server/services/guestConfig');
|
||||
const { getProjectByName } = require('~/models/Project');
|
||||
const { getLogStores } = require('~/cache');
|
||||
|
||||
@@ -60,8 +59,6 @@ router.get('/', async function (req, res) {
|
||||
|
||||
const balanceConfig = getBalanceConfig(appConfig);
|
||||
|
||||
const guestConfig = getGuestConfig();
|
||||
|
||||
// Strip server-internal fields from balance config before sending to client.
|
||||
// commerce.endpoint leaks K8s service URLs; commerce.token is a bearer secret.
|
||||
const clientBalanceConfig = balanceConfig
|
||||
@@ -124,8 +121,6 @@ router.get('/', async function (req, res) {
|
||||
sharePointPickerGraphScope: process.env.SHAREPOINT_PICKER_GRAPH_SCOPE,
|
||||
sharePointPickerSharePointScope: process.env.SHAREPOINT_PICKER_SHAREPOINT_SCOPE,
|
||||
openidReuseTokens,
|
||||
allowGuestChat: guestConfig.enabled,
|
||||
guestMessageMax: guestConfig.enabled ? guestConfig.messageMax : undefined,
|
||||
conversationImportMaxFileSize: process.env.CONVERSATION_IMPORT_MAX_FILE_SIZE_BYTES
|
||||
? parseInt(process.env.CONVERSATION_IMPORT_MAX_FILE_SIZE_BYTES, 10)
|
||||
: 0,
|
||||
|
||||
@@ -15,7 +15,6 @@ const { forkConversation, duplicateConversation } = require('~/server/utils/impo
|
||||
const { storage, importFileFilter } = require('~/server/routes/files/multer');
|
||||
const { deleteAllSharedLinks, deleteConvoSharedLink } = require('~/models');
|
||||
const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
|
||||
const requireGuestOrJwtAuth = require('~/server/middleware/requireGuestOrJwtAuth');
|
||||
const { importConversations } = require('~/server/utils/import');
|
||||
const { deleteToolCalls } = require('~/models/ToolCall');
|
||||
const getLogStores = require('~/cache/getLogStores');
|
||||
@@ -26,17 +25,9 @@ const assistantClients = {
|
||||
};
|
||||
|
||||
const router = express.Router();
|
||||
router.use(requireJwtAuth);
|
||||
|
||||
/**
|
||||
* Guest-accessible conversation list. Guests have no persisted conversations, so
|
||||
* this returns an empty, well-formed page — enough for the UI to mount the chat
|
||||
* history pane without a DB lookup. Registered with guest-aware auth BEFORE the
|
||||
* strict JWT guard below; every mutation/read-by-id route stays JWT-only.
|
||||
*/
|
||||
router.get('/', requireGuestOrJwtAuth, async (req, res) => {
|
||||
if (req.user?.guest === true) {
|
||||
return res.status(200).json({ conversations: [], nextCursor: null });
|
||||
}
|
||||
router.get('/', async (req, res) => {
|
||||
const limit = parseInt(req.query.limit, 10) || 25;
|
||||
const cursor = req.query.cursor;
|
||||
const isArchived = isEnabled(req.query.isArchived);
|
||||
@@ -66,9 +57,6 @@ router.get('/', requireGuestOrJwtAuth, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/** Every route below reads or writes persisted user data — JWT-only, no guests. */
|
||||
router.use(requireJwtAuth);
|
||||
|
||||
router.get('/:conversationId', async (req, res) => {
|
||||
const { conversationId } = req.params;
|
||||
const convo = await getConvo(req.user.id, conversationId);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
const express = require('express');
|
||||
const requireGuestOrJwtAuth = require('~/server/middleware/requireGuestOrJwtAuth');
|
||||
const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
|
||||
const endpointController = require('~/server/controllers/EndpointController');
|
||||
|
||||
const router = express.Router();
|
||||
router.get('/', requireGuestOrJwtAuth, endpointController);
|
||||
router.get('/', requireJwtAuth, endpointController);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
const express = require('express');
|
||||
const { modelController } = require('~/server/controllers/ModelController');
|
||||
const { requireGuestOrJwtAuth } = require('~/server/middleware/');
|
||||
const { requireJwtAuth } = require('~/server/middleware/');
|
||||
|
||||
const router = express.Router();
|
||||
router.get('/', requireGuestOrJwtAuth, modelController);
|
||||
router.get('/', requireJwtAuth, modelController);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// file deepcode ignore NoRateLimitingForLogin: `loginLimiter` is applied per-route to the IdP-initiation GETs (not the machine-driven /callback routes, which would break the OIDC code exchange)
|
||||
// file deepcode ignore NoRateLimitingForLogin: Rate limiting is handled by the `loginLimiter` middleware
|
||||
const express = require('express');
|
||||
const passport = require('passport');
|
||||
const { randomState } = require('openid-client');
|
||||
@@ -23,6 +23,7 @@ const domains = {
|
||||
};
|
||||
|
||||
router.use(logHeaders);
|
||||
router.use(loginLimiter);
|
||||
|
||||
const oauthHandler = createOAuthHandler();
|
||||
|
||||
@@ -41,7 +42,6 @@ router.get('/error', (req, res) => {
|
||||
*/
|
||||
router.get(
|
||||
'/google',
|
||||
loginLimiter,
|
||||
passport.authenticate('google', {
|
||||
scope: ['openid', 'profile', 'email'],
|
||||
session: false,
|
||||
@@ -66,7 +66,6 @@ router.get(
|
||||
*/
|
||||
router.get(
|
||||
'/facebook',
|
||||
loginLimiter,
|
||||
passport.authenticate('facebook', {
|
||||
scope: ['public_profile'],
|
||||
profileFields: ['id', 'email', 'name'],
|
||||
@@ -91,7 +90,7 @@ router.get(
|
||||
/**
|
||||
* OpenID Routes
|
||||
*/
|
||||
router.get('/openid', loginLimiter, (req, res, next) => {
|
||||
router.get('/openid', (req, res, next) => {
|
||||
return passport.authenticate('openid', {
|
||||
session: false,
|
||||
state: randomState(),
|
||||
@@ -115,7 +114,6 @@ router.get(
|
||||
*/
|
||||
router.get(
|
||||
'/github',
|
||||
loginLimiter,
|
||||
passport.authenticate('github', {
|
||||
scope: ['user:email', 'read:user'],
|
||||
session: false,
|
||||
@@ -140,7 +138,6 @@ router.get(
|
||||
*/
|
||||
router.get(
|
||||
'/discord',
|
||||
loginLimiter,
|
||||
passport.authenticate('discord', {
|
||||
scope: ['identify', 'email'],
|
||||
session: false,
|
||||
@@ -165,7 +162,6 @@ router.get(
|
||||
*/
|
||||
router.get(
|
||||
'/apple',
|
||||
loginLimiter,
|
||||
passport.authenticate('apple', {
|
||||
session: false,
|
||||
}),
|
||||
@@ -188,7 +184,6 @@ router.post(
|
||||
*/
|
||||
router.get(
|
||||
'/saml',
|
||||
loginLimiter,
|
||||
passport.authenticate('saml', {
|
||||
session: false,
|
||||
}),
|
||||
|
||||
@@ -3,12 +3,11 @@ const {
|
||||
updateFavoritesController,
|
||||
getFavoritesController,
|
||||
} = require('~/server/controllers/FavoritesController');
|
||||
const { requireJwtAuth, requireGuestOrJwtAuth } = require('~/server/middleware');
|
||||
const { requireJwtAuth } = require('~/server/middleware');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Read-only favorites are guest-safe (empty list); writes stay JWT-only.
|
||||
router.get('/favorites', requireGuestOrJwtAuth, getFavoritesController);
|
||||
router.get('/favorites', requireJwtAuth, getFavoritesController);
|
||||
router.post('/favorites', requireJwtAuth, updateFavoritesController);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -13,7 +13,6 @@ const {
|
||||
configMiddleware,
|
||||
canDeleteAccount,
|
||||
requireJwtAuth,
|
||||
requireGuestOrJwtAuth,
|
||||
} = require('~/server/middleware');
|
||||
|
||||
const settings = require('./settings');
|
||||
@@ -21,7 +20,7 @@ const settings = require('./settings');
|
||||
const router = express.Router();
|
||||
|
||||
router.use('/settings', settings);
|
||||
router.get('/', requireGuestOrJwtAuth, getUserController);
|
||||
router.get('/', requireJwtAuth, getUserController);
|
||||
router.get('/terms', requireJwtAuth, getTermsStatusController);
|
||||
router.post('/terms/accept', requireJwtAuth, acceptTermsController);
|
||||
router.post('/plugins', requireJwtAuth, updateUserPluginsController);
|
||||
|
||||
@@ -413,53 +413,6 @@ const setAuthTokens = async (userId, res, _session = null) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @function persistOpenIDTokensToSession
|
||||
* Persist the OpenID tokenset material an on-behalf-of downstream call needs
|
||||
* (e.g. running the caller's canonical Hanzo Cloud `/v1/agents`) into the
|
||||
* server-side express-session — and ONLY there, never a browser cookie.
|
||||
*
|
||||
* This is DELIBERATELY decoupled from the token-refresh strategy. It runs on
|
||||
* EVERY OpenID login regardless of `OPENID_REUSE_TOKENS`; that flag alone still
|
||||
* governs whether `/api/auth/refresh` performs an OIDC refresh-grant. It writes
|
||||
* only server-side session state (no `token_provider`, `refreshToken`, or other
|
||||
* auth cookie), so it cannot alter the browser-facing login/refresh cookies — a
|
||||
* REUSE-disabled login stays byte-identical.
|
||||
*
|
||||
* The OIDC `refresh` credential is a SEPARATE concern from the on-behalf-of
|
||||
* BEARER: it is persisted only when passed EXPLICITLY as `refreshToken`, which
|
||||
* the REUSE path does (its session refreshes via OIDC and `refreshController` /
|
||||
* `logoutController` read `session.openidTokens.refreshToken`). The decoupled
|
||||
* default passes NO refresh credential — its session refreshes via the local
|
||||
* JWT cookie — so no OIDC refresh token is written here. That keeps the decoupled
|
||||
* session carrying only bearer material and leaves refresh/logout identical to a
|
||||
* non-OpenID login (they read the local cookie, never this session field).
|
||||
*
|
||||
* @param {Object} req - request carrying the express-session
|
||||
* @param {import('openid-client').TokenEndpointResponse & Partial<import('openid-client').TokenEndpointResponseHelpers>} tokenset
|
||||
* @param {string} [refreshToken] - OIDC refresh credential to bind to this session (REUSE mode only); omitted in the decoupled path
|
||||
* @returns {boolean} true if persisted; false when no session (or tokenset) was available
|
||||
*/
|
||||
const persistOpenIDTokensToSession = (req, tokenset, refreshToken) => {
|
||||
if (!req?.session) {
|
||||
logger.warn(
|
||||
'[persistOpenIDTokensToSession] No session available; on-behalf-of tokens not stored',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (!tokenset) {
|
||||
return false;
|
||||
}
|
||||
const expiryInMilliseconds = math(process.env.REFRESH_TOKEN_EXPIRY, DEFAULT_REFRESH_TOKEN_EXPIRY);
|
||||
req.session.openidTokens = {
|
||||
accessToken: tokenset.access_token,
|
||||
idToken: tokenset.id_token,
|
||||
refreshToken,
|
||||
expiresAt: Date.now() + expiryInMilliseconds,
|
||||
};
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* @function setOpenIDAuthTokens
|
||||
* Set OpenID Authentication Tokens
|
||||
@@ -525,8 +478,15 @@ const setOpenIDAuthTokens = (tokenset, req, res, userId, existingRefreshToken) =
|
||||
sameSite: 'lax',
|
||||
});
|
||||
|
||||
/** Store tokens server-side in session to avoid large cookies (one writer). */
|
||||
if (!persistOpenIDTokensToSession(req, tokenset, refreshToken)) {
|
||||
/** Store tokens server-side in session to avoid large cookies */
|
||||
if (req.session) {
|
||||
req.session.openidTokens = {
|
||||
accessToken: tokenset.access_token,
|
||||
idToken: tokenset.id_token,
|
||||
refreshToken: refreshToken,
|
||||
expiresAt: expirationDate.getTime(),
|
||||
};
|
||||
} else {
|
||||
logger.warn('[setOpenIDAuthTokens] No session available, falling back to cookies');
|
||||
res.cookie('openid_access_token', tokenset.access_token, {
|
||||
expires: expirationDate,
|
||||
@@ -636,7 +596,6 @@ module.exports = {
|
||||
setAuthTokens,
|
||||
resetPassword,
|
||||
setOpenIDAuthTokens,
|
||||
persistOpenIDTokensToSession,
|
||||
requestPasswordReset,
|
||||
resendVerificationEmail,
|
||||
};
|
||||
|
||||
@@ -36,8 +36,7 @@ jest.mock('~/server/services/Config', () => ({ getAppConfig: jest.fn() }));
|
||||
jest.mock('~/server/utils', () => ({ sendEmail: jest.fn() }));
|
||||
|
||||
const { shouldUseSecureCookie } = require('@hanzochat/api');
|
||||
const { setOpenIDAuthTokens, persistOpenIDTokensToSession } = require('./AuthService');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { setOpenIDAuthTokens } = require('./AuthService');
|
||||
|
||||
/** Helper to build a mock Express response */
|
||||
function mockResponse() {
|
||||
@@ -268,76 +267,3 @@ describe('setOpenIDAuthTokens', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The on-behalf-of session writer, decoupled from the refresh strategy. Called on
|
||||
* every OpenID login (regardless of OPENID_REUSE_TOKENS) so downstream cloud
|
||||
* calls can run as the hanzo.id principal — and ONLY ever touches server-side
|
||||
* session state, never a browser cookie.
|
||||
*/
|
||||
describe('persistOpenIDTokensToSession', () => {
|
||||
const env = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
process.env = { ...env };
|
||||
delete process.env.REFRESH_TOKEN_EXPIRY; // fall back to DEFAULT_REFRESH_TOKEN_EXPIRY
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
process.env = env;
|
||||
});
|
||||
|
||||
it('stores ONLY the bearer material (no OIDC refresh credential) in the decoupled path', () => {
|
||||
// Decoupled call (no explicit refreshToken arg): the on-behalf-of bearer is
|
||||
// persisted, but the tokenset's refresh_token is NOT — the decoupled session
|
||||
// refreshes via the local JWT cookie, and leaking the OIDC refresh token into
|
||||
// the session would hijack logout's session lookup (findSession by the wrong
|
||||
// token) and leave the local session un-invalidated.
|
||||
const tokenset = {
|
||||
id_token: 'the-id-token',
|
||||
access_token: 'the-access-token',
|
||||
refresh_token: 'the-refresh-token',
|
||||
};
|
||||
const req = { session: {} };
|
||||
|
||||
const before = Date.now();
|
||||
const result = persistOpenIDTokensToSession(req, tokenset);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(req.session.openidTokens).toEqual({
|
||||
accessToken: 'the-access-token',
|
||||
idToken: 'the-id-token',
|
||||
refreshToken: undefined,
|
||||
expiresAt: expect.any(Number),
|
||||
});
|
||||
// Expiry is anchored ~REFRESH_TOKEN_EXPIRY (default 7d) ahead of now.
|
||||
expect(req.session.openidTokens.expiresAt).toBeGreaterThanOrEqual(before + 604800000);
|
||||
});
|
||||
|
||||
it('persists the OIDC refresh credential only when passed EXPLICITLY (REUSE path)', () => {
|
||||
const tokenset = { id_token: 'id', access_token: 'acc', refresh_token: 'tokenset-refresh' };
|
||||
const req = { session: {} };
|
||||
|
||||
// The REUSE caller (setOpenIDAuthTokens) resolves and passes the credential.
|
||||
persistOpenIDTokensToSession(req, tokenset, 'explicit-refresh');
|
||||
|
||||
expect(req.session.openidTokens.refreshToken).toBe('explicit-refresh');
|
||||
});
|
||||
|
||||
it('returns false and warns when no session is available (never a cookie)', () => {
|
||||
const req = { session: null };
|
||||
const result = persistOpenIDTokensToSession(req, { access_token: 'acc' });
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns false when no tokenset is provided', () => {
|
||||
const req = { session: {} };
|
||||
const result = persistOpenIDTokensToSession(req, undefined);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(req.session.openidTokens).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,282 +0,0 @@
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
|
||||
/**
|
||||
* The canonical cloud agent registry is Hanzo Cloud `/v1/agents`
|
||||
* (github.com/hanzoai/cloud, clients/agents). This client lets chat's backend
|
||||
* RUN a caller's own cloud agents from the chat thread, without ever exposing
|
||||
* the user's IAM token to the browser.
|
||||
*
|
||||
* Tenant isolation is NOT enforced here — it is enforced BY cloud. This client
|
||||
* forwards the caller's own hanzo.id bearer (the OpenID id_token) as
|
||||
* `Authorization: Bearer <token>`; cloud's SanitizeIdentity middleware
|
||||
* (HIP-0026) validates it and pins `X-Org-Id` from the verified `owner` claim,
|
||||
* stripping any client-supplied copy. So a chat user can only ever reach their
|
||||
* OWN org's agents — chat is not trusted to assert the org, and this client
|
||||
* deliberately never sends an org header for cloud to trust.
|
||||
*
|
||||
* This is NOT an open proxy: the base host is fixed from env, the only paths are
|
||||
* the three hardcoded templates below, and the agent name is validated against
|
||||
* cloud's own handle grammar before it is ever placed in a URL (no traversal,
|
||||
* no SSRF).
|
||||
*
|
||||
* @example
|
||||
* const client = getCloudAgentsClient();
|
||||
* const { agents } = await client.list(userBearer);
|
||||
* const run = await client.run(userBearer, 'researcher', 'summarize Q3');
|
||||
*/
|
||||
|
||||
/**
|
||||
* Cloud's org-unique agent handle AND URL path segment. Mirrors nameRE in
|
||||
* cloud/clients/agents/agents.go exactly — the traversal/SSRF guard at chat's
|
||||
* boundary, so a malformed name is rejected before it reaches the network.
|
||||
*/
|
||||
const AGENT_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
||||
|
||||
/** Matches cloud's maxInput (128 KiB) so oversized input fails fast, locally. */
|
||||
const MAX_INPUT = 128 * 1024;
|
||||
|
||||
/**
|
||||
* Cap the upstream body we buffer. A run's output is bounded; refusing a runaway
|
||||
* or malformed response keeps one request from pinning the shared backend's
|
||||
* memory. Availability defense — the proxy is otherwise unbounded by size.
|
||||
*/
|
||||
const MAX_RESPONSE = 4 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Process-wide ceiling on concurrent in-flight calls to cloud. Each call holds a
|
||||
* socket + timer for up to `timeout` ms; without a cap, a burst (many users, or
|
||||
* one user within the per-user rate window) could saturate the shared backend.
|
||||
* Fail fast (503) past the ceiling rather than queue and hold resources.
|
||||
*/
|
||||
const MAX_CONCURRENT = Number(process.env.CLOUD_AGENT_MAX_CONCURRENT) || 50;
|
||||
|
||||
class CloudAgentsClient {
|
||||
/**
|
||||
* @param {Object} opts
|
||||
* @param {string} opts.endpoint - Cloud base URL (e.g. https://api.hanzo.ai)
|
||||
* @param {number} [opts.timeout] - HTTP timeout in ms (default 30000; a run is
|
||||
* a real chat completion so it needs more headroom than a metadata read)
|
||||
* @param {number} [opts.maxConcurrent] - process-wide in-flight ceiling
|
||||
*/
|
||||
constructor({ endpoint, timeout = 30000, maxConcurrent = MAX_CONCURRENT }) {
|
||||
this.endpoint = endpoint.replace(/\/+$/, '');
|
||||
this.timeout = timeout;
|
||||
this.maxConcurrent = maxConcurrent;
|
||||
this._inFlight = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an agent name against cloud's handle grammar. Throws a tagged error
|
||||
* (status 400) on failure so the route surfaces an honest client error.
|
||||
* @param {string} name
|
||||
* @returns {string} the trimmed, validated name
|
||||
*/
|
||||
static requireValidName(name) {
|
||||
const n = (name ?? '').toString().trim();
|
||||
if (!AGENT_NAME_RE.test(n)) {
|
||||
const err = new Error('invalid agent name');
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/**
|
||||
* List the caller's cloud agents.
|
||||
* @param {string} bearer - the caller's hanzo.id id_token
|
||||
* @returns {Promise<{agents: Array}>}
|
||||
*/
|
||||
async list(bearer) {
|
||||
return this._request('GET', '/v1/agents', bearer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get one cloud agent (detail + recent runs).
|
||||
* @param {string} bearer
|
||||
* @param {string} name
|
||||
* @returns {Promise<Object>} cloud's AgentDetail
|
||||
*/
|
||||
async get(bearer, name) {
|
||||
const n = CloudAgentsClient.requireValidName(name);
|
||||
return this._request('GET', `/v1/agents/${encodeURIComponent(n)}`, bearer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one cloud agent with a caller-supplied input. Records a real run in
|
||||
* cloud and returns the RunResult (status "ok" with output, or an upstream
|
||||
* failure surfaced honestly).
|
||||
* @param {string} bearer
|
||||
* @param {string} name
|
||||
* @param {string} input
|
||||
* @returns {Promise<Object>} cloud's RunResult
|
||||
*/
|
||||
async run(bearer, name, input) {
|
||||
const n = CloudAgentsClient.requireValidName(name);
|
||||
const body = (input ?? '').toString();
|
||||
// Byte length, not UTF-16 units — matches cloud's byte-based maxInput exactly
|
||||
// (a multibyte string can be ~3x its .length in UTF-8).
|
||||
if (Buffer.byteLength(body, 'utf8') > MAX_INPUT) {
|
||||
const err = new Error('input too large');
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
return this._request('POST', `/v1/agents/${encodeURIComponent(n)}/run`, bearer, {
|
||||
input: body,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} method
|
||||
* @param {string} path - one of the fixed templates above
|
||||
* @param {string} bearer - the caller's hanzo.id bearer (required)
|
||||
* @param {Object} [body]
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async _request(method, path, bearer, body) {
|
||||
if (!bearer) {
|
||||
// Fail secure: never fall back to an ambient/service credential — that
|
||||
// would run as the wrong principal. Absent a user bearer, deny.
|
||||
const err = new Error('missing user credential');
|
||||
err.status = 401;
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (this._inFlight >= this.maxConcurrent) {
|
||||
// Shed load: the shared backend is saturated with upstream calls. Refusing
|
||||
// here protects every other tenant — availability over this one request.
|
||||
const err = new Error('cloud agents temporarily saturated');
|
||||
err.status = 503;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const url = `${this.endpoint}${path}`;
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${bearer}`,
|
||||
};
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
||||
this._inFlight += 1;
|
||||
|
||||
try {
|
||||
const opts = { method, headers, signal: controller.signal };
|
||||
if (body && method !== 'GET') {
|
||||
opts.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
const resp = await fetch(url, opts);
|
||||
const text = await this._readCapped(resp, controller);
|
||||
let json;
|
||||
try {
|
||||
json = text ? JSON.parse(text) : {};
|
||||
} catch {
|
||||
json = { error: text };
|
||||
}
|
||||
|
||||
if (!resp.ok) {
|
||||
// A run that executed but failed upstream comes back as 502 with a run
|
||||
// body (status/error) — preserve that so the caller can surface it.
|
||||
const err = new Error(`cloud ${method} ${path} returned ${resp.status}`);
|
||||
err.status = resp.status;
|
||||
err.body = json;
|
||||
throw err;
|
||||
}
|
||||
return json;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
this._inFlight -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the response body but never buffer more than MAX_RESPONSE bytes. Rejects
|
||||
* a declared-oversize body up front (Content-Length) and aborts a chunked
|
||||
* stream the instant it exceeds the cap, so a runaway upstream cannot exhaust
|
||||
* memory. Falls back to `.text()` when the runtime/mocks expose no stream body.
|
||||
* @param {Response} resp
|
||||
* @param {AbortController} controller
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async _readCapped(resp, controller) {
|
||||
const declared = Number(resp.headers?.get?.('content-length'));
|
||||
if (Number.isFinite(declared) && declared > MAX_RESPONSE) {
|
||||
controller.abort();
|
||||
const err = new Error('cloud response too large');
|
||||
err.status = 502;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const reader = resp.body?.getReader?.();
|
||||
if (!reader) {
|
||||
return resp.text().catch(() => '');
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let out = '';
|
||||
let total = 0;
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
total += value.byteLength;
|
||||
if (total > MAX_RESPONSE) {
|
||||
await reader.cancel();
|
||||
controller.abort();
|
||||
const err = new Error('cloud response too large');
|
||||
err.status = 502;
|
||||
throw err;
|
||||
}
|
||||
out += decoder.decode(value, { stream: true });
|
||||
}
|
||||
out += decoder.decode();
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleton, initialized lazily from env. Returns null when cloud agents are not
|
||||
* configured for this deployment (the route then answers with a disabled state).
|
||||
*/
|
||||
let _instance;
|
||||
|
||||
function getCloudAgentsClient() {
|
||||
if (_instance !== undefined) {
|
||||
return _instance;
|
||||
}
|
||||
|
||||
// Dedicated var first; fall back to the same host the rest of chat already
|
||||
// talks to (api.hanzo.ai), derived from OPENAI_BASE_URL by stripping the /v1.
|
||||
let endpoint = (process.env.HANZO_CLOUD_URL || '').trim();
|
||||
if (!endpoint) {
|
||||
const base = (process.env.OPENAI_BASE_URL || '').trim();
|
||||
if (base) {
|
||||
endpoint = base.replace(/\/v1\/?$/, '');
|
||||
}
|
||||
}
|
||||
|
||||
if (!endpoint) {
|
||||
_instance = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
_instance = new CloudAgentsClient({ endpoint });
|
||||
logger.info('[CloudAgentsClient] Initialized', { endpoint });
|
||||
return _instance;
|
||||
}
|
||||
|
||||
/** Reset the memoized singleton (tests only). */
|
||||
function _resetCloudAgentsClient() {
|
||||
_instance = undefined;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CloudAgentsClient,
|
||||
getCloudAgentsClient,
|
||||
_resetCloudAgentsClient,
|
||||
AGENT_NAME_RE,
|
||||
MAX_INPUT,
|
||||
MAX_RESPONSE,
|
||||
MAX_CONCURRENT,
|
||||
};
|
||||
@@ -1,248 +0,0 @@
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
logger: {
|
||||
debug: jest.fn(),
|
||||
error: jest.fn(),
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const {
|
||||
CloudAgentsClient,
|
||||
getCloudAgentsClient,
|
||||
_resetCloudAgentsClient,
|
||||
AGENT_NAME_RE,
|
||||
MAX_INPUT,
|
||||
MAX_RESPONSE,
|
||||
} = require('./CloudAgentsClient');
|
||||
|
||||
/**
|
||||
* Build a fetch mock that captures the last request and returns a canned response.
|
||||
*/
|
||||
function mockFetch({ ok = true, status = 200, body = {} } = {}) {
|
||||
const calls = [];
|
||||
const fn = jest.fn(async (url, opts) => {
|
||||
calls.push({ url, opts });
|
||||
return {
|
||||
ok,
|
||||
status,
|
||||
text: async () => (typeof body === 'string' ? body : JSON.stringify(body)),
|
||||
};
|
||||
});
|
||||
fn.calls = calls;
|
||||
return fn;
|
||||
}
|
||||
|
||||
describe('CloudAgentsClient', () => {
|
||||
const BEARER = 'header.payload.sig';
|
||||
|
||||
afterEach(() => {
|
||||
_resetCloudAgentsClient();
|
||||
delete global.fetch;
|
||||
});
|
||||
|
||||
describe('name validation (traversal / SSRF guard)', () => {
|
||||
const bad = ['', '.', '..', '../etc', 'a/b', 'name with space', 'a'.repeat(65), '-lead'];
|
||||
const good = ['researcher', 'a', 'A.b_c-1', '0abc'];
|
||||
|
||||
it('rejects malformed names before any network call', async () => {
|
||||
const fetch = mockFetch();
|
||||
global.fetch = fetch;
|
||||
const client = new CloudAgentsClient({ endpoint: 'https://api.hanzo.ai' });
|
||||
for (const name of bad) {
|
||||
await expect(client.get(BEARER, name)).rejects.toMatchObject({ status: 400 });
|
||||
}
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts valid handles matching cloud grammar', () => {
|
||||
for (const name of good) {
|
||||
expect(AGENT_NAME_RE.test(name)).toBe(true);
|
||||
expect(CloudAgentsClient.requireValidName(name)).toBe(name);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('bearer forwarding + tenant model', () => {
|
||||
it('forwards the user bearer and NEVER sends an org header', async () => {
|
||||
const fetch = mockFetch({ body: { agents: [] } });
|
||||
global.fetch = fetch;
|
||||
const client = new CloudAgentsClient({ endpoint: 'https://api.hanzo.ai' });
|
||||
await client.list(BEARER);
|
||||
|
||||
const { url, opts } = fetch.calls[0];
|
||||
expect(url).toBe('https://api.hanzo.ai/v1/agents');
|
||||
expect(opts.headers.Authorization).toBe(`Bearer ${BEARER}`);
|
||||
// Tenant isolation is cloud's job; chat must not assert an org.
|
||||
expect(opts.headers['X-Org-Id']).toBeUndefined();
|
||||
expect(opts.headers['X-Hanzo-Org']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('fails secure (401) with no fallback credential when bearer is missing', async () => {
|
||||
const fetch = mockFetch();
|
||||
global.fetch = fetch;
|
||||
const client = new CloudAgentsClient({ endpoint: 'https://api.hanzo.ai' });
|
||||
await expect(client.list('')).rejects.toMatchObject({ status: 401 });
|
||||
await expect(client.run('', 'researcher', 'x')).rejects.toMatchObject({ status: 401 });
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('run', () => {
|
||||
it('posts {input} to the run endpoint and returns the RunResult', async () => {
|
||||
const fetch = mockFetch({ body: { id: 'run_1', status: 'ok', output: 'hi' } });
|
||||
global.fetch = fetch;
|
||||
const client = new CloudAgentsClient({ endpoint: 'https://api.hanzo.ai/' });
|
||||
const run = await client.run(BEARER, 'researcher', 'summarize');
|
||||
|
||||
const { url, opts } = fetch.calls[0];
|
||||
expect(url).toBe('https://api.hanzo.ai/v1/agents/researcher/run');
|
||||
expect(opts.method).toBe('POST');
|
||||
expect(JSON.parse(opts.body)).toEqual({ input: 'summarize' });
|
||||
expect(run).toEqual({ id: 'run_1', status: 'ok', output: 'hi' });
|
||||
});
|
||||
|
||||
it('rejects oversized input locally (before the network)', async () => {
|
||||
const fetch = mockFetch();
|
||||
global.fetch = fetch;
|
||||
const client = new CloudAgentsClient({ endpoint: 'https://api.hanzo.ai' });
|
||||
const big = 'x'.repeat(128 * 1024 + 1);
|
||||
await expect(client.run(BEARER, 'researcher', big)).rejects.toMatchObject({ status: 400 });
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('surfaces an upstream failure body (cloud 502 error run) with its status', async () => {
|
||||
const fetch = mockFetch({
|
||||
ok: false,
|
||||
status: 502,
|
||||
body: { id: 'run_2', status: 'error', error: 'model down' },
|
||||
});
|
||||
global.fetch = fetch;
|
||||
const client = new CloudAgentsClient({ endpoint: 'https://api.hanzo.ai' });
|
||||
await expect(client.run(BEARER, 'researcher', 'x')).rejects.toMatchObject({
|
||||
status: 502,
|
||||
body: { status: 'error', error: 'model down' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('input byte cap', () => {
|
||||
it('rejects on UTF-8 BYTE length, not UTF-16 units (multibyte bypass)', async () => {
|
||||
const fetch = mockFetch();
|
||||
global.fetch = fetch;
|
||||
const client = new CloudAgentsClient({ endpoint: 'https://api.hanzo.ai' });
|
||||
// Half MAX_INPUT in .length, but 3 bytes/char in UTF-8 => 1.5x MAX_INPUT bytes.
|
||||
const multibyte = '你'.repeat(MAX_INPUT / 2);
|
||||
expect(multibyte.length).toBeLessThan(MAX_INPUT); // would pass a naive .length check
|
||||
expect(Buffer.byteLength(multibyte, 'utf8')).toBeGreaterThan(MAX_INPUT);
|
||||
await expect(client.run(BEARER, 'researcher', multibyte)).rejects.toMatchObject({
|
||||
status: 400,
|
||||
});
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('response size cap (availability)', () => {
|
||||
it('rejects a declared-oversize body up front via Content-Length (502)', async () => {
|
||||
global.fetch = jest.fn(
|
||||
async () =>
|
||||
new Response('ok', {
|
||||
status: 200,
|
||||
headers: { 'content-length': String(MAX_RESPONSE + 1) },
|
||||
}),
|
||||
);
|
||||
const client = new CloudAgentsClient({ endpoint: 'https://api.hanzo.ai' });
|
||||
await expect(client.list(BEARER)).rejects.toMatchObject({ status: 502 });
|
||||
});
|
||||
|
||||
it('aborts a chunked stream once it exceeds the cap (502)', async () => {
|
||||
const oversize = new Uint8Array(MAX_RESPONSE + 16);
|
||||
global.fetch = jest.fn(async () => {
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(oversize);
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
return new Response(stream, { status: 200 }); // chunked: no content-length
|
||||
});
|
||||
const client = new CloudAgentsClient({ endpoint: 'https://api.hanzo.ai' });
|
||||
await expect(client.list(BEARER)).rejects.toMatchObject({ status: 502 });
|
||||
});
|
||||
|
||||
it('reads a normal small streamed body', async () => {
|
||||
global.fetch = jest.fn(
|
||||
async () => new Response(JSON.stringify({ agents: [] }), { status: 200 }),
|
||||
);
|
||||
const client = new CloudAgentsClient({ endpoint: 'https://api.hanzo.ai' });
|
||||
await expect(client.list(BEARER)).resolves.toEqual({ agents: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('concurrency cap (load shedding)', () => {
|
||||
it('sheds load with 503 once the in-flight ceiling is reached, before any fetch', async () => {
|
||||
const fetch = mockFetch({ body: { agents: [] } });
|
||||
global.fetch = fetch;
|
||||
const client = new CloudAgentsClient({ endpoint: 'https://api.hanzo.ai', maxConcurrent: 2 });
|
||||
client._inFlight = 2; // simulate the ceiling being saturated
|
||||
await expect(client.list(BEARER)).rejects.toMatchObject({ status: 503 });
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('decrements in-flight after a call completes (no leak)', async () => {
|
||||
const fetch = mockFetch({ body: { agents: [] } });
|
||||
global.fetch = fetch;
|
||||
const client = new CloudAgentsClient({ endpoint: 'https://api.hanzo.ai', maxConcurrent: 1 });
|
||||
await client.list(BEARER);
|
||||
expect(client._inFlight).toBe(0);
|
||||
await client.list(BEARER); // ceiling of 1 is reusable across sequential calls
|
||||
expect(client._inFlight).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('endpoint normalization', () => {
|
||||
it('strips trailing slashes from the base URL', async () => {
|
||||
const fetch = mockFetch({ body: {} });
|
||||
global.fetch = fetch;
|
||||
const client = new CloudAgentsClient({ endpoint: 'https://api.hanzo.ai///' });
|
||||
await client.list(BEARER);
|
||||
expect(fetch.calls[0].url).toBe('https://api.hanzo.ai/v1/agents');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCloudAgentsClient (env wiring)', () => {
|
||||
const saved = {};
|
||||
beforeEach(() => {
|
||||
saved.HANZO_CLOUD_URL = process.env.HANZO_CLOUD_URL;
|
||||
saved.OPENAI_BASE_URL = process.env.OPENAI_BASE_URL;
|
||||
delete process.env.HANZO_CLOUD_URL;
|
||||
delete process.env.OPENAI_BASE_URL;
|
||||
_resetCloudAgentsClient();
|
||||
});
|
||||
afterEach(() => {
|
||||
process.env.HANZO_CLOUD_URL = saved.HANZO_CLOUD_URL;
|
||||
process.env.OPENAI_BASE_URL = saved.OPENAI_BASE_URL;
|
||||
if (saved.HANZO_CLOUD_URL === undefined) {
|
||||
delete process.env.HANZO_CLOUD_URL;
|
||||
}
|
||||
if (saved.OPENAI_BASE_URL === undefined) {
|
||||
delete process.env.OPENAI_BASE_URL;
|
||||
}
|
||||
_resetCloudAgentsClient();
|
||||
});
|
||||
|
||||
it('returns null when unconfigured', () => {
|
||||
expect(getCloudAgentsClient()).toBeNull();
|
||||
});
|
||||
|
||||
it('prefers HANZO_CLOUD_URL', () => {
|
||||
process.env.HANZO_CLOUD_URL = 'https://cloud.example';
|
||||
expect(getCloudAgentsClient().endpoint).toBe('https://cloud.example');
|
||||
});
|
||||
|
||||
it('derives the host from OPENAI_BASE_URL by stripping /v1', () => {
|
||||
process.env.OPENAI_BASE_URL = 'https://api.hanzo.ai/v1';
|
||||
expect(getCloudAgentsClient().endpoint).toBe('https://api.hanzo.ai');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -43,31 +43,14 @@ class CommerceClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* The Commerce namespace (X-Hanzo-Org) for a billing subject. The subject is
|
||||
* object.BillingSubject(owner, name): "owner/name" (per-user) or "owner"
|
||||
* (pooled) — so the namespace is always the part before the first "/", or the
|
||||
* whole subject. Deriving it here keeps every read/write scoped to the right
|
||||
* tenant without callers having to thread the org separately.
|
||||
* Check user's balance. Returns cached result if fresh, triggers async
|
||||
* refresh if stale, synchronous fetch on cache miss. Fails open on error.
|
||||
*
|
||||
* @param {string} subject
|
||||
* @returns {string}
|
||||
*/
|
||||
_namespaceOf(subject) {
|
||||
const s = (subject ?? '').toString();
|
||||
const i = s.indexOf('/');
|
||||
return i > 0 ? s.slice(0, i) : s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check a billing subject's balance. Returns cached result if fresh, triggers
|
||||
* async refresh if stale, synchronous fetch on cache miss. Fails CLOSED (the
|
||||
* cold-miss fetch throws) so the caller blocks rather than bleeding.
|
||||
*
|
||||
* @param {string} subject - Commerce billing subject (e.g. "hanzo/alice@gmail.com")
|
||||
* @param {string} userId - Commerce user ID (e.g. "hanzo/alice")
|
||||
* @returns {Promise<{sufficient: boolean, available: number}>}
|
||||
*/
|
||||
async checkBalance(subject) {
|
||||
const cached = this._balanceCache.get(subject);
|
||||
async checkBalance(userId) {
|
||||
const cached = this._balanceCache.get(userId);
|
||||
const now = Date.now();
|
||||
|
||||
if (cached) {
|
||||
@@ -78,13 +61,13 @@ class CommerceClient {
|
||||
// Stale: serve cached, refresh async
|
||||
if (!cached.refreshing) {
|
||||
cached.refreshing = true;
|
||||
this._fetchBalance(subject).catch(() => {});
|
||||
this._fetchBalance(userId).catch(() => {});
|
||||
}
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
// Cache miss: synchronous fetch
|
||||
return this._fetchBalance(subject);
|
||||
return this._fetchBalance(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -163,31 +146,30 @@ class CommerceClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a subject has the one-time $5 starter credit, idempotently.
|
||||
* Create a trial credit grant for a new user.
|
||||
*
|
||||
* This posts to /v1/billing/grant-starter, which creates a real Deposit
|
||||
* transaction (tag "starter-credit", $5, 30-day expiry) — so it nets into
|
||||
* GET /v1/billing/balance, the account the gateway gate reads and debits.
|
||||
* (NOT a credit-grant record: those live in a separate ledger the balance
|
||||
* endpoint does not read, so they would never unblock the gate.)
|
||||
*
|
||||
* Idempotent + race-safe in Commerce (tag-deduped inside a transaction): safe
|
||||
* to call on every first chat; duplicate/concurrent calls never double-grant.
|
||||
*
|
||||
* @param {string} subject - Commerce billing subject (e.g. "hanzo/alice@gmail.com")
|
||||
* @returns {Promise<{granted: boolean}|null>} or null on failure
|
||||
* @param {string} userId - Commerce user ID
|
||||
* @param {number} amountCents - Grant amount in cents (e.g. 500 = $5)
|
||||
* @param {number} expiryDays - Days until expiry
|
||||
* @param {string[]} [eligibility] - Meter IDs (empty = all meters)
|
||||
* @returns {Promise<Object|null>} Grant object or null on failure
|
||||
*/
|
||||
async grantStarter(subject) {
|
||||
async createTrialGrant(userId, amountCents, expiryDays, eligibility = []) {
|
||||
try {
|
||||
const resp = await this._request(
|
||||
'POST',
|
||||
'/v1/billing/grant-starter',
|
||||
{ user: subject, trigger: 'chat_first_use' },
|
||||
this._namespaceOf(subject),
|
||||
);
|
||||
const expiresIn = `${expiryDays * 24}h`;
|
||||
const resp = await this._request('POST', '/v1/billing/credit-grants', {
|
||||
userId,
|
||||
name: 'Trial Credit',
|
||||
amountCents,
|
||||
currency: 'usd',
|
||||
expiresIn,
|
||||
priority: 100, // Trial burns before purchased (200)
|
||||
eligibility,
|
||||
tags: 'trial',
|
||||
});
|
||||
return resp;
|
||||
} catch (err) {
|
||||
logger.error('[CommerceClient] Failed to ensure starter credit', err);
|
||||
logger.error('[CommerceClient] Failed to create trial grant', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -203,8 +185,6 @@ class CommerceClient {
|
||||
const resp = await this._request(
|
||||
'GET',
|
||||
`/v1/billing/credit-balance/breakdown?userId=${encodeURIComponent(userId)}`,
|
||||
undefined,
|
||||
this._namespaceOf(userId),
|
||||
);
|
||||
const breakdown = resp.breakdown || {};
|
||||
return {
|
||||
@@ -220,30 +200,27 @@ class CommerceClient {
|
||||
|
||||
// ── Internal methods ──
|
||||
|
||||
async _fetchBalance(subject) {
|
||||
// FAIL CLOSED: this is the money gate. On error we THROW so the caller blocks
|
||||
// the request rather than letting unfunded/unknown users spend. The cache
|
||||
// (serve-stale on refresh) smooths transient blips for already-known users;
|
||||
// only a cold miss + error propagates. `subject` is the billing account
|
||||
// (object.BillingSubject) used as `?user=`; the namespace (X-Hanzo-Org) is
|
||||
// its org prefix — matching the gateway's keying so chat reads the SAME
|
||||
// account the gateway debits.
|
||||
const resp = await this._request(
|
||||
'GET',
|
||||
`/v1/billing/balance?user=${encodeURIComponent(subject)}¤cy=usd`,
|
||||
undefined,
|
||||
this._namespaceOf(subject),
|
||||
);
|
||||
const data = {
|
||||
sufficient: (resp.available || 0) > 0,
|
||||
available: resp.available || 0,
|
||||
};
|
||||
this._balanceCache.set(subject, {
|
||||
data,
|
||||
fetchedAt: Date.now(),
|
||||
refreshing: false,
|
||||
});
|
||||
return data;
|
||||
async _fetchBalance(userId) {
|
||||
try {
|
||||
const resp = await this._request(
|
||||
'GET',
|
||||
`/v1/billing/balance?user=${encodeURIComponent(userId)}¤cy=usd`,
|
||||
);
|
||||
const data = {
|
||||
sufficient: (resp.available || 0) > 0,
|
||||
available: resp.available || 0,
|
||||
};
|
||||
this._balanceCache.set(userId, {
|
||||
data,
|
||||
fetchedAt: Date.now(),
|
||||
refreshing: false,
|
||||
});
|
||||
return data;
|
||||
} catch (err) {
|
||||
logger.warn('[CommerceClient] Balance check failed, failing open', { userId, error: err.message });
|
||||
// Fail open
|
||||
return { sufficient: true, available: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
async _fetchTier(userId, tierName) {
|
||||
@@ -252,7 +229,7 @@ class CommerceClient {
|
||||
if (tierName) {
|
||||
url += `&tier=${encodeURIComponent(tierName)}`;
|
||||
}
|
||||
const resp = await this._request('GET', url, undefined, this._namespaceOf(userId));
|
||||
const resp = await this._request('GET', url);
|
||||
const tier = resp.tier || null;
|
||||
if (tier) {
|
||||
this._tierCache.set(userId, {
|
||||
@@ -298,17 +275,12 @@ class CommerceClient {
|
||||
* @param {Object} [body]
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async _request(method, path, body, orgId) {
|
||||
async _request(method, path, body) {
|
||||
const url = `${this.endpoint}${path}`;
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
if (this.token) {
|
||||
headers['Authorization'] = `Bearer ${this.token}`;
|
||||
}
|
||||
// Scope the service-token call to the tenant's commerce namespace so reads/
|
||||
// writes are correctly per-org (not the service token's default namespace).
|
||||
if (orgId) {
|
||||
headers['X-Hanzo-Org'] = orgId;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
||||
|
||||
@@ -7,10 +7,7 @@ const buildOptions = (req, endpoint, parsedBody, endpointType) => {
|
||||
const agentPromise = loadAgent({
|
||||
req,
|
||||
spec,
|
||||
// No agent selected on the agents endpoint → ad-hoc (ephemeral) chat, so the
|
||||
// user gets a reply without first creating/selecting an agent. Mirrors the
|
||||
// access middleware, which treats a missing id as ephemeral.
|
||||
agent_id: isAgentsEndpoint(endpoint) && agent_id ? agent_id : Constants.EPHEMERAL_AGENT_ID,
|
||||
agent_id: isAgentsEndpoint(endpoint) ? agent_id : Constants.EPHEMERAL_AGENT_ID,
|
||||
endpoint,
|
||||
model_parameters,
|
||||
}).catch((error) => {
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
/**
|
||||
* Regression: when no agent is created/selected, the agents endpoint must resolve
|
||||
* to an ephemeral (ad-hoc, plain-model) agent so the user gets a reply — instead
|
||||
* of loadAgent returning null because agent_id was missing.
|
||||
*
|
||||
* Hermetic: mocks data-schemas (no winston) and ~/models/Agent (no MongoDB).
|
||||
*/
|
||||
|
||||
jest.mock('@librechat/data-schemas', () => ({ logger: { error: jest.fn() } }));
|
||||
|
||||
jest.mock('librechat-data-provider', () => ({
|
||||
Constants: { EPHEMERAL_AGENT_ID: 'ephemeral' },
|
||||
isAgentsEndpoint: (endpoint) => endpoint === 'agents',
|
||||
removeNullishValues: (obj) =>
|
||||
Object.fromEntries(Object.entries(obj).filter(([, v]) => v != null)),
|
||||
}));
|
||||
|
||||
const mockLoadAgent = jest.fn(() => Promise.resolve({ id: 'ephemeral', model: 'zen3-nano' }));
|
||||
jest.mock('~/models/Agent', () => ({ loadAgent: mockLoadAgent }));
|
||||
|
||||
const { buildOptions } = require('./build');
|
||||
|
||||
const EPHEMERAL = 'ephemeral';
|
||||
|
||||
describe('agents buildOptions — missing agent_id resolves to ephemeral', () => {
|
||||
beforeEach(() => mockLoadAgent.mockClear());
|
||||
|
||||
test('no agent_id on agents endpoint → loadAgent called with EPHEMERAL_AGENT_ID', async () => {
|
||||
const req = { body: {}, user: { id: 'u1' } };
|
||||
// parsedBody as built from "My Agents" with no agent selected (no agent_id).
|
||||
const options = buildOptions(req, 'agents', { model: 'zen3-nano' });
|
||||
await options.agent;
|
||||
|
||||
expect(mockLoadAgent).toHaveBeenCalledTimes(1);
|
||||
expect(mockLoadAgent.mock.calls[0][0].agent_id).toBe(EPHEMERAL);
|
||||
});
|
||||
|
||||
test('real agent_id on agents endpoint is passed through unchanged', async () => {
|
||||
const req = { body: {}, user: { id: 'u1' } };
|
||||
const options = buildOptions(req, 'agents', { model: 'zen3-nano', agent_id: 'agent_real_42' });
|
||||
await options.agent;
|
||||
|
||||
expect(mockLoadAgent.mock.calls[0][0].agent_id).toBe('agent_real_42');
|
||||
});
|
||||
|
||||
test('non-agents endpoint always resolves to ephemeral', async () => {
|
||||
const req = { body: {}, user: { id: 'u1' } };
|
||||
const options = buildOptions(req, 'openAI', { model: 'zen3-nano', agent_id: 'agent_real_42' });
|
||||
await options.agent;
|
||||
|
||||
expect(mockLoadAgent.mock.calls[0][0].agent_id).toBe(EPHEMERAL);
|
||||
});
|
||||
});
|
||||
@@ -1,118 +0,0 @@
|
||||
const { isEnabled } = require('@hanzochat/api');
|
||||
const { EModelEndpoint } = require('librechat-data-provider');
|
||||
|
||||
const GUEST_ROLE = 'GUEST';
|
||||
const GUEST_NAME = 'Guest';
|
||||
const DEFAULT_GUEST_MESSAGE_MAX = 3;
|
||||
const DEFAULT_GUEST_TOKEN_EXPIRY_MS = 60 * 60 * 1000;
|
||||
const DEFAULT_GUEST_ENDPOINT = 'Hanzo';
|
||||
const DEFAULT_GUEST_MODEL = 'zen3-nano';
|
||||
|
||||
/**
|
||||
* Resolves the guest-chat configuration from the environment.
|
||||
* Guest chat is disabled unless `ALLOW_GUEST_CHAT` is explicitly enabled.
|
||||
*
|
||||
* @returns {{
|
||||
* enabled: boolean,
|
||||
* messageMax: number,
|
||||
* tokenExpiryMs: number,
|
||||
* endpoint: string,
|
||||
* model: string,
|
||||
* }}
|
||||
*/
|
||||
const getGuestConfig = () => {
|
||||
const messageMax = Number.parseInt(process.env.GUEST_MESSAGE_MAX, 10);
|
||||
const tokenExpiryMs = Number.parseInt(process.env.GUEST_TOKEN_EXPIRY, 10);
|
||||
|
||||
return {
|
||||
enabled: isEnabled(process.env.ALLOW_GUEST_CHAT),
|
||||
messageMax:
|
||||
Number.isFinite(messageMax) && messageMax > 0 ? messageMax : DEFAULT_GUEST_MESSAGE_MAX,
|
||||
tokenExpiryMs:
|
||||
Number.isFinite(tokenExpiryMs) && tokenExpiryMs > 0
|
||||
? tokenExpiryMs
|
||||
: DEFAULT_GUEST_TOKEN_EXPIRY_MS,
|
||||
endpoint: process.env.GUEST_ENDPOINT || DEFAULT_GUEST_ENDPOINT,
|
||||
model: process.env.GUEST_MODEL || DEFAULT_GUEST_MODEL,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the ephemeral guest principal for a verified guest token.
|
||||
*
|
||||
* This is the SINGLE source of truth for the guest `req.user` shape. It is a
|
||||
* plain object — never a DB document — so no route ever reads or writes real
|
||||
* user data on behalf of a guest. No email, no DB id.
|
||||
*
|
||||
* @param {string} id - The synthetic guest id from the token (`guest_<uuid>`).
|
||||
* @returns {{ id: string, role: string, name: string, guest: true }}
|
||||
*/
|
||||
const buildGuestPrincipal = (id) => ({
|
||||
id,
|
||||
role: GUEST_ROLE,
|
||||
name: GUEST_NAME,
|
||||
guest: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* Builds the guest-scoped `/api/user` response: the ephemeral principal only.
|
||||
* Mirrors the safe-field shape the client expects (no password/totp/email/db id).
|
||||
*
|
||||
* @param {{ id: string }} principal
|
||||
* @returns {object}
|
||||
*/
|
||||
const buildGuestUser = (principal) => ({
|
||||
id: principal.id,
|
||||
username: GUEST_NAME,
|
||||
name: GUEST_NAME,
|
||||
role: GUEST_ROLE,
|
||||
provider: 'guest',
|
||||
emailVerified: false,
|
||||
guest: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* Builds the guest-scoped endpoints config: ONLY the configured guest endpoint,
|
||||
* with no builder/agent/file/preset capabilities. Everything else is omitted so
|
||||
* the client cannot surface any other endpoint to a guest.
|
||||
*
|
||||
* @returns {Record<string, object>}
|
||||
*/
|
||||
const buildGuestEndpointsConfig = () => {
|
||||
const { endpoint } = getGuestConfig();
|
||||
return {
|
||||
[endpoint]: {
|
||||
type: EModelEndpoint.custom,
|
||||
userProvide: false,
|
||||
modelDisplayLabel: endpoint,
|
||||
order: 0,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the guest-scoped models config: the single configured guest model under
|
||||
* the guest endpoint. The client pins the composer to exactly this one model.
|
||||
*
|
||||
* @returns {Record<string, string[]>}
|
||||
*/
|
||||
const buildGuestModelsConfig = () => {
|
||||
const { endpoint, model } = getGuestConfig();
|
||||
return {
|
||||
[endpoint]: [model],
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getGuestConfig,
|
||||
buildGuestPrincipal,
|
||||
buildGuestUser,
|
||||
buildGuestEndpointsConfig,
|
||||
buildGuestModelsConfig,
|
||||
GUEST_ROLE,
|
||||
GUEST_NAME,
|
||||
DEFAULT_GUEST_MESSAGE_MAX,
|
||||
DEFAULT_GUEST_TOKEN_EXPIRY_MS,
|
||||
DEFAULT_GUEST_ENDPOINT,
|
||||
DEFAULT_GUEST_MODEL,
|
||||
};
|
||||
@@ -1,120 +0,0 @@
|
||||
jest.mock('@hanzochat/api', () => ({
|
||||
isEnabled: (value) => value === 'true' || value === true,
|
||||
}));
|
||||
|
||||
jest.mock('librechat-data-provider', () => ({
|
||||
EModelEndpoint: { custom: 'custom' },
|
||||
}));
|
||||
|
||||
const {
|
||||
getGuestConfig,
|
||||
buildGuestPrincipal,
|
||||
buildGuestUser,
|
||||
buildGuestEndpointsConfig,
|
||||
buildGuestModelsConfig,
|
||||
GUEST_ROLE,
|
||||
GUEST_NAME,
|
||||
DEFAULT_GUEST_MESSAGE_MAX,
|
||||
DEFAULT_GUEST_ENDPOINT,
|
||||
DEFAULT_GUEST_MODEL,
|
||||
} = require('./guestConfig');
|
||||
|
||||
describe('getGuestConfig', () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.ALLOW_GUEST_CHAT;
|
||||
delete process.env.GUEST_MESSAGE_MAX;
|
||||
delete process.env.GUEST_TOKEN_EXPIRY;
|
||||
delete process.env.GUEST_ENDPOINT;
|
||||
delete process.env.GUEST_MODEL;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
it('is disabled by default (fail closed)', () => {
|
||||
expect(getGuestConfig().enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('is enabled only when ALLOW_GUEST_CHAT is truthy', () => {
|
||||
process.env.ALLOW_GUEST_CHAT = 'true';
|
||||
expect(getGuestConfig().enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('uses defaults when env is unset', () => {
|
||||
const config = getGuestConfig();
|
||||
expect(config.messageMax).toBe(DEFAULT_GUEST_MESSAGE_MAX);
|
||||
expect(config.endpoint).toBe(DEFAULT_GUEST_ENDPOINT);
|
||||
expect(config.model).toBe(DEFAULT_GUEST_MODEL);
|
||||
});
|
||||
|
||||
it('honors GUEST_MESSAGE_MAX', () => {
|
||||
process.env.GUEST_MESSAGE_MAX = '7';
|
||||
expect(getGuestConfig().messageMax).toBe(7);
|
||||
});
|
||||
|
||||
it('falls back to default for invalid or non-positive GUEST_MESSAGE_MAX', () => {
|
||||
process.env.GUEST_MESSAGE_MAX = 'abc';
|
||||
expect(getGuestConfig().messageMax).toBe(DEFAULT_GUEST_MESSAGE_MAX);
|
||||
process.env.GUEST_MESSAGE_MAX = '0';
|
||||
expect(getGuestConfig().messageMax).toBe(DEFAULT_GUEST_MESSAGE_MAX);
|
||||
process.env.GUEST_MESSAGE_MAX = '-5';
|
||||
expect(getGuestConfig().messageMax).toBe(DEFAULT_GUEST_MESSAGE_MAX);
|
||||
});
|
||||
|
||||
it('honors the configurable guest endpoint and model', () => {
|
||||
process.env.GUEST_ENDPOINT = 'Hanzo';
|
||||
process.env.GUEST_MODEL = 'zen4-mini';
|
||||
const config = getGuestConfig();
|
||||
expect(config.endpoint).toBe('Hanzo');
|
||||
expect(config.model).toBe('zen4-mini');
|
||||
});
|
||||
|
||||
it('exposes GUEST_ROLE constant', () => {
|
||||
expect(GUEST_ROLE).toBe('GUEST');
|
||||
});
|
||||
});
|
||||
|
||||
describe('guest principal + scoped-config builders', () => {
|
||||
beforeEach(() => {
|
||||
delete process.env.GUEST_ENDPOINT;
|
||||
delete process.env.GUEST_MODEL;
|
||||
});
|
||||
|
||||
it('buildGuestPrincipal returns an ephemeral GUEST principal with no DB id/email', () => {
|
||||
const principal = buildGuestPrincipal('guest_abc');
|
||||
expect(principal).toEqual({
|
||||
id: 'guest_abc',
|
||||
role: GUEST_ROLE,
|
||||
name: GUEST_NAME,
|
||||
guest: true,
|
||||
});
|
||||
expect(principal).not.toHaveProperty('email');
|
||||
expect(principal).not.toHaveProperty('_id');
|
||||
});
|
||||
|
||||
it('buildGuestUser exposes only safe, guest-scoped fields (no email)', () => {
|
||||
const user = buildGuestUser(buildGuestPrincipal('guest_abc'));
|
||||
expect(user.id).toBe('guest_abc');
|
||||
expect(user.role).toBe(GUEST_ROLE);
|
||||
expect(user.name).toBe(GUEST_NAME);
|
||||
expect(user.guest).toBe(true);
|
||||
expect(user).not.toHaveProperty('email');
|
||||
expect(user).not.toHaveProperty('password');
|
||||
});
|
||||
|
||||
it('buildGuestEndpointsConfig exposes ONLY the configured guest endpoint', () => {
|
||||
process.env.GUEST_ENDPOINT = 'Hanzo';
|
||||
const config = buildGuestEndpointsConfig();
|
||||
expect(Object.keys(config)).toEqual(['Hanzo']);
|
||||
expect(config.Hanzo).toMatchObject({ type: 'custom', userProvide: false });
|
||||
});
|
||||
|
||||
it('buildGuestModelsConfig exposes ONLY the single configured guest model', () => {
|
||||
process.env.GUEST_ENDPOINT = 'Hanzo';
|
||||
process.env.GUEST_MODEL = 'zen5-mini';
|
||||
expect(buildGuestModelsConfig()).toEqual({ Hanzo: ['zen5-mini'] });
|
||||
});
|
||||
});
|
||||
@@ -12,16 +12,6 @@ const jwtLogin = () =>
|
||||
},
|
||||
async (payload, done) => {
|
||||
try {
|
||||
/**
|
||||
* Guest tokens carry `guest: true` and a synthetic `guest_<uuid>` id that
|
||||
* is NOT a Mongo ObjectId. Reject them cleanly here so the strict `jwt`
|
||||
* strategy fails with a 401 instead of letting `getUserById` throw a
|
||||
* Mongoose CastError (→ 500). Guests reach their scoped routes through
|
||||
* `requireGuestOrJwtAuth`, never through this strategy. Fail closed.
|
||||
*/
|
||||
if (payload?.guest === true) {
|
||||
return done(null, false);
|
||||
}
|
||||
const user = await getUserById(payload?.id, '-password -__v -totpSecret -backupCodes');
|
||||
if (user) {
|
||||
user.id = user._id.toString();
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
const { SystemRoles } = require('librechat-data-provider');
|
||||
|
||||
// --- Capture the verify callback passed to JwtStrategy ---
|
||||
let capturedVerifyCallback;
|
||||
jest.mock('passport-jwt', () => ({
|
||||
Strategy: jest.fn((_opts, verifyCallback) => {
|
||||
capturedVerifyCallback = verifyCallback;
|
||||
return { name: 'jwt' };
|
||||
}),
|
||||
ExtractJwt: {
|
||||
fromAuthHeaderAsBearerToken: jest.fn(() => 'mock-extractor'),
|
||||
},
|
||||
}));
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
logger: { info: jest.fn(), warn: jest.fn(), debug: jest.fn(), error: jest.fn() },
|
||||
}));
|
||||
jest.mock('~/models', () => ({
|
||||
getUserById: jest.fn(),
|
||||
updateUser: jest.fn(),
|
||||
}));
|
||||
|
||||
const { getUserById, updateUser } = require('~/models');
|
||||
const jwtLogin = require('./jwtStrategy');
|
||||
|
||||
// Helper: invoke the captured verify callback as a promise
|
||||
const invokeVerify = (payload) =>
|
||||
new Promise((resolve, reject) => {
|
||||
capturedVerifyCallback(payload, (err, user, info) => {
|
||||
if (err) {
|
||||
return reject(err);
|
||||
}
|
||||
resolve({ user, info });
|
||||
});
|
||||
});
|
||||
|
||||
describe('jwtStrategy verify callback', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
// Instantiate to capture the verify callback.
|
||||
jwtLogin();
|
||||
});
|
||||
|
||||
it('rejects a guest token cleanly without touching the DB (no CastError → 401)', async () => {
|
||||
const { user } = await invokeVerify({ id: 'guest_abc-123', guest: true, role: 'GUEST' });
|
||||
// done(null, false) → passport responds 401, never 500.
|
||||
expect(user).toBe(false);
|
||||
expect(getUserById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('never calls getUserById for a guest id, even if it would CastError', async () => {
|
||||
getUserById.mockRejectedValue(new Error('CastError: not an ObjectId'));
|
||||
const { user } = await invokeVerify({ id: 'guest_xyz', guest: true });
|
||||
expect(user).toBe(false);
|
||||
expect(getUserById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves a real user for a non-guest token', async () => {
|
||||
getUserById.mockResolvedValue({ _id: { toString: () => 'real-id' }, role: SystemRoles.USER });
|
||||
const { user } = await invokeVerify({ id: 'real-id' });
|
||||
expect(getUserById).toHaveBeenCalledWith('real-id', expect.any(String));
|
||||
expect(user).toMatchObject({ id: 'real-id', role: SystemRoles.USER });
|
||||
});
|
||||
|
||||
it('returns false when a non-guest user is not found', async () => {
|
||||
getUserById.mockResolvedValue(null);
|
||||
const { user } = await invokeVerify({ id: 'missing-id' });
|
||||
expect(user).toBe(false);
|
||||
});
|
||||
|
||||
it('propagates a DB error for a non-guest token (done(err, false))', async () => {
|
||||
getUserById.mockRejectedValue(new Error('db down'));
|
||||
await expect(invokeVerify({ id: 'real-id' })).rejects.toThrow('db down');
|
||||
});
|
||||
|
||||
it('does not treat guest:false as a guest token', async () => {
|
||||
getUserById.mockResolvedValue({ _id: { toString: () => 'real-id' }, role: SystemRoles.USER });
|
||||
await invokeVerify({ id: 'real-id', guest: false });
|
||||
expect(getUserById).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('backfills a missing role for a real user', async () => {
|
||||
getUserById.mockResolvedValue({ _id: { toString: () => 'real-id' } });
|
||||
const { user } = await invokeVerify({ id: 'real-id' });
|
||||
expect(updateUser).toHaveBeenCalledWith('real-id', { role: SystemRoles.USER });
|
||||
expect(user.role).toBe(SystemRoles.USER);
|
||||
});
|
||||
});
|
||||
@@ -9,8 +9,6 @@
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="description" content="Hanzo Chat - AI chat platform with support for multiple AI models" />
|
||||
<title>Hanzo Chat</title>
|
||||
<link rel="icon" type="image/svg+xml" href="assets/logo.svg" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" sizes="any" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="assets/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="assets/favicon-16x16.png" />
|
||||
<link rel="apple-touch-icon" href="assets/apple-touch-icon-180x180.png" />
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"@codesandbox/sandpack-react": "^2.19.10",
|
||||
"@dicebear/collection": "^9.2.2",
|
||||
"@dicebear/core": "^9.2.2",
|
||||
"@hanzo/iam": "^0.13.1",
|
||||
"@hanzo/iam": "^0.4.0",
|
||||
"@headlessui/react": "^2.1.2",
|
||||
"@librechat/client": "workspace:*",
|
||||
"@marsidev/react-turnstile": "^1.1.0",
|
||||
|
||||
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 632 B After Width: | Height: | Size: 229 B |
|
Before Width: | Height: | Size: 829 B After Width: | Height: | Size: 366 B |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 2.2 KiB |
@@ -1,8 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 67 67" role="img" aria-label="Hanzo">
|
||||
<style>path{fill:#000}@media (prefers-color-scheme:dark){path{fill:#fff}}</style>
|
||||
<path d="M22.21 67V44.6369H0V67H22.21Z"/>
|
||||
<path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z"/>
|
||||
<path d="M22.21 0H0V22.3184H22.21V0Z"/>
|
||||
<path d="M66.7198 0H44.5098V22.3184H66.7198V0Z"/>
|
||||
<path d="M66.7198 67V44.6369H44.5098V67H66.7198Z"/>
|
||||
</svg>
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="#f2f2f2"><path d="M3 2 H7 V10 H17 V2 H21 V22 H17 V14 H7 V22 H3 Z"/></svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 443 B After Width: | Height: | Size: 140 B |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 15 KiB |
@@ -4,8 +4,7 @@ async function postBuild() {
|
||||
try {
|
||||
await fs.copy('public/assets', 'dist/assets');
|
||||
await fs.copy('public/robots.txt', 'dist/robots.txt');
|
||||
await fs.copy('public/favicon.ico', 'dist/favicon.ico');
|
||||
console.log('✅ PWA icons, favicon.ico, and robots.txt copied successfully. Glob pattern warnings resolved.');
|
||||
console.log('✅ PWA icons and robots.txt copied successfully. Glob pattern warnings resolved.');
|
||||
} catch (err) {
|
||||
console.error('❌ Error copying files:', err);
|
||||
process.exit(1);
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { lazy, Suspense, useEffect } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { RecoilRoot } from 'recoil';
|
||||
import { DndProvider } from 'react-dnd';
|
||||
import { RouterProvider } from 'react-router-dom';
|
||||
import * as RadixToast from '@radix-ui/react-toast';
|
||||
import { HTML5Backend } from 'react-dnd-html5-backend';
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||
import { Toast, ThemeProvider, ToastProvider } from '@librechat/client';
|
||||
import { QueryClient, QueryClientProvider, QueryCache } from '@tanstack/react-query';
|
||||
import { ScreenshotProvider, useApiErrorBoundary } from './hooks';
|
||||
@@ -13,13 +14,6 @@ import { initializeFontSize } from '~/store/fontSize';
|
||||
import { LiveAnnouncer } from '~/a11y';
|
||||
import { router } from './routes';
|
||||
|
||||
// Dev-only: lazily loaded so the devtools bundle never ships in production builds.
|
||||
const ReactQueryDevtools = import.meta.env.DEV
|
||||
? lazy(() =>
|
||||
import('@tanstack/react-query-devtools').then((m) => ({ default: m.ReactQueryDevtools })),
|
||||
)
|
||||
: null;
|
||||
|
||||
const App = () => {
|
||||
const { setError } = useApiErrorBoundary();
|
||||
|
||||
@@ -69,11 +63,7 @@ const App = () => {
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
<RouterProvider router={router} />
|
||||
<WakeLockManager />
|
||||
{import.meta.env.DEV && ReactQueryDevtools && (
|
||||
<Suspense fallback={null}>
|
||||
<ReactQueryDevtools initialIsOpen={false} position="top-right" />
|
||||
</Suspense>
|
||||
)}
|
||||
<ReactQueryDevtools initialIsOpen={false} position="top-right" />
|
||||
<Toast />
|
||||
<RadixToast.Viewport className="pointer-events-none fixed inset-0 z-[1000] mx-auto my-2 flex max-w-[560px] flex-col items-stretch justify-start md:pb-5" />
|
||||
</DndProvider>
|
||||
|
||||
@@ -453,7 +453,6 @@ export type TAuthContext = {
|
||||
user: t.TUser | undefined;
|
||||
token: string | undefined;
|
||||
isAuthenticated: boolean;
|
||||
isGuest: boolean;
|
||||
error: string | undefined;
|
||||
login: (data: t.TLoginUser) => void;
|
||||
logout: (redirect?: string) => void;
|
||||
|
||||
@@ -57,7 +57,7 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Set page title
|
||||
useDocumentTitle(`${localize('com_agents_marketplace')} | Hanzo Chat`);
|
||||
useDocumentTitle(`${localize('com_agents_marketplace')} | LibreChat`);
|
||||
|
||||
// Ensure right sidebar is always visible in marketplace
|
||||
useEffect(() => {
|
||||
|
||||
@@ -197,7 +197,7 @@ describe('AgentCard', () => {
|
||||
expect(avatarImg).toHaveAttribute('src', '/string-avatar.png');
|
||||
});
|
||||
|
||||
it('displays the Hanzo mark fallback when no avatar is provided', () => {
|
||||
it('displays Feather icon fallback when no avatar is provided', () => {
|
||||
const agentWithoutAvatar = {
|
||||
...mockAgent,
|
||||
avatar: undefined,
|
||||
@@ -209,9 +209,9 @@ describe('AgentCard', () => {
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
// The fallback renders the Hanzo H-mark (aria-label="Hanzo")
|
||||
const hanzoMark = document.querySelector('[aria-label="Hanzo"]');
|
||||
expect(hanzoMark).toBeInTheDocument();
|
||||
// Check for Feather icon presence by looking for the svg with lucide-feather class
|
||||
const featherIcon = document.querySelector('.lucide-feather');
|
||||
expect(featherIcon).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('card is clickable and has dialog trigger', () => {
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { OGDialog, OGDialogTemplate, Button } from '@librechat/client';
|
||||
import { getHanzoIamSdk, isStaticIamMode } from '~/utils/iam';
|
||||
import { useGetStartupConfig } from '~/data-provider';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
/**
|
||||
* Login gate shown when an anonymous guest exhausts the free message quota.
|
||||
* Reuses the existing OpenID / Hanzo IAM login flow — it does not implement a
|
||||
* new login. Listens for the `guestLimitReached` window event dispatched by the
|
||||
* chat submission path on a `402 { type: 'GUEST_LIMIT' }` response.
|
||||
*/
|
||||
export default function GuestLimitDialog() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const localize = useLocalize();
|
||||
const { data: startupConfig } = useGetStartupConfig();
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => setOpen(true);
|
||||
window.addEventListener('guestLimitReached', handler);
|
||||
return () => window.removeEventListener('guestLimitReached', handler);
|
||||
}, []);
|
||||
|
||||
const handleLogin = useCallback(() => {
|
||||
const iamSdk = getHanzoIamSdk();
|
||||
if (isStaticIamMode() && iamSdk) {
|
||||
iamSdk.signinRedirect();
|
||||
return;
|
||||
}
|
||||
if (startupConfig?.openidLoginEnabled && startupConfig?.serverDomain) {
|
||||
window.location.href = `${startupConfig.serverDomain}/oauth/openid`;
|
||||
return;
|
||||
}
|
||||
window.location.href = '/login';
|
||||
}, [startupConfig]);
|
||||
|
||||
return (
|
||||
<OGDialog open={open} onOpenChange={setOpen}>
|
||||
<OGDialogTemplate
|
||||
title={localize('com_auth_guest_limit_title')}
|
||||
className="max-w-md"
|
||||
main={
|
||||
<div className="text-sm text-text-secondary">
|
||||
{localize('com_auth_guest_limit_message')}
|
||||
</div>
|
||||
}
|
||||
buttons={
|
||||
<Button variant="submit" onClick={handleLogin}>
|
||||
{localize('com_auth_guest_limit_login')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</OGDialog>
|
||||
);
|
||||
}
|
||||
@@ -34,8 +34,8 @@ export default function OAuthCallback() {
|
||||
try {
|
||||
const tokens = await iamSdk.handleCallback();
|
||||
|
||||
if (tokens.accessToken) {
|
||||
setTokenHeader(tokens.accessToken);
|
||||
if (tokens.access_token) {
|
||||
setTokenHeader(tokens.access_token);
|
||||
}
|
||||
|
||||
navigate('/c/new', { replace: true });
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
import { useState, useRef, useEffect, useMemo, memo } from 'react';
|
||||
import { AutoSizer, List } from 'react-virtualized';
|
||||
import { Spinner, useCombobox } from '@librechat/client';
|
||||
import { useSetRecoilState, useRecoilValue } from 'recoil';
|
||||
import type { MentionOption } from '~/common';
|
||||
import { useListCloudAgentsQuery } from '~/data-provider';
|
||||
import { removeCharIfLast } from '~/utils';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import MentionItem from './MentionItem';
|
||||
import store from '~/store';
|
||||
|
||||
const commandChar = '/';
|
||||
const ROW_HEIGHT = 44;
|
||||
|
||||
/**
|
||||
* `/agent` command popover — lists the caller's canonical Hanzo Cloud agents
|
||||
* (`/v1/agents`) as they type `/agent …`. Selecting one sets the composer to
|
||||
* `/agent <name> ` so the user can add a prompt; submitting runs the agent (the
|
||||
* run is intercepted in ChatForm and dispatched through the ONE run path,
|
||||
* useRunCloudAgent). Structurally mirrors PromptsCommand to stay DRY.
|
||||
*/
|
||||
function AgentsCommand({
|
||||
index,
|
||||
textAreaRef,
|
||||
}: {
|
||||
index: number;
|
||||
textAreaRef: React.MutableRefObject<HTMLTextAreaElement | null>;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const setShowAgentsPopover = useSetRecoilState(store.showAgentsPopoverFamily(index));
|
||||
const showAgentsPopover = useRecoilValue(store.showAgentsPopoverFamily(index));
|
||||
|
||||
const { data, isLoading } = useListCloudAgentsQuery({ enabled: showAgentsPopover });
|
||||
|
||||
const options = useMemo<MentionOption[]>(
|
||||
() =>
|
||||
(data?.agents ?? []).map((agent) => ({
|
||||
type: 'cloudAgent',
|
||||
value: agent.name,
|
||||
label: agent.name,
|
||||
description: agent.description || agent.model,
|
||||
})),
|
||||
[data],
|
||||
);
|
||||
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const { open, setOpen, searchValue, setSearchValue, matches } = useCombobox({
|
||||
value: '',
|
||||
options,
|
||||
});
|
||||
|
||||
const handleSelect = (mention?: MentionOption) => {
|
||||
if (!mention) {
|
||||
return;
|
||||
}
|
||||
setSearchValue('');
|
||||
setOpen(false);
|
||||
setShowAgentsPopover(false);
|
||||
|
||||
const el = textAreaRef.current;
|
||||
if (el) {
|
||||
// Replace the in-progress command with `/agent <name> ` and keep focus so
|
||||
// the user can type a prompt, then Enter to run.
|
||||
removeCharIfLast(el, commandChar);
|
||||
el.value = `/agent ${mention.value} `;
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
el.focus();
|
||||
el.setSelectionRange(el.value.length, el.value.length);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setActiveIndex(0);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const currentActiveItem = document.getElementById(`agent-item-${activeIndex}`);
|
||||
currentActiveItem?.scrollIntoView({ behavior: 'instant', block: 'nearest' });
|
||||
}, [activeIndex]);
|
||||
|
||||
if (!showAgentsPopover) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rowRenderer = ({
|
||||
index: rowIndex,
|
||||
key,
|
||||
style,
|
||||
}: {
|
||||
index: number;
|
||||
key: string;
|
||||
style: React.CSSProperties;
|
||||
}) => {
|
||||
const mention = matches[rowIndex] as MentionOption;
|
||||
return (
|
||||
<MentionItem
|
||||
index={rowIndex}
|
||||
type="agent"
|
||||
key={key}
|
||||
style={style}
|
||||
onClick={() => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
timeoutRef.current = null;
|
||||
handleSelect(mention);
|
||||
}}
|
||||
name={mention.label ?? ''}
|
||||
icon={mention.icon}
|
||||
description={mention.description}
|
||||
isActive={rowIndex === activeIndex}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-28 z-10 w-full space-y-2">
|
||||
<div className="popover border-token-border-light rounded-2xl border bg-surface-tertiary-alt p-2 shadow-lg">
|
||||
<input
|
||||
// Focus transitions to the input field when the popover opens.
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus
|
||||
ref={inputRef}
|
||||
placeholder={localize('com_agents_cloud_command_placeholder')}
|
||||
className="mb-1 w-full border-0 bg-surface-tertiary-alt p-2 text-sm focus:outline-none dark:text-gray-200"
|
||||
autoComplete="off"
|
||||
value={searchValue}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
setOpen(false);
|
||||
setShowAgentsPopover(false);
|
||||
textAreaRef.current?.focus();
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
setActiveIndex((prev) => (prev + 1) % Math.max(matches.length, 1));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
setActiveIndex((prev) => (prev - 1 + matches.length) % Math.max(matches.length, 1));
|
||||
} else if (e.key === 'Enter' || e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
handleSelect(matches[activeIndex] as MentionOption | undefined);
|
||||
} else if (e.key === 'Backspace' && searchValue === '') {
|
||||
setOpen(false);
|
||||
setShowAgentsPopover(false);
|
||||
textAreaRef.current?.focus();
|
||||
}
|
||||
}}
|
||||
onChange={(e) => setSearchValue(e.target.value)}
|
||||
onFocus={() => setOpen(true)}
|
||||
onBlur={() => {
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setOpen(false);
|
||||
setShowAgentsPopover(false);
|
||||
}, 150);
|
||||
}}
|
||||
/>
|
||||
<div className="max-h-40 overflow-y-auto">
|
||||
{isLoading && open ? (
|
||||
<div className="flex h-32 items-center justify-center text-text-primary">
|
||||
<Spinner />
|
||||
</div>
|
||||
) : null}
|
||||
{!isLoading && open ? (
|
||||
<div className="max-h-40">
|
||||
<AutoSizer disableHeight>
|
||||
{({ width }) => (
|
||||
<List
|
||||
width={width}
|
||||
overscanRowCount={5}
|
||||
rowHeight={ROW_HEIGHT}
|
||||
rowCount={matches.length}
|
||||
rowRenderer={rowRenderer}
|
||||
scrollToIndex={activeIndex}
|
||||
height={Math.min(matches.length * ROW_HEIGHT, 160)}
|
||||
/>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(AgentsCommand);
|
||||
@@ -1,7 +1,7 @@
|
||||
import { memo, useRef, useMemo, useEffect, useState, useCallback } from 'react';
|
||||
import { useWatch } from 'react-hook-form';
|
||||
import { TextareaAutosize } from '@librechat/client';
|
||||
import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil';
|
||||
import { useRecoilState, useRecoilValue } from 'recoil';
|
||||
import { Constants, isAssistantsEndpoint, isAgentsEndpoint } from 'librechat-data-provider';
|
||||
import {
|
||||
useChatContext,
|
||||
@@ -19,14 +19,12 @@ import {
|
||||
useSubmitMessage,
|
||||
useFocusChatEffect,
|
||||
} from '~/hooks';
|
||||
import { useRunCloudAgent } from '~/hooks/Agents';
|
||||
import { mainTextareaId, BadgeItem } from '~/common';
|
||||
import AttachFileChat from './Files/AttachFileChat';
|
||||
import FileFormChat from './Files/FileFormChat';
|
||||
import { cn, removeFocusRings, parseAgentCommand } from '~/utils';
|
||||
import { cn, removeFocusRings } from '~/utils';
|
||||
import TextareaHeader from './TextareaHeader';
|
||||
import PromptsCommand from './PromptsCommand';
|
||||
import AgentsCommand from './AgentsCommand';
|
||||
import AudioRecorder from './AudioRecorder';
|
||||
import CollapseChat from './CollapseChat';
|
||||
import StreamAudio from './StreamAudio';
|
||||
@@ -64,7 +62,6 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
|
||||
const [showMentionPopover, setShowMentionPopover] = useRecoilState(
|
||||
store.showMentionPopoverFamily(index),
|
||||
);
|
||||
const setShowAgentsPopover = useSetRecoilState(store.showAgentsPopoverFamily(index));
|
||||
|
||||
const { requiresKey } = useRequiresKey();
|
||||
const methods = useChatFormContext();
|
||||
@@ -132,26 +129,6 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
|
||||
});
|
||||
|
||||
const { submitMessage, submitPrompt } = useSubmitMessage();
|
||||
const runCloudAgent = useRunCloudAgent();
|
||||
|
||||
/**
|
||||
* Submit handler that intercepts the `/agent <name> [prompt]` command and
|
||||
* dispatches it through the single cloud-agent run path; everything else is a
|
||||
* normal chat message. This is the ONE place the command is turned into a run.
|
||||
*/
|
||||
const onSubmit = useCallback(
|
||||
(data?: { text: string }) => {
|
||||
const command = parseAgentCommand(data?.text ?? '');
|
||||
if (command) {
|
||||
methods.reset();
|
||||
setShowAgentsPopover(false);
|
||||
void runCloudAgent(command.name, command.prompt);
|
||||
return;
|
||||
}
|
||||
submitMessage(data);
|
||||
},
|
||||
[methods, runCloudAgent, submitMessage, setShowAgentsPopover],
|
||||
);
|
||||
|
||||
const handleKeyUp = useHandleKeyUp({
|
||||
index,
|
||||
@@ -226,7 +203,7 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={methods.handleSubmit(onSubmit)}
|
||||
onSubmit={methods.handleSubmit(submitMessage)}
|
||||
className={cn(
|
||||
'mx-auto flex w-full flex-row gap-3 transition-[max-width] duration-300 sm:px-2',
|
||||
maximizeChatSpace ? 'max-w-full' : 'md:max-w-3xl xl:max-w-4xl',
|
||||
@@ -260,7 +237,6 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
|
||||
/>
|
||||
)}
|
||||
<PromptsCommand index={index} textAreaRef={textAreaRef} submitPrompt={submitPrompt} />
|
||||
<AgentsCommand index={index} textAreaRef={textAreaRef} />
|
||||
<div
|
||||
onClick={handleContainerClick}
|
||||
className={cn(
|
||||
|
||||
@@ -5,18 +5,6 @@ import { useGetAssistantDocsQuery, useGetEndpointsQuery } from '~/data-provider'
|
||||
import { getIconEndpoint, getEntity } from '~/utils';
|
||||
import { useSubmitMessage } from '~/hooks';
|
||||
|
||||
/**
|
||||
* Curated fallback starters for plain-model chats (no agent/assistant-specific
|
||||
* starters). Keeps the empty state from being bare and matches the ChatGPT/Claude
|
||||
* suggestion-chip pattern. Category-style so submitting reads naturally.
|
||||
*/
|
||||
const DEFAULT_STARTERS = [
|
||||
'Help me write or refine something',
|
||||
'Explain a concept clearly',
|
||||
'Review, debug, or improve some code',
|
||||
'Brainstorm ideas and a plan',
|
||||
];
|
||||
|
||||
const ConversationStarters = () => {
|
||||
const { conversation } = useChatContext();
|
||||
const agentsMap = useAgentsMapContext();
|
||||
@@ -52,18 +40,11 @@ const ConversationStarters = () => {
|
||||
return entity.conversation_starters;
|
||||
}
|
||||
|
||||
// Agents may intentionally omit starters — honor that (no defaults).
|
||||
if (isAgent) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const docStarters = documentsMap.get(entity?.id ?? '')?.conversation_starters;
|
||||
if (docStarters?.length) {
|
||||
return docStarters;
|
||||
}
|
||||
|
||||
// Plain-model chat: fall back to curated defaults so the empty state isn't bare.
|
||||
return DEFAULT_STARTERS;
|
||||
return documentsMap.get(entity?.id ?? '')?.conversation_starters ?? [];
|
||||
}, [documentsMap, isAgent, entity]);
|
||||
|
||||
const { submitMessage } = useSubmitMessage();
|
||||
|
||||
@@ -93,7 +93,7 @@ export default function MCPConfigDialog({
|
||||
}
|
||||
selection={{
|
||||
selectHandler: handleSubmit(onFormSubmit),
|
||||
selectClasses: 'bg-primary hover:bg-primary/90 text-primary-foreground',
|
||||
selectClasses: 'bg-green-500 hover:bg-green-600 text-white',
|
||||
selectText: isSubmitting ? localize('com_ui_saving') : localize('com_ui_save'),
|
||||
}}
|
||||
buttons={
|
||||
|
||||
@@ -77,22 +77,6 @@ export default function Mention({
|
||||
}
|
||||
};
|
||||
|
||||
if (mention.type === 'cloudAgent') {
|
||||
// Arm the `/agent <name> ` command so the run flows through the single
|
||||
// cloud-agent run path (intercepted on submit in ChatForm).
|
||||
setSearchValue('');
|
||||
setOpen(false);
|
||||
setShowMentionPopover(false);
|
||||
const el = textAreaRef.current;
|
||||
if (el) {
|
||||
removeCharIfLast(el, commandChar);
|
||||
el.value = `/agent ${mention.value} `;
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
el.focus();
|
||||
el.setSelectionRange(el.value.length, el.value.length);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (mention.type === 'endpoint' && mention.value === EModelEndpoint.agents) {
|
||||
setSearchValue('');
|
||||
setInputOptions(agentsList ?? []);
|
||||
|
||||
@@ -6,7 +6,7 @@ export interface MentionItemProps {
|
||||
name: string;
|
||||
onClick: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
index: number;
|
||||
type?: 'prompt' | 'mention' | 'add-convo' | 'agent';
|
||||
type?: 'prompt' | 'mention' | 'add-convo';
|
||||
icon?: React.ReactNode;
|
||||
isActive?: boolean;
|
||||
description?: string;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { memo } from 'react';
|
||||
import HanzoLogoIcon from '~/components/svg/HanzoLogoIcon';
|
||||
import ZenLogoIcon from '~/components/svg/ZenLogoIcon';
|
||||
import { Feather } from 'lucide-react';
|
||||
import { EModelEndpoint, isAssistantsEndpoint, alternateName } from 'librechat-data-provider';
|
||||
import {
|
||||
Plugin,
|
||||
@@ -12,6 +11,7 @@ import {
|
||||
AssistantIcon,
|
||||
AnthropicIcon,
|
||||
AzureMinimalIcon,
|
||||
CustomMinimalIcon,
|
||||
} from '@librechat/client';
|
||||
import UnknownIcon from '~/hooks/Endpoint/UnknownIcon';
|
||||
import { IconProps } from '~/common';
|
||||
@@ -110,7 +110,7 @@ const MessageEndpointIcon: React.FC<IconProps> = (props) => {
|
||||
) : (
|
||||
<div className="h-6 w-6">
|
||||
<div className="shadow-stroke flex h-6 w-6 items-center justify-center overflow-hidden rounded-full">
|
||||
<HanzoLogoIcon className="h-2/3 w-2/3 text-gray-400" aria-hidden="true" />
|
||||
<Feather className="h-2/3 w-2/3 text-gray-400" aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
@@ -148,10 +148,8 @@ const MessageEndpointIcon: React.FC<IconProps> = (props) => {
|
||||
name: alternateName[EModelEndpoint.bedrock],
|
||||
},
|
||||
[EModelEndpoint.custom]: {
|
||||
// hanzo.chat's custom endpoint is Zen-only (Hanzo AI zen* models) — brand it
|
||||
// with the ensō mark instead of the generic LibreChat "custom" glyph.
|
||||
icon: <ZenLogoIcon size={size * 0.72} className="text-white" />,
|
||||
name: 'Zen',
|
||||
icon: <CustomMinimalIcon size={size * 0.7} />,
|
||||
name: 'Custom',
|
||||
},
|
||||
null: { icon: <GPTIcon size={size * 0.7} />, bg: 'grey', name: 'N/A' },
|
||||
default: {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import HanzoLogoIcon from '~/components/svg/HanzoLogoIcon';
|
||||
import { Feather } from 'lucide-react';
|
||||
import { EModelEndpoint, alternateName } from 'librechat-data-provider';
|
||||
import {
|
||||
Sparkles,
|
||||
@@ -43,7 +43,7 @@ const MinimalIcon: React.FC<IconProps> = (props) => {
|
||||
[EModelEndpoint.assistants]: { icon: <Sparkles className="icon-sm" />, name: 'Assistant' },
|
||||
[EModelEndpoint.azureAssistants]: { icon: <Sparkles className="icon-sm" />, name: 'Assistant' },
|
||||
[EModelEndpoint.agents]: {
|
||||
icon: <HanzoLogoIcon className="icon-sm" aria-hidden="true" />,
|
||||
icon: <Feather className="icon-sm" aria-hidden="true" />,
|
||||
name: props.modelLabel ?? alternateName[EModelEndpoint.agents],
|
||||
},
|
||||
[EModelEndpoint.bedrock]: {
|
||||
|
||||
@@ -83,7 +83,7 @@ const SaveAsPresetDialog = ({ open, onOpenChange, preset }: TEditPresetProps) =>
|
||||
}
|
||||
selection={{
|
||||
selectHandler: submitPreset,
|
||||
selectClasses: 'bg-primary hover:bg-primary/90 text-primary-foreground',
|
||||
selectClasses: 'bg-green-500 hover:bg-green-600 dark:hover:bg-green-600 text-white',
|
||||
selectText: localize('com_ui_save'),
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -51,7 +51,7 @@ export default function FileListItem2({
|
||||
return (
|
||||
<span
|
||||
key={index}
|
||||
className="ml-2 mt-1 content-center rounded-full bg-surface-secondary px-2 text-xs text-text-secondary"
|
||||
className="ml-2 mt-1 content-center rounded-full bg-[#f2f8ec] px-2 text-xs text-[#91c561]"
|
||||
>
|
||||
{vectorStore.name}
|
||||
</span>
|
||||
|
||||
@@ -84,7 +84,7 @@ export default function FilePreview() {
|
||||
Status
|
||||
</span>
|
||||
<div className="w-1/2 sm:w-3/4 md:w-3/5">
|
||||
<span className="flex w-20 flex-row items-center justify-evenly rounded-full bg-surface-secondary p-1 text-text-secondary">
|
||||
<span className="flex w-20 flex-row items-center justify-evenly rounded-full bg-[#f2f8ec] p-1 text-[#91c561]">
|
||||
<CheckMark className="m-0 p-0" />
|
||||
<div>{file.object}</div>
|
||||
</span>
|
||||
|
||||
@@ -88,7 +88,7 @@ export const fileTableColumns: ColumnDef<TFile>[] = [
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<span key={index} className="ml-2 mt-2 rounded-full bg-surface-secondary px-2 text-text-secondary">
|
||||
<span key={index} className="ml-2 mt-2 rounded-full bg-[#f2f8ec] px-2 text-[#91c561]">
|
||||
{vectorStore.name}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -139,7 +139,7 @@ export default function VectorStorePreview() {
|
||||
</span>
|
||||
<div className="w-1/2 md:w-3/5">
|
||||
<p className="text-gray-500">
|
||||
<span className="text-text-secondary">0 KB hours</span>
|
||||
<span className="text-[#91c561]">0 KB hours</span>
|
||||
Free until end of 2024
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -13,9 +13,9 @@ const colors = {
|
||||
mutedFg: 'hsla(0, 0%, 70%, 0.85)',
|
||||
border: 'hsla(0, 0%, 40%, 0.2)',
|
||||
fg: 'hsl(0, 0%, 96%)',
|
||||
brand: '#ffffff',
|
||||
brandDim: 'rgba(255, 255, 255, 0.10)',
|
||||
brandGlow: 'rgba(255, 255, 255, 0.06)',
|
||||
brand: '#fd4444',
|
||||
brandDim: 'rgba(253, 68, 68, 0.10)',
|
||||
brandGlow: 'rgba(253, 68, 68, 0.04)',
|
||||
secondary: '#1f1f1f',
|
||||
} as const;
|
||||
|
||||
@@ -97,7 +97,7 @@ const features = [
|
||||
{
|
||||
title: '100+ Models',
|
||||
description:
|
||||
'The Zen model family plus Claude, GPT-5, Gemini, and more. Every major provider through one interface.',
|
||||
'Claude, GPT-5, DeepSeek, Qwen, and more. Every major provider through one interface.',
|
||||
Icon: IconCube,
|
||||
},
|
||||
{
|
||||
@@ -121,12 +121,10 @@ const zenModels = [
|
||||
{ name: 'zen3-omni', description: 'Multimodal vision and text', context: '128K', params: '32B' },
|
||||
];
|
||||
|
||||
// Independent third-party providers offered via the gateway. NO upstream
|
||||
// base-model names that would leak the Zen family's provenance (brand policy:
|
||||
// no raw Qwen/DeepSeek/Kimi/Llama/HuggingFace names).
|
||||
const thirdPartyModels = [
|
||||
'GPT-5', 'Claude Opus 4', 'Gemini 2.5', 'Grok',
|
||||
'Mistral Large', 'Command R+',
|
||||
'GPT-5', 'Claude Opus 4', 'Gemini 2.5', 'DeepSeek R1',
|
||||
'Qwen3', 'Llama 4', 'Mistral Large', 'Command R+',
|
||||
'Grok', 'Phi-4',
|
||||
];
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
@@ -154,7 +152,7 @@ export default function LandingPage() {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen selection:bg-white/20"
|
||||
className="min-h-screen selection:bg-[#fd4444]/30"
|
||||
style={{
|
||||
backgroundColor: colors.bg,
|
||||
color: colors.fg,
|
||||
@@ -171,7 +169,7 @@ export default function LandingPage() {
|
||||
>
|
||||
<div className="mx-auto flex max-w-[1400px] items-center justify-between px-6 py-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<HanzoLogo className="size-5 text-white" />
|
||||
<HanzoLogo className="size-5 text-[#fd4444]" />
|
||||
<span className="text-sm font-bold tracking-tight">Hanzo Chat</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -201,7 +199,7 @@ export default function LandingPage() {
|
||||
href={loginHref}
|
||||
onClick={handleLoginClick}
|
||||
className="rounded-full px-5 py-2 text-sm font-medium tracking-tight transition-colors"
|
||||
style={{ backgroundColor: colors.brand, color: '#000' }}
|
||||
style={{ backgroundColor: colors.brand, color: '#fff' }}
|
||||
onMouseOver={(e) => (e.currentTarget.style.filter = 'brightness(1.15)')}
|
||||
onMouseOut={(e) => (e.currentTarget.style.filter = 'none')}
|
||||
>
|
||||
@@ -217,7 +215,7 @@ export default function LandingPage() {
|
||||
className="relative flex min-h-[600px] flex-col overflow-hidden rounded-2xl"
|
||||
style={{
|
||||
border: `1px solid ${colors.border}`,
|
||||
background: `linear-gradient(135deg, rgba(255,255,255,0.08) 0%, transparent 50%, rgba(255,255,255,0.04) 100%)`,
|
||||
background: `linear-gradient(135deg, rgba(253,68,68,0.08) 0%, transparent 50%, rgba(253,68,68,0.04) 100%)`,
|
||||
}}
|
||||
>
|
||||
{/* Grid pattern overlay */}
|
||||
@@ -234,7 +232,7 @@ export default function LandingPage() {
|
||||
<div
|
||||
className="flex w-fit items-center gap-2 rounded-full px-3 py-1.5 text-xs font-medium"
|
||||
style={{
|
||||
border: `1px solid rgba(255,255,255,0.5)`,
|
||||
border: `1px solid rgba(253,68,68,0.5)`,
|
||||
color: colors.brand,
|
||||
}}
|
||||
>
|
||||
@@ -260,7 +258,7 @@ export default function LandingPage() {
|
||||
href={loginHref}
|
||||
onClick={handleLoginClick}
|
||||
className="inline-flex items-center justify-center rounded-full px-5 py-3 text-sm font-medium tracking-tight transition-colors"
|
||||
style={{ backgroundColor: colors.brand, color: '#000' }}
|
||||
style={{ backgroundColor: colors.brand, color: '#fff' }}
|
||||
onMouseOver={(e) => (e.currentTarget.style.filter = 'brightness(1.15)')}
|
||||
onMouseOut={(e) => (e.currentTarget.style.filter = 'none')}
|
||||
>
|
||||
@@ -365,8 +363,8 @@ zen4-coder: I'll help you refactor the auth module.
|
||||
<div
|
||||
className="rounded-2xl p-6 text-sm shadow-lg md:p-8"
|
||||
style={{
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.06)',
|
||||
border: `1px solid rgba(255, 255, 255, 0.15)`,
|
||||
backgroundColor: 'rgba(253, 68, 68, 0.06)',
|
||||
border: `1px solid rgba(253, 68, 68, 0.15)`,
|
||||
}}
|
||||
>
|
||||
<IconBolt className="mb-4 size-8" style={{ color: colors.brand }} />
|
||||
@@ -385,7 +383,7 @@ zen4-coder: I'll help you refactor the auth module.
|
||||
border: `1px solid ${colors.border}`,
|
||||
backgroundColor: 'rgba(0,0,0,0.2)',
|
||||
}}
|
||||
onMouseOver={(e) => (e.currentTarget.style.borderColor = 'rgba(255, 255, 255, 0.3)')}
|
||||
onMouseOver={(e) => (e.currentTarget.style.borderColor = 'rgba(253, 68, 68, 0.3)')}
|
||||
onMouseOut={(e) => (e.currentTarget.style.borderColor = colors.border)}
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
@@ -482,11 +480,11 @@ zen4-coder: I'll help you refactor the auth module.
|
||||
<div
|
||||
className="rounded-xl p-6"
|
||||
style={{
|
||||
border: `1px solid rgba(255, 255, 255, 0.3)`,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.04)',
|
||||
border: `1px solid rgba(253, 68, 68, 0.3)`,
|
||||
backgroundColor: 'rgba(253, 68, 68, 0.04)',
|
||||
}}
|
||||
>
|
||||
<p className="mb-2 text-xs font-medium uppercase tracking-wider" style={{ color: 'rgba(255, 255, 255, 0.7)' }}>
|
||||
<p className="mb-2 text-xs font-medium uppercase tracking-wider" style={{ color: 'rgba(253, 68, 68, 0.7)' }}>
|
||||
Free credit
|
||||
</p>
|
||||
<p className="text-3xl font-bold">$5</p>
|
||||
@@ -530,7 +528,7 @@ zen4-coder: I'll help you refactor the auth module.
|
||||
href={loginHref}
|
||||
onClick={handleLoginClick}
|
||||
className="inline-flex items-center justify-center rounded-full px-5 py-3 text-sm font-medium tracking-tight transition-colors"
|
||||
style={{ backgroundColor: colors.brand, color: '#000' }}
|
||||
style={{ backgroundColor: colors.brand, color: '#fff' }}
|
||||
onMouseOver={(e) => (e.currentTarget.style.filter = 'brightness(1.15)')}
|
||||
onMouseOut={(e) => (e.currentTarget.style.filter = 'none')}
|
||||
>
|
||||
|
||||
@@ -125,12 +125,11 @@ const errorMessages = {
|
||||
);
|
||||
}
|
||||
|
||||
// Commerce insufficient balance — new users claim the $5 starter credit,
|
||||
// spent-out users add funds. Both live at billing.hanzo.ai.
|
||||
// Commerce insufficient balance
|
||||
if (reason === 'commerce_insufficient') {
|
||||
return (
|
||||
<>
|
||||
{'You have no Hanzo Cloud balance. Claim your $5 starter credit (or add funds) to start chatting.'}
|
||||
{'Your account balance is empty. Please add funds to continue.'}
|
||||
<br />
|
||||
<br />
|
||||
<a
|
||||
@@ -139,18 +138,12 @@ const errorMessages = {
|
||||
rel="noopener noreferrer"
|
||||
className="text-hanzo-red underline"
|
||||
>
|
||||
Claim your $5 credit at billing.hanzo.ai
|
||||
Add funds to your Hanzo account
|
||||
</a>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Commerce unreachable — fail closed (we block rather than bleed). The user
|
||||
// may well be funded; do NOT tell them to add funds.
|
||||
if (reason === 'commerce_unavailable') {
|
||||
return 'Billing is temporarily unavailable, so we could not verify your balance. Please try again in a moment.';
|
||||
}
|
||||
|
||||
// Convert tokenCredits to USD: 1,000,000 tokenCredits = $1 USD
|
||||
const balanceUsd = (balance / 1000000).toFixed(4);
|
||||
const costUsd = (tokenCost / 1000000).toFixed(4);
|
||||
|
||||
@@ -80,7 +80,7 @@ describe('About', () => {
|
||||
expect(mockCopy).toHaveBeenCalledTimes(1);
|
||||
const [blob, options] = mockCopy.mock.calls[0] as [string, { format: string }];
|
||||
expect(options).toEqual({ format: 'text/plain' });
|
||||
expect(blob).toContain(`Hanzo Chat version: ${Constants.VERSION}`);
|
||||
expect(blob).toContain(`LibreChat version: ${Constants.VERSION}`);
|
||||
expect(blob).toContain(`Commit: ${populatedBuildInfo.commit}`);
|
||||
expect(blob).toContain(`Branch: ${populatedBuildInfo.branch}`);
|
||||
expect(blob).toContain('Build date: 2026-04-20 12:00:00 UTC');
|
||||
|
||||
@@ -27,7 +27,7 @@ function buildDiagnosticsBlob(
|
||||
buildInfo: TStartupConfig['buildInfo'] | undefined,
|
||||
): string {
|
||||
const lines: string[] = [
|
||||
`Hanzo Chat version: ${version}`,
|
||||
`LibreChat version: ${version}`,
|
||||
`Commit: ${buildInfo?.commit ?? UNKNOWN_PLACEHOLDER}`,
|
||||
`Branch: ${buildInfo?.branch ?? UNKNOWN_PLACEHOLDER}`,
|
||||
`Build date: ${formatBuildDate(buildInfo?.buildDate)}`,
|
||||
|
||||
@@ -274,7 +274,7 @@ export default function ActionsInput({
|
||||
<button
|
||||
disabled={!functions || !functions.length}
|
||||
onClick={saveAction}
|
||||
className="focus:shadow-outline mt-1 flex min-w-[100px] items-center justify-center rounded bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-primary/90 focus:outline-none focus:ring-0 disabled:opacity-50"
|
||||
className="focus:shadow-outline mt-1 flex min-w-[100px] items-center justify-center rounded bg-green-500 px-4 py-2 font-semibold text-white hover:bg-green-400 focus:border-green-500 focus:outline-none focus:ring-0 disabled:bg-green-400"
|
||||
type="button"
|
||||
>
|
||||
{getButtonContent()}
|
||||
|
||||
@@ -180,7 +180,7 @@ export default function AgentConfig() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="h-auto bg-white px-3 pt-3 dark:bg-transparent sm:px-4">
|
||||
<div className="h-auto bg-white px-4 pt-3 dark:bg-transparent">
|
||||
{/* Avatar & Name */}
|
||||
<div className="mb-4">
|
||||
<AgentAvatar avatar={agent?.['avatar'] ?? null} />
|
||||
@@ -206,7 +206,7 @@ export default function AgentConfig() {
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'mt-1 w-full max-w-full break-words text-sm text-red-500',
|
||||
'mt-1 w-56 text-sm text-red-500',
|
||||
errors.name ? 'visible h-auto' : 'invisible h-0',
|
||||
)}
|
||||
role="alert"
|
||||
@@ -360,7 +360,7 @@ export default function AgentConfig() {
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2 flex flex-col gap-2 sm:flex-row sm:gap-2">
|
||||
<div className="mt-2 flex space-x-2">
|
||||
{(toolsEnabled ?? false) && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -121,7 +121,7 @@ export default function AgentFooter({
|
||||
{agent && agent.author === user?.id && <DuplicateAgent agent_id={agent_id} />}
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
className="btn btn-primary focus:shadow-outline flex h-9 w-full items-center justify-center px-4 py-2 font-semibold"
|
||||
className="btn btn-primary focus:shadow-outline flex h-9 w-full items-center justify-center px-4 py-2 font-semibold text-white hover:bg-green-600 focus:border-green-500"
|
||||
type="submit"
|
||||
disabled={isSaving}
|
||||
aria-busy={isSaving}
|
||||
|
||||
@@ -480,7 +480,7 @@ export default function AgentPanel() {
|
||||
<FormProvider {...methods}>
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="scrollbar-gutter-stable h-auto w-full min-w-0 flex-shrink-0 overflow-x-hidden overflow-y-hidden"
|
||||
className="scrollbar-gutter-stable h-auto w-full flex-shrink-0 overflow-y-hidden overflow-x-visible"
|
||||
aria-label="Agent configuration form"
|
||||
>
|
||||
<div className="mx-1 mt-2 flex w-full flex-wrap gap-2">
|
||||
|
||||
@@ -95,7 +95,7 @@ export default function ApiKeyDialog({
|
||||
}
|
||||
selection={{
|
||||
selectHandler: handleSubmit(onSubmit),
|
||||
selectClasses: 'bg-primary hover:bg-primary/90 text-primary-foreground',
|
||||
selectClasses: 'bg-green-500 hover:bg-green-600 text-white',
|
||||
selectText: localize('com_ui_save'),
|
||||
}}
|
||||
buttons={
|
||||
|
||||
@@ -245,7 +245,7 @@ export default function ApiKeyDialog({
|
||||
}
|
||||
selection={{
|
||||
selectHandler: handleSubmit(onSubmit),
|
||||
selectClasses: 'bg-primary hover:bg-primary/90 text-primary-foreground',
|
||||
selectClasses: 'bg-green-500 hover:bg-green-600 text-white',
|
||||
selectText: localize('com_ui_save'),
|
||||
}}
|
||||
buttons={
|
||||
|
||||
@@ -286,7 +286,7 @@ export default function ActionsInput({
|
||||
<button
|
||||
disabled={!functions || !functions.length}
|
||||
onClick={saveAction}
|
||||
className="focus:shadow-outline mt-1 flex min-w-[100px] items-center justify-center rounded bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-primary/90 focus:outline-none focus:ring-0 disabled:opacity-50"
|
||||
className="focus:shadow-outline mt-1 flex min-w-[100px] items-center justify-center rounded bg-green-500 px-4 py-2 font-semibold text-white hover:bg-green-400 focus:border-green-500 focus:outline-none focus:ring-0 disabled:bg-green-400"
|
||||
type="button"
|
||||
>
|
||||
{submitContext()}
|
||||
|
||||
@@ -237,7 +237,7 @@ export default function AssistantPanel({
|
||||
{/* Select Button */}
|
||||
{assistant_id && (
|
||||
<button
|
||||
className="btn btn-primary focus:shadow-outline mx-2 mt-1 h-[40px] rounded px-4 py-2 font-semibold focus:outline-none focus:ring-0"
|
||||
className="btn btn-primary focus:shadow-outline mx-2 mt-1 h-[40px] rounded bg-green-500 px-4 py-2 font-semibold text-white hover:bg-green-400 focus:border-green-500 focus:outline-none focus:ring-0"
|
||||
type="button"
|
||||
disabled={!assistant_id}
|
||||
onClick={(e) => {
|
||||
@@ -460,7 +460,7 @@ export default function AssistantPanel({
|
||||
/>
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
className="btn btn-primary focus:shadow-outline flex w-full items-center justify-center px-4 py-2 font-semibold"
|
||||
className="btn btn-primary focus:shadow-outline flex w-full items-center justify-center px-4 py-2 font-semibold text-white hover:bg-green-600 focus:border-green-500"
|
||||
type="submit"
|
||||
>
|
||||
{submitContext}
|
||||
|
||||