Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .chainlit/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,10 @@ latex = false
auto_tag_thread = true

# Allow users to edit their own messages
edit_message = true
# Off: an edit removed later turns from the screen but not from the model's
# history, so text a reader edited out kept being sent with every turn
# (review, area 1b).
edit_message = false

# Authorize users to spontaneously upload files with messages
[features.spontaneous_file_upload]
Expand Down
33 changes: 32 additions & 1 deletion bin/chat-chainlit.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
# or get_data_layer. Not fixable here; it needs stubs upstream. The file is
# named with a hyphen, so it cannot be listed in [[tool.mypy.overrides]].
import asyncio
import contextlib
import os
import time
from collections.abc import Awaitable, Callable
Expand Down Expand Up @@ -476,7 +477,21 @@ async def on_window_message(message: object) -> None:
# the summary's data loads. Otherwise a question asked meanwhile ran on
# the same thread, and the seed landed beside it and was lost -- while
# the chat still said it was continuing from the summary.
run_as_task(lambda: continue_from_handoff(handoff_id))
# Messages wait for this, below. Running the claim as a task alone did
# not hold them: Chainlit ends its own startup task with task_end,
# which unlocks the box mid-seed whenever the claim arrives first --
# 6 of 6 page loads at 200ms latency (review, area 1b).
seeding = asyncio.Event()
_seeding[session_id()] = seeding

async def claim() -> None:
try:
await continue_from_handoff(handoff_id)
finally:
seeding.set()
_seeding.pop(session_id(), None)

run_as_task(claim)

await cl.send_window_message(acknowledgement(handoff_id))

Expand Down Expand Up @@ -526,6 +541,20 @@ async def answer_with_model(content: str, message_id: str) -> None:
save_openai_metrics(message_id, openai_cb)


#: Handoffs being seeded, per session. A message waits for its session's seed
#: before touching the thread, or the seed lands beside a running turn and is
#: lost while the chat says it is continuing from the summary.
_seeding: dict[str, asyncio.Event] = {}
SEED_WAIT_SECONDS = 45.0


async def wait_for_seed() -> None:
event = _seeding.get(session_id())
if event is not None:
with contextlib.suppress(TimeoutError):
await asyncio.wait_for(event.wait(), SEED_WAIT_SECONDS)


#: Guests' messages, limited per human check rather than per session. The
#: per-session quota is keyed on the session id, which the client chooses, so
#: a reconnect -- or a script -- started a fresh quota every time (review, 1b).
Expand Down Expand Up @@ -554,6 +583,8 @@ def solve_key() -> str:

@cl.on_message
async def main(message: cl.Message) -> None:
await wait_for_seed()

# First, before any early return: a "yes" means the offer just made, so
# any other message -- rate limited, an attachment -- ends that meaning.
latest = proposals.take_latest(session_id())
Expand Down
33 changes: 24 additions & 9 deletions bin/chat-fastapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,21 @@
from typing import Any
from urllib.parse import urlsplit

from dotenv import load_dotenv

from util.secrets import SECRET_NAMES, get_secret, load_secrets_to_environ

# Before anything imports chainlit. `chainlit.utils` loads
# `chainlit.oauth_providers`, which reads OAUTH_*_CLIENT_SECRET once, at import:
# loaded after it, an OAuth secret supplied as a Docker secret was never seen,
# and logged-in chat failed at the OAuth callback (review, area 1b).
load_dotenv()
# The same list chat-chainlit uses. This was a second, hand-maintained copy
# that had already drifted from it in both directions.
load_secrets_to_environ(SECRET_NAMES)

import httpx
from chainlit.utils import mount_chainlit
from dotenv import load_dotenv
from fastapi import FastAPI, Request, Response
from fastapi.responses import HTMLResponse, RedirectResponse

Expand All @@ -21,12 +33,6 @@
from util.captcha_scope import is_captcha_exempt
from util.embedding_environment import EmbeddingEnvironment
from util.logging import logging
from util.secrets import SECRET_NAMES, get_secret, load_secrets_to_environ

load_dotenv()
# The same list chat-chainlit uses. This was a second, hand-maintained copy
# that had already drifted from it in both directions.
load_secrets_to_environ(SECRET_NAMES)


