diff --git a/.chainlit/config.toml b/.chainlit/config.toml index 4e2f3e7..9ff01b2 100644 --- a/.chainlit/config.toml +++ b/.chainlit/config.toml @@ -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] diff --git a/bin/chat-chainlit.py b/bin/chat-chainlit.py index 37ea68b..b55cf6b 100644 --- a/bin/chat-chainlit.py +++ b/bin/chat-chainlit.py @@ -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 @@ -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)) @@ -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). @@ -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()) diff --git a/bin/chat-fastapi.py b/bin/chat-fastapi.py index 627aeed..78df461 100644 --- a/bin/chat-fastapi.py +++ b/bin/chat-fastapi.py @@ -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 @@ -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 @@ -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. @@ -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""" @@ -225,7 +240,7 @@ async def captcha_page() -> Response:
">', + citations=(), + created_at=time.time(), + ) + ) + assert re.search(r"(? None: + # No leeway refused 20% of fresh tokens at 200ms of skew (review, 1b). + private, public = keys + assert verify(_mint(private, iat=int(time.time()) + 10), public) + + +def test_a_token_from_well_in_the_future_is_refused(keys: tuple[str, str]) -> None: + private, public = keys + with pytest.raises(TokenRejectedError, match="future"): + verify(_mint(private, iat=int(time.time()) + 600), public) + + +@pytest.mark.parametrize("which", ["private", "garbage"]) +def test_a_key_that_cannot_verify_stops_startup( + keys: tuple[str, str], tmp_path: Path, which: str +) -> None: + # It passed the emptiness check, then every token was refused as + # "unusable" in a log line that blamed the website (review, 1b). + private, _ = keys + key_file = tmp_path / "key.pem" + key_file.write_text( + private if which == "private" else "-----BEGIN PUBLIC KEY-----\nAAAA\n" + ) + with pytest.raises(RuntimeError, match="not a PEM public key"): + load_verifying_key(str(key_file)) + + +def test_the_real_public_key_loads(keys: tuple[str, str], tmp_path: Path) -> None: + _, public = keys + key_file = tmp_path / "key.pem" + key_file.write_text(public) + assert load_verifying_key(str(key_file)).startswith("-----BEGIN PUBLIC KEY-----") diff --git a/tests/util/test_logging_tokens.py b/tests/util/test_logging_tokens.py index 208c8ac..6140b6c 100644 --- a/tests/util/test_logging_tokens.py +++ b/tests/util/test_logging_tokens.py @@ -20,3 +20,26 @@ def test_our_own_info_logs_still_are() -> None: # The fix must lower only the request logger, not logging generally. root = logging.getLogger() assert root.isEnabledFor(logging.getLevelName(util.logging.DEFAULT_LOG_LEVEL)) + + +def test_session_ids_are_redacted_from_the_access_log() -> None: + # The access log line uvicorn writes for a Chainlit file download. + record = logging.LogRecord( + "uvicorn.access", + logging.INFO, + __file__, + 0, + '%s - "%s %s HTTP/%s" %d', + ( + "1.2.3.4:5", + "GET", + "/chat/guest/project/file/abc?session_id=SECRET-SID", + "1.1", + 200, + ), + None, + ) + assert logging.getLogger("uvicorn.access").filter(record) + line = record.getMessage() + assert "SECRET-SID" not in line + assert "session_id=[redacted]" in line