@asynccontextmanager
Expand Down Expand Up @@ -62,7 +68,9 @@ async def lifespan(_app: FastAPI) -> AsyncIterator[None]:

app = FastAPI(lifespan=lifespan)

CHAINLIT_URI = os.getenv("CHAINLIT_URI")
# Empty means unset. Compose turns an unset variable into "", and an empty
# CHAINLIT_URI left the captcha page redirecting to itself (review, 1b).
CHAINLIT_URI = os.getenv("CHAINLIT_URI") or None

# Defined after CHAINLIT_URI, which it is built from. The endpoint lives under
# the Chainlit mount point so one nginx location covers both.
Expand Down Expand Up @@ -99,6 +107,13 @@ async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
"this deployment does not want one."
)
CLOUDFLARE_SITE_KEY = os.getenv("CLOUDFLARE_SITE_KEY")
if CHAT_REQUIRES_HUMAN and not CLOUDFLARE_SITE_KEY:
# Without it the check page renders data-sitekey="None", which no one can
# pass, while startup reported healthy (review, area 1b).
raise RuntimeError(
"CHAT_REQUIRES_HUMAN is on and no CLOUDFLARE_SITE_KEY is configured, "
"so the human check could not be shown."
)

ERROR_PAGE_TEMPLATE = Template(
f"""
Expand Down Expand Up @@ -225,7 +240,7 @@ async def captcha_page() -> Response:
</head>
<body>
<form id="captcha-form" action="{CHAINLIT_URI}/verify_captcha" method="post">
<div class="cf-turnstile" data-sitekey="{os.getenv('CLOUDFLARE_SITE_KEY')}" data-callback="onSubmit"></div>
<div class="cf-turnstile" data-sitekey="{CLOUDFLARE_SITE_KEY}" data-callback="onSubmit"></div>
</form>
<script>
// Continue in chat (spec 013): keep a handoff across this page.
Expand Down
16 changes: 12 additions & 4 deletions deploy/beta/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,19 +82,27 @@ a real key is installed**: on the verifying side it is pure liability.

Bound to loopback: Apache is the only thing that should reach it.

In practice, run `~/update-beta-chat.sh --tag <sha>`: it does the following,
keeps the previous container for `--rollback`, and refuses to switch over
unless the answer sweep and routing probe pass. By hand:

```bash
docker run -d --name biochat_beta_guest --restart unless-stopped \
--env-file .env.beta \
-v "$PWD/embeddings:/app/embeddings" \
-v "$PWD/config.yml:/app/config.yml" \
-v "$PWD/deploy/beta/caller_token_public.pem:/run/secrets/caller_token_public.pem:ro" \
-p 127.0.0.1:8000:8000 \
public.ecr.aws/reactome/reactome-chatbot:e398a37
public.ecr.aws/reactome/reactome-chatbot:<sha>

curl -s localhost:8000/chat/ | grep -o React-to-Me # should print React-to-Me
curl -s localhost:8000/chat/ | grep -o React-to-Me # should print React-to-Me
curl -s -o /dev/null -w '%{http_code}\n' localhost:8000/chat/guest/ # 307: gated
```

The image tag matches what production runs today, so this is a like-for-like
baseline to compare against after the dependency upgrade.
Use a current image. Older ones -- `e398a37`, which this section used to pin --
bypass the human check when the key is unset, and the first smoke test above
passes either way; the second is the one that says the chat is gated. (Updated
2026-10-03, review area 1b.)

Note: the landing page shows both a **Guest Access** and a **Log In** button. Only
Guest Access works in this setup; wiring Log In needs a second container on :8001
Expand Down
8 changes: 6 additions & 2 deletions deploy/beta/env.beta.template
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,14 @@
# Deliberately omitted (do not copy from prod's .env):
# OAUTH_GOOGLE_* redirect URIs are registered for reactome.org
# CHAINLIT_AUTH_SECRET enables the login flow, which has nowhere to go here
# CLOUDFLARE_* Turnstile site key is bound to reactome.org; unset =
# captcha middleware bypasses itself
# POSTGRES_* unset = MemorySaver, no chat history, no DB to run

# Required: the chat will not start without both (unless CHAT_REQUIRES_HUMAN=0
# says deliberately that it wants no human check). The site key lists
# beta.reactome.org as well as reactome.org.
CLOUDFLARE_SITE_KEY=
CLOUDFLARE_SECRET_KEY=

OPENAI_API_KEY=
TAVILY_API_KEY=

Expand Down
25 changes: 20 additions & 5 deletions deploy/beta/reclaim-docker-space.sh
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,17 @@
set -euo pipefail

MODE="${1:-audit}"
# Checked before anything else: an unknown argument (--help, --dry-run) used to
# run the image and cache prunes and only then warn (review, area 1b).
case "$MODE" in
audit|--safe|--all) ;;
*) echo "usage: $0 [--safe|--all] (no argument: audit only, changes nothing)" >&2; exit 2 ;;
esac

# An anonymous volume's name is 64 hex characters; a named one (postgres-data,
# say, after `docker compose down`) is not. The header has always promised
# --all removes only anonymous volumes; it used to remove both (review, 1b).
is_anonymous() { [[ "$1" =~ ^[0-9a-f]{64}$ ]]; }

say() { printf '\n\033[1m%s\033[0m\n' "$*"; }
ok() { printf ' \033[32m✓\033[0m %s\n' "$*"; }
Expand All @@ -34,19 +45,24 @@ docker system df
say "Dangling volumes (candidates for --all)"
found=0
DANGLING=()
NAMED=()
for v in $(docker volume ls -qf dangling=true); do
found=1
DANGLING+=("$v")
if is_anonymous "$v"; then DANGLING+=("$v"); else NAMED+=("$v"); fi
mp=$(docker volume inspect -f '{{.Mountpoint}}' "$v")
created=$(docker volume inspect -f '{{.CreatedAt}}' "$v")
size=$(du -sh "$mp" 2>/dev/null | cut -f1 || echo '?')
printf '\n %s\n created %s, %s\n' "$v" "$created" "$size"
printf ' top-level contents:\n'
ls -A "$mp" 2>/dev/null | head -8 | sed 's/^/ /' || true
n=$(ls -A "$mp" 2>/dev/null | wc -l)
[ "$n" -gt 8 ] && printf ' ... and %s more entries\n' "$((n - 8))"
# `|| true`: without sudo, ls fails, and pipefail ended the audit silently.
n=$(ls -A "$mp" 2>/dev/null | wc -l || true)
if [ "${n:-0}" -gt 8 ]; then printf ' ... and %s more entries\n' "$((n - 8))"; fi
done
[ "$found" -eq 0 ] && ok "none"
if [ ${#NAMED[@]} -gt 0 ]; then
warn "named volumes above are kept even with --all: ${NAMED[*]}"
fi

if [ "$MODE" = "audit" ]; then
say "Audit only -- nothing was changed."
Expand All @@ -64,12 +80,11 @@ if [ "$MODE" = "--all" ]; then
if [ ${#DANGLING[@]} -gt 0 ]; then
# Remove by id rather than `docker volume prune`, which only sweeps volumes
# Docker tagged as anonymous and silently leaves older unreferenced ones.
# Anonymous ones only -- see is_anonymous.
docker volume rm "${DANGLING[@]}" || warn "some volumes could not be removed"
else
ok "no dangling volumes"
fi
elif [ "$MODE" != "--safe" ]; then
warn "unknown mode '$MODE' -- treated as --safe"
fi

AFTER=$(free_gb)
Expand Down
6 changes: 4 additions & 2 deletions public/custom.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@
</div>
`.trim();

const WATERMARK_SELECTOR = 'a.watermark';
// `.watermark`, not `a.watermark`: since Chainlit 2.11 the footer is a <div>,
// and the disclaimer had silently stopped appearing (review, area 1b).
const WATERMARK_SELECTOR = '.watermark';
const STYLE_ID = 'custom-watermark-style';
const SIBLING_ATTR = 'data-custom-watermark-sibling';

Expand All @@ -26,7 +28,7 @@
const style = document.createElement('style');
style.id = STYLE_ID;
style.textContent = `
a.watermark {
.watermark {
display: none !important;
}
`;
Expand Down
6 changes: 3 additions & 3 deletions src/handoff/seed.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
from analysis.disclosure import for_tier
from analysis.summarise import DATA_RULES, prompt_input, summary_instruction
from handoff.store import DEFAULT_TTL_SECONDS, AnalysisHandoff, Handoff, SearchHandoff
from util.markdown import escape
from util.markdown import escape, inert_html

#: What the reader asked for on the website, stated as what happened.
HUMAN_TURN = (
Expand Down Expand Up @@ -131,12 +131,12 @@ def shown_to_reader(handoff: Handoff) -> str:
# live markup -- script in a srcdoc iframe -- into the chat of
# whoever opened it (review, area 1a).
f"Continuing from your search: **{escape(handoff.question)}**\n\n"
f"{handoff.summary}\n\n"
f"{inert_html(handoff.summary)}\n\n"
"---\nAsk a follow-up question."
)
return (
"Continuing from your analysis summary:\n\n"
f"{handoff.summary}\n\n"
f"{inert_html(handoff.summary)}\n\n"
"---\nAsk a follow-up question about this analysis."
)

Expand Down
48 changes: 46 additions & 2 deletions src/util/caller_token.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,18 @@
"""

import os
import time
from dataclasses import dataclass
from pathlib import Path

import jwt
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey

ALGORITHMS = ["EdDSA", "RS256"]
#: Tolerated difference between the website's clock and ours.
CLOCK_LEEWAY_SECONDS = 30
#: A real caller token is a few hundred characters. Bounding it bounds the
#: parse an unauthenticated request can force.
MAX_TOKEN_CHARS = 4096
Expand Down Expand Up @@ -81,6 +87,22 @@ def load_verifying_key(path: str | None = None) -> str:
) from exc
if not key:
raise RuntimeError(f"The verifying key at {key_file} is empty.")
# Parsed now, not at the first request: a truncated PEM, a certificate or
# the *private* key passed the emptiness check, and then every token was
# refused as "unusable" -- in a log line that blamed the website (review,
# area 1b).
try:
parsed = serialization.load_pem_public_key(key.encode())
except ValueError as exc:
raise RuntimeError(
f"The verifying key at {key_file} is not a PEM public key. "
"(Was the private key, or a certificate, mounted by mistake?)"
) from exc
if not isinstance(parsed, Ed25519PublicKey | RSAPublicKey):
raise RuntimeError(
f"The verifying key at {key_file} is a {type(parsed).__name__}; "
f"tokens are signed with one of {ALGORITHMS}."
)
return key


Expand Down Expand Up @@ -115,7 +137,7 @@ def verify(token: str, verifying_key: str, *, audience: str | None = None) -> di
raise TokenRejectedError("token too long")
expected = audience or expected_audience()
try:
return dict(
claims = dict(
jwt.decode(
token,
verifying_key,
Expand All @@ -127,9 +149,19 @@ def verify(token: str, verifying_key: str, *, audience: str | None = None) -> di
# is expected, so removing it fails no test. It is here so that
# behaviour changing in a future PyJWT cannot quietly turn "no
# audience" into "nothing to check".
options={"require": ["exp", "aud"]},
# iat and nbf are checked below, with leeway: clocks differ, and
# with none a website clock 200ms ahead had 20% of fresh tokens
# refused as not yet valid (review, area 1b). Expiry stays
# strict -- leeway in PyJWT would stretch it too.
options={
"require": ["exp", "aud"],
"verify_iat": False,
"verify_nbf": False,
},
)
)
_check_not_from_the_future(claims)
return claims
except jwt.ExpiredSignatureError as exc:
raise TokenRejectedError("token expired") from exc
except jwt.InvalidAudienceError as exc:
Expand All @@ -156,6 +188,18 @@ def verify(token: str, verifying_key: str, *, audience: str | None = None) -> di
raise TokenRejectedError(f"unusable token: {type(exc).__name__}") from exc


def _check_not_from_the_future(claims: dict) -> None:
now = time.time()
for name in ("iat", "nbf"):
value = claims.get(name)
if value is None:
continue
if not isinstance(value, int | float):
raise TokenRejectedError(f"invalid token: {name} is not a number")
if value > now + CLOCK_LEEWAY_SECONDS:
raise TokenRejectedError(f"invalid token: {name} is in the future")


# --- human presence, for the analysis-summary endpoint ----------------------
#
# A stricter bar than `verify`, and deliberately separate from it. `verify`
Expand Down
Loading
Loading