M1: make the first story turn work with no Internet

Phase 0B ran the upstream application on a network with no route out and
the first turn died in tiktoken, which downloads its BPE table the first
time anything counts a token. The browser separately fetched three font
families from Google on every page load. Neither is visible on a machine
that has been online once, which is why both now have tests.

The tokenizer table is vendored at
backend/app/context/vendor/cl100k_base.tiktoken and
backend/app/context/encoding.py builds the encoding from it directly,
verifying its SHA-256 against the digest tiktoken itself pins for that
URL. No code path in the tokenizer can reach the network any more —
not a warm cache, not an environment variable a deployment could forget.
The encoding was checked token for token against tiktoken's own.

The three font families are self-hosted as variable fonts under
frontend/public/fonts/ (343 KiB, Latin and Latin Extended), declared in
frontend/src/styles/fonts.css, and re-vendored by
frontend/tools/vendor_fonts.py. Their OFL licences ship beside them.
With no remote asset left, the CSP drops both Google hosts and gains
object-src, base-uri and form-action; woff2 also gets its real media
type, which Python's table lacks on a slim image.

A trusted-LAN Ollama turned out not to work at all over HTTPS. httpx
verifies against the certifi bundle, so an endpoint whose certificate
comes from a CA the user installed on their own machines — a StartOS
server's Ollama, for one — was refused with CERTIFICATE_VERIFY_FAILED
while curl and the browser on the same host accepted it.
app/tlstrust.py builds one context that unions the platform CA store
with certifi's, and all four outbound clients use it. A union rather
than a swap, so an image with an empty system store cannot start failing
on endpoints that worked before. Verification itself is untouched:
CERT_REQUIRED, hostname checking on, and no insecure escape hatch.

The storyteller listener is now loopback by explicit statement rather
than by inheriting uvicorn's default: start.sh, start.ps1, and
docker-compose.yml, which publishes to 127.0.0.1 rather than every
interface. Reaching an Ollama on another machine is outbound and needs
none of that inbound exposure.

backend/requirements.lock pins the exact tested closure;
requirements.txt keeps the ranges. DEVELOPMENT.md covers setup, the
same-host and trusted-LAN Ollama configurations, and how to re-run the
offline proof. PROVENANCE.md records the upstream commit, the MIT terms,
and both vendored assets.

Verified, not just compiled. On an --internal Docker network with
1.1.1.1 unreachable and no name resolving, a campaign was created and
played for six turns through same-host Ollama, restarted, and resumed.
A second run played ten turns through Ollama on a separate physical
machine on the LAN over verified HTTPS, summaries and embeddings
included, with the storyteller's default route deleted so the LAN was
reachable and the Internet was not. Its capture: 893 packets to the
approved host, 730 loopback, zero anywhere else, and zero DNS queries.
Two induced model failures left the accepted story bit-identical. The
inherited SPA was opened in a browser and a campaign read back from it.
Evidence is in planning/reports/M1-BASELINE-REPORT.md, along with the
findings that did not belong in this change.

648 backend tests pass, up from the inherited 632; frontend lint and
build are clean; the image builds. No M2 work is included: the hosted,
cloud, analytics, Postgres and scripting surfaces are untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017foPNqFjAJa2Ngebf5mEfL
This commit is contained in:
JesseMarkowitz
2026-09-02 02:40:28 -04:00
co-authored by Claude Opus 5
parent 7f182a86e9
commit c1a73b3d77
35 changed files with 102102 additions and 23 deletions
+4
View File
@@ -3,3 +3,7 @@
*.sh text eol=lf *.sh text eol=lf
Dockerfile text eol=lf Dockerfile text eol=lf
*.ps1 text eol=crlf *.ps1 text eol=crlf
# Vendored web fonts: binary, and never to be line-ending-normalized.
*.woff2 binary
*.woff binary
+4
View File
@@ -17,5 +17,9 @@ frontend/dist/
# Misc # Misc
.claude/ .claude/
# Phase 0B candidate clones, spikes and captured runs. Research scratch that
# predates the fork: it holds virtualenvs, databases and downloaded models.
/phase0b/
# Phase 8: auto-generated session/encryption secret (lives next to the DB) # Phase 8: auto-generated session/encryption secret (lives next to the DB)
secret.key secret.key
+246
View File
@@ -0,0 +1,246 @@
# Development and local operation
This is the Adventure Storyteller production fork of AI-DnD. `PROVENANCE.md`
records where the code came from; `planning/` holds the product specification
and milestone plan.
Everything here assumes the local-only rule from
`planning/DECISIONS/004-local-only-production.md`: after setup, ordinary story
play must work with **no Internet access at all**. Setup itself downloads
dependencies and models; playing does not.
## Versions this was built and tested on
| | |
| --- | --- |
| OS | Linux (Ubuntu 24.04 userland), x86-64, 4 cores, 15 GB RAM, no GPU |
| Python | 3.12.3 |
| Node | 22.23.1, npm 10.9.8 (the Dockerfile builds the SPA on Node 24) |
| Ollama | `ollama/ollama:latest` in Docker |
| Models | `qwen2.5:3b-instruct` (narrator), `nomic-embed-text` (memory bank) |
## Setup
```bash
# Backend, from the exact tested dependency closure.
python3 -m venv backend/.venv
backend/.venv/bin/pip install -r backend/requirements.lock
# Frontend.
cd frontend && npm ci && cd ..
```
`backend/requirements.lock` pins every version, transitive ones included.
`backend/requirements.txt` states the ranges the code actually needs and stays
the file you edit; regenerate the lock after a deliberate upgrade (the header in
the lock says how).
This is the only step that needs the Internet. It downloads Python and npm
packages; it does **not** download a tokenizer or a font, because both are
vendored in the tree — see "What was made offline-safe" below.
You also need the models, once:
```bash
ollama pull qwen2.5:3b-instruct
ollama pull nomic-embed-text # only if you want the memory bank
```
## Running
**Development** — backend on `:8000`, Vite dev server on `:5173`:
```bash
./start.sh
```
**Production-shaped** — one server, SPA served by FastAPI:
```bash
cd frontend && npm run build && cd ..
cd backend && .venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8000
```
Then open <http://127.0.0.1:8000>.
**Docker**:
```bash
docker compose up --build
```
### The listener is loopback, and stays loopback
`start.sh`, `start.ps1` and the production command above all pass
`--host 127.0.0.1` explicitly. `docker-compose.yml` publishes
`127.0.0.1:8000:8000` — the process inside the container listens on `0.0.0.0`
because a published port cannot reach anything else, but the port is only
bound on the host's loopback.
That is a requirement, not a preference. In local mode the storyteller API is
single-user and unauthenticated: anything that can reach it can read and
rewrite every campaign. Putting Ollama on another machine (below) does **not**
change this — it is an outbound connection and needs no inbound exposure.
If you publish the port to `0.0.0.0` anyway, you have made a deliberate
decision that this project's threat model does not cover
(`planning/SECURITY-THREAT-MODEL.md`).
## Pointing the storyteller at Ollama
The endpoint, model and (unused) API key are **runtime settings stored in the
database**, not environment variables. Set them on the app's Settings page, or
with one request:
```bash
curl -X PUT http://127.0.0.1:8000/api/settings \
-H 'Content-Type: application/json' \
-d '{"endpoint_url":"http://127.0.0.1:11434/v1","model":"qwen2.5:3b-instruct",
"api_mode":"chat","api_key":"","max_output_tokens":200,
"context_token_budget":4096}'
```
`POST /api/settings/test` (the **Test connection** button) returns
`{"ok": true, "models": [...]}` and is the fastest way to tell a wrong endpoint
from a missing model.
### Same host (the default)
```text
endpoint_url = http://127.0.0.1:11434/v1
```
Nothing else to do. Ollama's own default is to listen on loopback.
### An Ollama on another machine on your trusted LAN
Supported and explicitly configured — never guessed, never discovered.
On the **inference machine**, tell Ollama to accept connections from the LAN,
because it binds loopback by default:
```bash
OLLAMA_HOST=0.0.0.0:11434 ollama serve
```
On the **storyteller machine**, set the endpoint to that host's address:
```text
endpoint_url = http://192.168.1.50:11434/v1
```
Use an IP address or a name your own network resolves. Then:
- the storyteller UI/API stays on `127.0.0.1` — do not change the listener;
- prompts, story text, retrieved memories and embedding inputs all travel to
that host, so it has to be one you control, on a network you trust;
- the inference machine needs the models installed, not the storyteller;
- no Internet is involved in either direction.
`app/netguard.py` refuses private addresses only in hosted multi-user mode
(`AIDND_MULTI_USER=1`), which local installs never turn on, so a LAN endpoint
is accepted as configured.
#### If that endpoint is HTTPS with your own CA
Some inference hosts are only reachable over TLS. A StartOS server is one: it
serves Ollama over HTTPS with a certificate from its own local CA, and plain
HTTP redirects to it.
Install that CA on the machine running the storyteller, the same way you would
for the browser — on Debian and Ubuntu:
```bash
sudo cp your-ca.crt /usr/local/share/ca-certificates/
sudo update-ca-certificates
```
then use the `https://` URL and the hostname the certificate is issued for:
```text
endpoint_url = https://inference.lan:8443/v1
```
The application verifies against the machine's CA store **and** the `certifi`
bundle (`backend/app/tlstrust.py`), so a CA you installed at the OS level is
honoured, exactly as `curl` and your browser honour it. Public certificates
keep working unchanged.
There is deliberately **no** setting to skip verification. If a connection is
refused with `CERTIFICATE_VERIFY_FAILED`, the CA is not installed where the
storyteller can see it, or the URL's hostname does not match the certificate —
`openssl s_client -connect host:port` will say which. In a container, remember
the CA has to be inside the image or bind-mounted; the host's store is not
visible from within.
## Tests
```bash
cd backend && .venv/bin/python -m pytest tests/ -q # 648 tests
cd frontend && npm run lint && npm run build
```
Two files are the M1 regression guards.
`test_offline_assets.py` fails if the tokenizer starts fetching its table
again, if a remote font or stylesheet comes back, or if the CSP names a remote
origin. Two of its checks read the built SPA under `frontend/dist/` and skip
when it has not been built, so run `npm run build` before treating a green
suite as complete evidence.
`test_tls_trust.py` fails if outbound verification is weakened, if a public CA
is lost from the union, or if a new HTTP client is added without the shared
verification context.
## What was made offline-safe, and how to check
Two runtime downloads were removed in Milestone M1. Both were invisible on a
machine that had been online once, which is exactly why they need tests.
**The tokenizer.** `tiktoken.get_encoding("cl100k_base")` downloads a 1.7 MB
BPE table on first use, and the context builder counts tokens on every turn, so
the first story turn on an air-gapped install died with a `ConnectionError`.
The table is vendored at `backend/app/context/vendor/cl100k_base.tiktoken` and
`backend/app/context/encoding.py` builds the encoding from it, verifying its
SHA-256 against the digest `tiktoken` itself pins.
**The fonts.** The SPA linked `fonts.googleapis.com` from `index.html`, so
every page load fetched a stylesheet and font files from Google. The three
families are self-hosted under `frontend/public/fonts/`, declared in
`frontend/src/styles/fonts.css`, and re-vendored by
`python3 frontend/tools/vendor_fonts.py`. The CSP in `backend/app/main.py` now
names no remote origin at all.
To convince yourself on a machine that has already been online, run the app
with no route out rather than trusting a cold cache:
```bash
docker network create --internal offline
docker run -d --name ollama --network offline -v ollama-models:/root/.ollama ollama/ollama
docker build -t storyteller .
# The app shares Ollama's network namespace, so Ollama is on its loopback and
# neither has a route to the Internet.
docker run -d --name app --network container:ollama -v story-data:/data \
storyteller uvicorn app.main:app --host 127.0.0.1 --port 8000
docker exec app python -c "import socket; socket.create_connection(('1.1.1.1',443),timeout=4)"
# -> OSError: Network is unreachable, and story turns still work
```
`planning/reports/M1-BASELINE-REPORT.md` records the run this procedure is
taken from, including the packet captures.
## Things inherited from upstream that M1 deliberately did not touch
These are M2's scope (`planning/BUILD-MILESTONES.md`), listed here so nobody
reports them as new:
- hosted/multi-user/account/demo-key code, analytics tables, Postgres and
Render deployment paths, and the OpenRouter default endpoint constant all
still exist in the tree. None of them is reachable from a default local run,
and none requires a cloud service.
- `docs/*.html` is upstream's GitHub Pages project site and still links Google
Fonts. It is not served by the application and is not part of any build.
- `.github/workflows/ci.yml` is upstream's GitHub Actions pipeline. This
repository lives on a self-hosted Gitea; the workflow is kept for provenance
and is not what runs the tests here.
- QuickJS campaign scripting is still present and still tested.
+5
View File
@@ -32,6 +32,11 @@ ENV AIDND_DB_PATH=/data/data.db
VOLUME /data VOLUME /data
EXPOSE 8000 EXPOSE 8000
# Publish this port to loopback only — `-p 127.0.0.1:8000:8000`, which is what
# docker-compose.yml does. The listener below is 0.0.0.0 because that is the
# only address a published port can reach inside a container; it is not an
# invitation to put the storyteller on the LAN, which is single-user and
# unauthenticated in local mode.
WORKDIR /app/backend WORKDIR /app/backend
# --proxy-headers lets uvicorn fix up the request scheme (https) behind the # --proxy-headers lets uvicorn fix up the request scheme (https) behind the
# platform's edge. We deliberately do NOT pass --forwarded-allow-ips "*": that # platform's edge. We deliberately do NOT pass --forwarded-allow-ips "*": that
+106
View File
@@ -0,0 +1,106 @@
# Provenance
This repository is the Adventure Storyteller production fork. Its application
code comes from **AI-DnD**, and its planning package (`planning/`) is original
to this project.
## Upstream
| | |
| --- | --- |
| Project | AI-DnD |
| Repository | <https://github.com/parththakkar106/AI-DnD> |
| Commit | `d72f7c1bda0f34fccd84afb7a25c34eb01c901de` |
| Subject | Stop paying twice for a block a retry can still throw away |
| Author date | Mon 31 Aug 2026 16:14:24 +0000 |
| Position | tip of `upstream/main` on 1 Sep 2026, when the fork was taken |
| License | MIT, © 2026 Parth Thakkar |
The commit is the one pinned by `planning/DECISIONS/009-ai-dnd-production-base.md`
after Phase 0B. It was not substituted for a newer upstream commit.
## How the fork is wired
Upstream history is *in* this repository rather than copied out of it. The
import is a merge of the pinned commit with `--allow-unrelated-histories`, so:
- `git log d72f7c1bda0f34fccd84afb7a25c34eb01c901de` shows the real upstream
history, not a squashed snapshot;
- upstream paths are unchanged (`backend/`, `frontend/`, `docs/`, …), so a
later upstream commit can still be fetched and cherry-picked against
matching files;
- the planning package that predates the fork keeps its own history on the
other parent of the merge.
To re-verify from a fresh clone:
```bash
git remote add upstream https://github.com/parththakkar106/AI-DnD.git
git fetch --no-tags upstream
git cat-file -t d72f7c1bda0f34fccd84afb7a25c34eb01c901de # -> commit
git merge-base --is-ancestor d72f7c1bda0f34fccd84afb7a25c34eb01c901de HEAD && echo "in this history"
```
## License
Upstream is MIT. `LICENSE` is upstream's file, unmodified, and the copyright
notice stays with it. The MIT terms require that the notice travel with the
code and with substantial portions of it; keep `LICENSE` in place in any
redistribution of this fork, including a packaged build.
Work done in this repository after the fork is a derivative of that MIT-licensed
code.
## Vendored third-party assets
Both were added by Milestone M1 to remove a runtime Internet dependency. Each
is redistributable and each has a regeneration path in the tree, so neither is
an opaque binary nobody can rebuild.
### `backend/app/context/vendor/cl100k_base.tiktoken`
The BPE merge table for OpenAI's `cl100k_base` tokenizer, used for context
budgeting only — no model of OpenAI's is ever called.
- Source: `https://openaipublic.blob.core.windows.net/encodings/cl100k_base.tiktoken`
- SHA-256: `223921b76ee99bde995b7ff738513eef100fb51d18c93597a113bcffe865b2a7`,
which is the digest `tiktoken` itself pins for that URL, and which
`backend/app/context/encoding.py` re-checks every time it builds the encoding.
- Published by OpenAI for use with `tiktoken` (MIT).
### `frontend/public/fonts/*.woff2`
Cinzel, Crimson Pro and Inter, Latin and Latin Extended subsets, as variable
fonts. All three are licensed under the SIL Open Font License 1.1; the license
text ships beside them as `OFL-cinzel.txt`, `OFL-crimsonpro.txt` and
`OFL-inter.txt`, which is what the OFL requires of a redistribution.
Regenerate with `python3 frontend/tools/vendor_fonts.py`, which also rewrites
`frontend/src/styles/fonts.css`.
## What this fork changed in Milestone M1
Nothing was removed from upstream. The changes are the offline/locality
hardening M1 called for; see `planning/reports/M1-BASELINE-REPORT.md` for the
evidence.
- `backend/app/context/encoding.py` (new) and `backend/app/context/builder.py` —
build `cl100k_base` from the vendored table instead of downloading it on
first use.
- `frontend/index.html`, `frontend/src/index.css`,
`frontend/src/styles/fonts.css` (new), `frontend/public/fonts/` (new),
`frontend/tools/vendor_fonts.py` (new) — self-hosted fonts in place of the
Google Fonts link.
- `backend/app/main.py` — CSP narrowed to same-origin, with the two Google
hosts dropped and `object-src` / `base-uri` / `form-action` added; `woff2`
registered so the self-hosted fonts are served with their real media type.
- `backend/app/tlstrust.py` (new), `backend/app/providers/openai_compatible.py`,
`backend/app/routers/settings.py` — outbound HTTPS verifies against the
machine's own CA store as well as certifi's, so a trusted-LAN Ollama with a
locally-issued certificate works. Verification is not relaxed.
- `start.sh`, `start.ps1`, `docker-compose.yml` — the storyteller listener is
explicitly loopback-bound.
- `backend/requirements.lock` (new) — the exact tested dependency closure.
- `backend/tests/test_offline_assets.py` and `backend/tests/test_tls_trust.py`
(new) — regression tests for the above.
- `DEVELOPMENT.md` (new) — environment setup and Ollama configuration.
+5 -4
View File
@@ -18,13 +18,12 @@ top of the prompt re-prices everything below it. See the comments on the static
block and the live sections in `build_context`. block and the live sections in `build_context`.
""" """
import functools
from dataclasses import dataclass from dataclasses import dataclass
import tiktoken import tiktoken
from .. import models, worldstate from .. import models, worldstate
from . import history from . import encoding, history
AUTHORS_NOTE_DEPTH = 3 # actions from the end of history AUTHORS_NOTE_DEPTH = 3 # actions from the end of history
CARD_BUDGET_SHARE = 0.4 # max share of non-reserved budget that story cards may take CARD_BUDGET_SHARE = 0.4 # max share of non-reserved budget that story cards may take
@@ -61,9 +60,11 @@ MIN_LENGTH_FLOOR_WORDS = 60
MAX_LENGTH_FLOOR_WORDS = 300 MAX_LENGTH_FLOOR_WORDS = 300
@functools.lru_cache(maxsize=1) # Built from the table vendored in `encoding.py`, not fetched: the upstream
# `tiktoken.get_encoding("cl100k_base")` downloads it on first use, and this
# is called on every turn.
def _encoding() -> tiktoken.Encoding: def _encoding() -> tiktoken.Encoding:
return tiktoken.get_encoding("cl100k_base") return encoding.get_encoding()
def count_tokens(text: str) -> int: def count_tokens(text: str) -> int:
+90
View File
@@ -0,0 +1,90 @@
"""`cl100k_base` without a first-use download.
Upstream called `tiktoken.get_encoding("cl100k_base")`, which fetches the BPE
table from `openaipublic.blob.core.windows.net` the first time it is used and
caches it under the system temp directory. That download is invisible on a
developer machine that has already made it once, and fatal on a machine with
outbound Internet blocked: the context builder counts tokens on *every* turn,
so the first story turn died with a `ConnectionError` instead of narrating
(Phase 0B offline report; acceptance tests A01 and H11).
The table is vendored beside this module and the `Encoding` is constructed from
it directly, so no code path inside the tokenizer can reach the network — not a
cache that happens to be warm, and not an environment variable a deployment
could forget to set.
The vendored file's SHA-256 is verified against the digest `tiktoken` itself
pins for that URL. A truncated checkout or a substituted table then fails
loudly, rather than silently changing every token count the context budget is
computed from.
"""
import base64
import functools
import hashlib
from pathlib import Path
import tiktoken
ENCODING_NAME = "cl100k_base"
#: Where the table came from, recorded so the vendored copy can be re-derived.
SOURCE_URL = "https://openaipublic.blob.core.windows.net/encodings/cl100k_base.tiktoken"
#: The digest `tiktoken_ext.openai_public.cl100k_base()` pins for SOURCE_URL.
BPE_SHA256 = "223921b76ee99bde995b7ff738513eef100fb51d18c93597a113bcffe865b2a7"
BPE_PATH = Path(__file__).resolve().parent / "vendor" / "cl100k_base.tiktoken"
# Both copied from `tiktoken_ext.openai_public.cl100k_base()`. They are part of
# the encoding's identity: the same merge table with a different pattern is a
# different tokenizer, so they are pinned here rather than imported, and the
# round-trip test asserts this build agrees with tiktoken's own.
_PAT_STR = r"""'(?i:[sdmt]|ll|ve|re)|[^\r\n\p{L}\p{N}]?+\p{L}++|\p{N}{1,3}+| ?[^\s\p{L}\p{N}]++[\r\n]*+|\s++$|\s*[\r\n]|\s+(?!\S)|\s"""
_SPECIAL_TOKENS = {
"<|endoftext|>": 100257,
"<|fim_prefix|>": 100258,
"<|fim_middle|>": 100259,
"<|fim_suffix|>": 100260,
"<|endofprompt|>": 100276,
}
def _mergeable_ranks() -> dict[bytes, int]:
try:
data = BPE_PATH.read_bytes()
except OSError as exc: # pragma: no cover - packaging fault, not a run fault
raise RuntimeError(
f"The vendored {ENCODING_NAME} table is missing at {BPE_PATH}. "
f"Re-download it from {SOURCE_URL} (SHA-256 {BPE_SHA256})."
) from exc
digest = hashlib.sha256(data).hexdigest()
if digest != BPE_SHA256:
raise RuntimeError(
f"The vendored {ENCODING_NAME} table at {BPE_PATH} has SHA-256 "
f"{digest}, expected {BPE_SHA256}."
)
# Same parse as tiktoken.load.load_tiktoken_bpe, minus its fetch/cache step.
ranks: dict[bytes, int] = {}
for line in data.splitlines():
if not line:
continue
try:
token, rank = line.split()
ranks[base64.b64decode(token)] = int(rank)
except Exception as exc:
raise ValueError(f"Error parsing line {line!r} in {BPE_PATH}") from exc
return ranks
@functools.lru_cache(maxsize=1)
def get_encoding() -> tiktoken.Encoding:
"""The `cl100k_base` encoding, built from the vendored table."""
return tiktoken.Encoding(
name=ENCODING_NAME,
pat_str=_PAT_STR,
mergeable_ranks=_mergeable_ranks(),
special_tokens=_SPECIAL_TOKENS,
)
File diff suppressed because it is too large Load Diff
+25 -4
View File
@@ -1,3 +1,4 @@
import mimetypes
import os import os
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
@@ -68,8 +69,18 @@ app.add_middleware(BodySizeLimitMiddleware)
class SecurityHeadersMiddleware: class SecurityHeadersMiddleware:
"""Standard hardening headers on every response. Pure ASGI (wraps `send`) """Standard hardening headers on every response. Pure ASGI (wraps `send`)
so SSE streams pass through unbuffered. The CSP allows exactly what the so SSE streams pass through unbuffered.
SPA uses: same-origin everything, inline styles (React), Google Fonts."""
The CSP allows exactly what the SPA uses, and that is now same-origin and
nothing else: scripts, styles, fonts, images and XHR/SSE all resolve to the
app itself. The fonts used to come from Google, which made an Internet
request on every page load; they are self-hosted under /fonts/ instead
(frontend/tools/vendor_fonts.py), so `font-src 'self'` covers them and the
two remote hosts are gone from the policy.
`'unsafe-inline'` stays on `style-src` because React writes inline `style`
attributes. It is deliberately absent from `script-src`.
"""
_HEADERS = [ _HEADERS = [
(b"x-content-type-options", b"nosniff"), (b"x-content-type-options", b"nosniff"),
@@ -79,10 +90,13 @@ class SecurityHeadersMiddleware:
b"content-security-policy", b"content-security-policy",
b"default-src 'self'; " b"default-src 'self'; "
b"script-src 'self'; " b"script-src 'self'; "
b"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " b"style-src 'self' 'unsafe-inline'; "
b"font-src https://fonts.gstatic.com; " b"font-src 'self'; "
b"img-src 'self' data:; " b"img-src 'self' data:; "
b"connect-src 'self'; " b"connect-src 'self'; "
b"object-src 'none'; "
b"base-uri 'none'; "
b"form-action 'self'; "
b"frame-ancestors 'none'", b"frame-ancestors 'none'",
), ),
] ]
@@ -171,6 +185,13 @@ class SPAStaticFiles(StaticFiles):
return response return response
# Python's mimetypes table has no entry for woff2 on a slim Debian image, so
# StaticFiles served the self-hosted fonts as application/octet-stream. Browsers
# take them anyway — a @font-face src carries its own format() hint — but the
# honest type costs one line.
mimetypes.add_type("font/woff2", ".woff2")
mimetypes.add_type("font/woff", ".woff")
frontend_dist = Path(__file__).resolve().parent.parent.parent / "frontend" / "dist" frontend_dist = Path(__file__).resolve().parent.parent.parent / "frontend" / "dist"
if frontend_dist.is_dir(): if frontend_dist.is_dir():
app.mount("/", SPAStaticFiles(directory=frontend_dist, html=True), name="frontend") app.mount("/", SPAStaticFiles(directory=frontend_dist, html=True), name="frontend")
+10 -4
View File
@@ -3,7 +3,7 @@ from typing import AsyncIterator
import httpx import httpx
from .. import debuglog, netguard from .. import debuglog, netguard, tlstrust
from .base import PromptParts, Provider, ProviderError from .base import PromptParts, Provider, ProviderError
# Appended after the story text in chat mode, so a chat-tuned model continues # Appended after the story text in chat mode, so a chat-tuned model continues
@@ -247,7 +247,9 @@ class OpenAICompatibleProvider(Provider):
log = debuglog.start_entry(url, self.model, body) log = debuglog.start_entry(url, self.model, body)
received: list[str] = [] received: list[str] = []
try: try:
async with httpx.AsyncClient(timeout=httpx.Timeout(120, connect=10)) as client: async with httpx.AsyncClient(
timeout=httpx.Timeout(120, connect=10), verify=tlstrust.ssl_context()
) as client:
async with client.stream("POST", url, json=body, headers=self._headers()) as resp: async with client.stream("POST", url, json=body, headers=self._headers()) as resp:
if resp.status_code != 200: if resp.status_code != 200:
detail = (await resp.aread()).decode(errors="replace")[:500] detail = (await resp.aread()).decode(errors="replace")[:500]
@@ -354,7 +356,9 @@ class OpenAICompatibleProvider(Provider):
log = debuglog.start_entry(url, self.model, body) log = debuglog.start_entry(url, self.model, body)
try: try:
async with httpx.AsyncClient(timeout=httpx.Timeout(120, connect=10)) as client: async with httpx.AsyncClient(
timeout=httpx.Timeout(120, connect=10), verify=tlstrust.ssl_context()
) as client:
resp = await client.post(url, json=body, headers=self._headers()) resp = await client.post(url, json=body, headers=self._headers())
except httpx.HTTPError as exc: except httpx.HTTPError as exc:
debuglog.finish_entry(log, error=str(exc)) debuglog.finish_entry(log, error=str(exc))
@@ -381,7 +385,9 @@ class OpenAICompatibleProvider(Provider):
body = {"model": self.model, "input": texts} body = {"model": self.model, "input": texts}
log = debuglog.start_entry(url, self.model, body) log = debuglog.start_entry(url, self.model, body)
try: try:
async with httpx.AsyncClient(timeout=httpx.Timeout(60, connect=10)) as client: async with httpx.AsyncClient(
timeout=httpx.Timeout(60, connect=10), verify=tlstrust.ssl_context()
) as client:
resp = await client.post(url, json=body, headers=self._headers()) resp = await client.post(url, json=body, headers=self._headers())
except httpx.HTTPError as exc: except httpx.HTTPError as exc:
debuglog.finish_entry(log, error=str(exc)) debuglog.finish_entry(log, error=str(exc))
+2 -2
View File
@@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from starlette.concurrency import run_in_threadpool from starlette.concurrency import run_in_threadpool
from .. import auth, limits, models, netguard, schemas, security from .. import auth, limits, models, netguard, schemas, security, tlstrust
from ..database import get_db from ..database import get_db
router = APIRouter(prefix="/api/settings", tags=["settings"]) router = APIRouter(prefix="/api/settings", tags=["settings"])
@@ -95,7 +95,7 @@ async def list_endpoint_models(cfg: auth.ProviderConfig) -> dict:
if cfg.api_key: if cfg.api_key:
headers["Authorization"] = f"Bearer {cfg.api_key}" headers["Authorization"] = f"Bearer {cfg.api_key}"
try: try:
async with httpx.AsyncClient(timeout=10) as client: async with httpx.AsyncClient(timeout=10, verify=tlstrust.ssl_context()) as client:
resp = await client.get(url, headers=headers) resp = await client.get(url, headers=headers)
except httpx.HTTPError as exc: except httpx.HTTPError as exc:
return {"ok": False, "detail": f"Connection failed: {exc}"} return {"ok": False, "detail": f"Connection failed: {exc}"}
+47
View File
@@ -0,0 +1,47 @@
"""Verify TLS against this machine's own trust store as well as certifi's.
`httpx` verifies against the `certifi` bundle, which carries the public web's
certificate authorities and nothing else. A trusted-LAN inference host often
has no public certificate: on a StartOS server, Ollama is served over HTTPS
with a certificate from a local CA that the user installs on the machines they
use it from. `curl` and the browser accepted such an endpoint; this
application refused it:
Connection failed: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify
failed: self-signed certificate in certificate chain
That is a wrong answer for the deployment this project targets
(`planning/DECISIONS/002-ollama-only-v1.md`), because the user had already
made the decision to trust that CA, at the level where such decisions belong.
So the rule is the one a user already expects from everything else on their
machine: **a CA installed on this host is trusted by this application.** This
is not a relaxation of verification. Certificates are still verified, hostnames
are still checked, and a certificate signed by nobody the machine trusts is
still refused — `AIDND_ENDPOINT_INSECURE` and its like deliberately do not
exist.
The two stores are unioned rather than swapped. `ssl.create_default_context()`
alone would be a behaviour *change* — it loads only the platform's default CA
locations (`/etc/ssl/certs` on Debian and Ubuntu), and a stripped-down image
whose system store is empty or stale would start failing on endpoints that
used to work. Adding certifi on top makes this a strict superset of the old
behaviour, so nothing that verified before can stop verifying now.
Building a context parses every certificate in both stores, so it is done once
and cached. The result is read-only afterwards and is shared safely across
concurrent requests.
"""
import functools
import ssl
import certifi
@functools.lru_cache(maxsize=1)
def ssl_context() -> ssl.SSLContext:
"""The verification context every outbound HTTPS client should use."""
context = ssl.create_default_context() # the platform's CA store
context.load_verify_locations(cafile=certifi.where()) # plus the public web's
return context
+57
View File
@@ -0,0 +1,57 @@
# Exact versions of the whole dependency closure, including transitive ones.
#
# requirements.txt states the ranges the code needs; this file states what was
# actually installed and tested, so a fresh checkout reproduces a known-good
# environment instead of resolving whatever is newest that day. It covers the
# dev/test dependencies too, because the regression report is only meaningful
# against a pinned suite.
#
# python3 -m venv backend/.venv
# backend/.venv/bin/pip install -r backend/requirements.lock
#
# Regenerate after a deliberate upgrade, by installing from requirements.txt
# and requirements-dev.txt and re-running the suite:
#
# backend/.venv/bin/pip freeze > backend/requirements.lock # then restore this header
#
# `pip` itself is deliberately left out: it is the tool, not a dependency.
annotated-doc==0.0.5
annotated-types==0.8.0
anyio==4.14.2
certifi==2026.7.22
cffi==2.1.1
charset-normalizer==3.5.1
click==8.5.0
cryptography==50.0.1
fastapi==0.141.1
greenlet==3.5.5
h11==0.16.0
httpcore==1.0.9
httptools==0.8.0
httpx==0.28.1
idna==3.19
iniconfig==2.3.0
packaging==26.3
pluggy==1.6.0
psycopg==3.3.5
psycopg-binary==3.3.5
pycparser==3.0
pydantic==2.13.5
pydantic_core==2.46.5
Pygments==2.21.0
pytest==9.1.1
python-dotenv==1.2.3
PyYAML==6.0.3
quickjs==1.19.4
regex==2026.9.3
requests==2.34.2
SQLAlchemy==2.0.52
starlette==1.6.0
tiktoken==0.14.0
typing-inspection==0.4.4
typing_extensions==4.16.0
urllib3==2.7.0
uvicorn==0.52.4
uvloop==0.22.1
watchfiles==1.2.0
websockets==17.1
+4
View File
@@ -3,6 +3,10 @@ uvicorn[standard]>=0.30
sqlalchemy>=2.0 sqlalchemy>=2.0
pydantic>=2.7 pydantic>=2.7
httpx>=0.27 httpx>=0.27
# Imported directly by app/tlstrust.py, which unions this bundle with the
# machine's own CA store. It arrives with httpx anyway; declared because the
# code imports it by name.
certifi
tiktoken>=0.7 tiktoken>=0.7
quickjs>=1.19 quickjs>=1.19
cryptography>=42 cryptography>=42
+160
View File
@@ -0,0 +1,160 @@
"""Guards on the offline baseline: nothing a story turn needs is fetched.
Phase 0B ran the upstream application on a network with no route out and the
first turn died in `tiktoken`, which downloads its BPE table the first time
anything counts a token — invisible on a machine that had already been online
once. The browser separately pulled three font families from Google on every
page load. Both are now local, and these tests fail if either comes back.
The distinction that matters here is *runtime* assets. Downloading a dependency
at install time is fine; downloading one while the user is playing is not.
python -m pytest tests/test_offline_assets.py -v
Acceptance tests A01, H01 and H11.
"""
import hashlib
import inspect
import re
import socket
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from app.context import builder, encoding
from app.main import app
REPO = Path(__file__).resolve().parents[2]
FRONTEND = REPO / "frontend"
# Any absolute http(s) URL. Matched against the places a browser would actually
# be told to fetch from: markup attributes and CSS url()/@import.
REMOTE_URL = re.compile(rb"https?://[^\s\"'()>]+")
@pytest.fixture
def client():
with TestClient(app) as c:
yield c
# --- the tokenizer -------------------------------------------------------
def test_vendored_table_is_present_and_intact():
"""The digest is the whole reason the vendored copy is trustworthy."""
data = encoding.BPE_PATH.read_bytes()
assert hashlib.sha256(data).hexdigest() == encoding.BPE_SHA256
def test_pinned_digest_still_matches_tiktokens_own():
"""`tiktoken` hardcodes the digest it expects for `cl100k_base`. If an
upgrade ever points that name at a different table, the vendored copy is
stale and every token count would quietly disagree with upstream's."""
import tiktoken_ext.openai_public
source = inspect.getsource(tiktoken_ext.openai_public.cl100k_base)
assert encoding.BPE_SHA256 in source
assert encoding.SOURCE_URL in source
def test_token_counting_makes_no_network_call(monkeypatch):
"""The real regression. `count_tokens` runs on every turn; on a host with
no route out, the upstream version raised ConnectionError instead of
narrating."""
encoding.get_encoding.cache_clear()
def refuse(*args, **kwargs):
raise AssertionError("the tokenizer tried to open a socket")
monkeypatch.setattr(socket, "socket", refuse)
monkeypatch.setattr(socket, "create_connection", refuse)
monkeypatch.setattr(socket, "getaddrinfo", refuse)
try:
assert builder.count_tokens("The lighthouse keeper's lamp guttered.") > 0
finally:
encoding.get_encoding.cache_clear()
def test_token_counts_are_unchanged():
"""Golden values from the encoding built by upstream's own
`tiktoken.get_encoding("cl100k_base")`, checked equal token for token
across ASCII and accented text. The context
budget is computed from these numbers, so a silently different tokenizer
would silently change what fits in a prompt."""
assert builder.count_tokens("") == 0
assert builder.count_tokens("hello world") == 2
assert builder.count_tokens(
"The lighthouse keeper's lamp guttered in the salt wind."
) == 13
assert builder.count_tokens("naïve café — résumé") == 8
def test_round_trip_survives_the_awkward_characters():
enc = encoding.get_encoding()
for text in ("", "naïve café — résumé 🜂 龍", "tabs\tand\r\nCRLF", "a" * 500):
assert enc.decode(enc.encode(text)) == text
# --- the browser ---------------------------------------------------------
def test_csp_names_no_remote_origin(client):
csp = client.get("/api/health").headers["content-security-policy"]
assert "fonts.googleapis.com" not in csp
assert "fonts.gstatic.com" not in csp
assert "//" not in csp, f"CSP still allows a remote origin: {csp}"
assert "font-src 'self'" in csp
assert "connect-src 'self'" in csp
def test_index_html_fetches_nothing_remote():
html = (FRONTEND / "index.html").read_bytes()
# The comment explaining where the fonts went is not a fetch, so match
# attributes rather than the whole file.
for attr in re.findall(rb"(?:href|src)\s*=\s*\"([^\"]*)\"", html):
assert not REMOTE_URL.match(attr), attr
def test_stylesheets_fetch_nothing_remote():
for css in sorted((FRONTEND / "src").rglob("*.css")):
text = css.read_bytes()
for statement in re.findall(rb"(?:url|@import)\s*\(?[^;{}]*", text):
assert not REMOTE_URL.search(statement), f"{css.name}: {statement}"
def test_every_declared_font_file_exists():
"""A @font-face pointing at a file that is not in the tree falls back to a
system font on the developer's machine and 404s on a user's."""
css = (FRONTEND / "src" / "styles" / "fonts.css").read_text()
declared = re.findall(r"url\('/fonts/([^']+)'\)", css)
assert declared, "fonts.css declares no faces"
for name in declared:
assert (FRONTEND / "public" / "fonts" / name).is_file(), name
def _without_comments(text: bytes) -> bytes:
"""Comments name the hosts these files no longer talk to — the note in
`index.html` saying where the fonts went, and the `/* from ... */` line
recording where each vendored face was downloaded from. Both are
documentation. Strip them, then the check below can be blunt."""
text = re.sub(rb"<!--.*?-->", b"", text, flags=re.S)
return re.sub(rb"/\*.*?\*/", b"", text, flags=re.S)
@pytest.mark.skipif(
not (Path(__file__).resolve().parents[2] / "frontend" / "dist" / "index.html").exists(),
reason="SPA not built; run `npm run build` in frontend/ to check the shipped bundle",
)
def test_built_spa_fetches_no_fonts_remotely():
"""The source is what the tests above read; this is what users are served."""
dist = FRONTEND / "dist"
for path in list(dist.rglob("*.html")) + list(dist.rglob("*.css")):
text = _without_comments(path.read_bytes())
assert b"fonts.googleapis.com" not in text, path
assert b"fonts.gstatic.com" not in text, path
for path in dist.rglob("*.css"):
for url in re.findall(rb"url\(([^)]*)\)", _without_comments(path.read_bytes())):
assert not REMOTE_URL.search(url), f"{path}: {url}"
+93
View File
@@ -0,0 +1,93 @@
"""Guards on outbound TLS verification.
A trusted-LAN Ollama is often served over HTTPS with a certificate from a CA
the user installed on their own machines rather than one the public web knows.
`httpx` verifies against `certifi` alone, so such an endpoint failed here while
`curl` and the browser accepted it. `app/tlstrust.py` unions the machine's CA
store with certifi's; these tests keep that union honest in both directions —
it must not lose a public CA, and it must not stop verifying.
Everything here is local. Nothing in this file opens a socket, so the suite
still runs with no network.
python -m pytest tests/test_tls_trust.py -v
"""
import ast
import ssl
from pathlib import Path
import certifi
import pytest
from app import tlstrust
APP = Path(__file__).resolve().parents[1] / "app"
def test_verification_is_not_weakened():
"""The point of the change is *where* trust comes from, never whether it is
checked. A context that skipped verification would make every one of these
endpoints reachable, including a hostile one."""
context = tlstrust.ssl_context()
assert context.verify_mode is ssl.CERT_REQUIRED
assert context.check_hostname is True
def test_context_is_built_once():
assert tlstrust.ssl_context() is tlstrust.ssl_context()
def test_public_certificate_authorities_are_still_trusted():
"""The union is a strict superset of what httpx trusted before. Swapping
certifi for the platform store instead would quietly break public
endpoints on an image whose system store is empty or stale."""
ours = {c for c in tlstrust.ssl_context().get_ca_certs(binary_form=True)}
certifi_only = ssl.create_default_context(cafile=certifi.where())
theirs = {c for c in certifi_only.get_ca_certs(binary_form=True)}
assert theirs, "certifi's bundle came back empty; the comparison proves nothing"
assert theirs <= ours, f"{len(theirs - ours)} certifi roots are missing from the union"
def _async_client_calls(path: Path):
"""Every `httpx.AsyncClient(...)` construction in a module, as AST nodes."""
tree = ast.parse(path.read_text())
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
func = node.func
if (
isinstance(func, ast.Attribute)
and func.attr == "AsyncClient"
and isinstance(func.value, ast.Name)
and func.value.id == "httpx"
):
yield node
@pytest.mark.parametrize(
"module",
["providers/openai_compatible.py", "routers/settings.py"],
)
def test_every_http_client_uses_the_shared_context(module):
"""Checked in the source rather than at runtime, because the failure this
catches is a *new* client added later without the context — which no
existing test would exercise, and which would work perfectly until someone
pointed it at a LAN endpoint."""
calls = list(_async_client_calls(APP / module))
assert calls, f"no httpx.AsyncClient found in {module} — has it been renamed?"
for call in calls:
keywords = {kw.arg for kw in call.keywords}
assert "verify" in keywords, (
f"{module}:{call.lineno} builds an httpx.AsyncClient without "
f"verify=tlstrust.ssl_context()"
)
def test_no_other_module_builds_its_own_client():
"""If a third module starts making outbound requests, it has to be added to
the list above rather than inheriting certifi-only trust by default."""
known = {APP / "providers/openai_compatible.py", APP / "routers/settings.py"}
found = {p for p in APP.rglob("*.py") if any(_async_client_calls(p))}
assert found == known, f"unexpected httpx.AsyncClient call sites: {found - known}"
+6 -1
View File
@@ -2,7 +2,12 @@ services:
ai-dnd: ai-dnd:
build: . build: .
ports: ports:
- "8000:8000" # Loopback on the host on purpose. The container listens on 0.0.0.0
# because that is the only address a published port can reach, but the
# storyteller UI/API is single-user and unauthenticated in local mode,
# so it must not be published to the LAN. Reaching an Ollama on another
# machine is a separate, outbound thing and needs no change here.
- "127.0.0.1:8000:8000"
volumes: volumes:
- ai-dnd-data:/data - ai-dnd-data:/data
restart: unless-stopped restart: unless-stopped
+4 -6
View File
@@ -11,12 +11,10 @@
name="viewport" name="viewport"
content="width=device-width, initial-scale=1.0, interactive-widget=resizes-content" content="width=device-width, initial-scale=1.0, interactive-widget=resizes-content"
/> />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <!-- The three families this design uses are self-hosted, declared in
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> src/styles/fonts.css and served from /fonts/. They used to be linked
<link from fonts.googleapis.com, which made every page load an Internet
href="https://fonts.googleapis.com/css2?family=Cinzel:wght@500;600;700&family=Crimson+Pro:ital,wght@0,400;0,500;0,600;1,400&family=Inter:wght@400;500;600&display=swap" request; see frontend/tools/vendor_fonts.py. -->
rel="stylesheet"
/>
<title>AI D&amp;D</title> <title>AI D&amp;D</title>
</head> </head>
<body> <body>
+93
View File
@@ -0,0 +1,93 @@
Copyright 2020 The Cinzel Project Authors (https://github.com/NDISCOVER/Cinzel)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2018 The Crimson Pro Project Authors (https://github.com/Fonthausen/CrimsonPro)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2020 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
View File
@@ -8,6 +8,7 @@
Add a new section by adding a file and an `@import` for it. Put the import Add a new section by adding a file and an `@import` for it. Put the import
where the rules belong in the cascade, not at the end by habit. where the rules belong in the cascade, not at the end by habit.
*/ */
@import './styles/fonts.css'; /* @font-face for the self-hosted families */
@import './styles/tokens.css'; /* custom properties: colors, fonts, spacing */ @import './styles/tokens.css'; /* custom properties: colors, fonts, spacing */
@import './styles/base.css'; /* scrollbars */ @import './styles/base.css'; /* scrollbars */
@import './styles/nav.css'; /* the top navigation bar */ @import './styles/nav.css'; /* the top navigation bar */
+90
View File
@@ -0,0 +1,90 @@
/* Generated by frontend/tools/vendor_fonts.py — do not hand-edit.
Self-hosted so the SPA makes no request to fonts.googleapis.com or
fonts.gstatic.com at runtime. See the script for why, and for how to
change a family, weight, or subset.
Files live in frontend/public/fonts/ and are served by the app itself
at /fonts/..., which is what the `font-src 'self'` CSP in
backend/app/main.py allows.
*/
/* Cinzel normal — from https://fonts.gstatic.com/s/cinzel/v26/8vIJ7ww63mVu7gt7-GT7LEc.woff2 */
@font-face {
font-family: 'Cinzel';
font-style: normal;
font-weight: 400 900;
font-display: swap;
src: url('/fonts/cinzel-normal-latin-ext.woff2') format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* Cinzel normal — from https://fonts.gstatic.com/s/cinzel/v26/8vIJ7ww63mVu7gt79mT7.woff2 */
@font-face {
font-family: 'Cinzel';
font-style: normal;
font-weight: 400 900;
font-display: swap;
src: url('/fonts/cinzel-normal-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* Crimson Pro italic — from https://fonts.gstatic.com/s/crimsonpro/v28/q5uBsoa5M_tv7IihmnkabARekY1wDfKi.woff2 */
@font-face {
font-family: 'Crimson Pro';
font-style: italic;
font-weight: 200 900;
font-display: swap;
src: url('/fonts/crimson-pro-italic-latin-ext.woff2') format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* Crimson Pro italic — from https://fonts.gstatic.com/s/crimsonpro/v28/q5uBsoa5M_tv7IihmnkabARekYNwDQ.woff2 */
@font-face {
font-family: 'Crimson Pro';
font-style: italic;
font-weight: 200 900;
font-display: swap;
src: url('/fonts/crimson-pro-italic-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* Crimson Pro normal — from https://fonts.gstatic.com/s/crimsonpro/v28/q5uDsoa5M_tv7IihmnkabARVoYFoCQ.woff2 */
@font-face {
font-family: 'Crimson Pro';
font-style: normal;
font-weight: 200 900;
font-display: swap;
src: url('/fonts/crimson-pro-normal-latin-ext.woff2') format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* Crimson Pro normal — from https://fonts.gstatic.com/s/crimsonpro/v28/q5uDsoa5M_tv7IihmnkabARboYE.woff2 */
@font-face {
font-family: 'Crimson Pro';
font-style: normal;
font-weight: 200 900;
font-display: swap;
src: url('/fonts/crimson-pro-normal-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* Inter normal — from https://fonts.gstatic.com/s/inter/v20/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa25L7SUc.woff2 */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url('/fonts/inter-normal-latin-ext.woff2') format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* Inter normal — from https://fonts.gstatic.com/s/inter/v20/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1ZL7.woff2 */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url('/fonts/inter-normal-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
+137
View File
@@ -0,0 +1,137 @@
#!/usr/bin/env python3
"""Re-vendor the web fonts the SPA uses into `frontend/public/fonts/`.
The upstream `index.html` linked Google Fonts, so every page load fetched a
stylesheet from `fonts.googleapis.com` and font files from `fonts.gstatic.com`.
That is a runtime Internet dependency and a third party learning when the story
is being read, both of which the local-only requirement rules out (acceptance
tests A01, H01 and H11).
This script downloads the same faces once, at development time, and writes
`frontend/src/styles/fonts.css` pointing at the local copies. It needs the
Internet; the application never does. Run it only when a family, weight, or
subset needs to change, and commit what it produces.
python3 frontend/tools/vendor_fonts.py
Variable fonts are requested on purpose: one file per family per subset covers
every weight the design uses, instead of one file per weight.
"""
import re
import sys
import urllib.request
from pathlib import Path
# Chrome's UA: the Google Fonts CSS API serves woff2 only to a client it
# believes supports it, and returns older formats to anything it doesn't
# recognize.
UA = (
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/140.0.0.0 Safari/537.36"
)
CSS_URL = (
"https://fonts.googleapis.com/css2"
"?family=Cinzel:wght@400..900"
"&family=Crimson+Pro:ital,wght@0,200..900;1,200..900"
"&family=Inter:wght@100..900"
"&display=swap"
)
# Latin and Latin Extended cover the alphabets this interface and its prose are
# written in. Greek/Cyrillic/Vietnamese subsets are skipped rather than
# vendored unused; text in them falls back to the system stack in
# `tokens.css`. Add the subset here if that stops being acceptable.
SUBSETS = ("latin", "latin-ext")
ROOT = Path(__file__).resolve().parent.parent
FONT_DIR = ROOT / "public" / "fonts"
CSS_OUT = ROOT / "src" / "styles" / "fonts.css"
BLOCK_RE = re.compile(
r"/\* (?P<subset>[a-z-]+) \*/\s*@font-face \{(?P<body>.*?)\}", re.S
)
def _field(body: str, name: str) -> str:
match = re.search(rf"{name}:\s*(.*?);", body, re.S)
if not match:
raise SystemExit(f"no {name} in @font-face block:\n{body}")
return match.group(1).strip()
def _get(url: str) -> bytes:
request = urllib.request.Request(url, headers={"User-Agent": UA})
with urllib.request.urlopen(request, timeout=60) as response:
return response.read()
def main() -> int:
css = _get(CSS_URL).decode()
FONT_DIR.mkdir(parents=True, exist_ok=True)
faces = []
for block in BLOCK_RE.finditer(css):
subset = block.group("subset")
if subset not in SUBSETS:
continue
body = block.group("body")
family = _field(body, "font-family").strip("'\"")
style = _field(body, "font-style")
url = re.search(r"url\((.*?)\)", _field(body, "src")).group(1)
slug = family.lower().replace(" ", "-")
name = f"{slug}-{style}-{subset}.woff2"
(FONT_DIR / name).write_bytes(_get(url))
faces.append(
{
"family": family,
"style": style,
"weight": _field(body, "font-weight"),
"range": _field(body, "unicode-range"),
"file": name,
"source": url,
}
)
if not faces:
raise SystemExit("no @font-face blocks matched the requested subsets")
lines = [
"/* Generated by frontend/tools/vendor_fonts.py — do not hand-edit.",
"",
" Self-hosted so the SPA makes no request to fonts.googleapis.com or",
" fonts.gstatic.com at runtime. See the script for why, and for how to",
" change a family, weight, or subset.",
"",
" Files live in frontend/public/fonts/ and are served by the app itself",
" at /fonts/..., which is what the `font-src 'self'` CSP in",
" backend/app/main.py allows.",
"*/",
"",
]
for face in faces:
lines += [
f"/* {face['family']} {face['style']} — from {face['source']} */",
"@font-face {",
f" font-family: '{face['family']}';",
f" font-style: {face['style']};",
f" font-weight: {face['weight']};",
" font-display: swap;",
f" src: url('/fonts/{face['file']}') format('woff2');",
f" unicode-range: {face['range']};",
"}",
"",
]
CSS_OUT.write_text("\n".join(lines))
total = sum((FONT_DIR / f["file"]).stat().st_size for f in faces)
print(f"vendored {len(faces)} faces, {total / 1024:.0f} KiB, into {FONT_DIR}")
print(f"wrote {CSS_OUT}")
return 0
if __name__ == "__main__":
sys.exit(main())
+466
View File
@@ -0,0 +1,466 @@
# M1 — Production Fork and Offline Baseline: evidence report
**Date:** 2026-09-01/02
**Milestone:** M1, `planning/BUILD-MILESTONES.md`
**Base:** AI-DnD `d72f7c1bda0f34fccd84afb7a25c34eb01c901de` (see `PROVENANCE.md`)
**Scope note:** M1 only. No M2 work was started; nothing was removed from the
inherited hosted/cloud/scripting surface.
This report records what was run and what was observed. Where a condition was
not reproduced exactly as the acceptance test specifies, it says so and says
what was reproduced instead. Nothing untested is called a PASS.
Hostnames and LAN addresses below are **placeholders** — `inference.lan`,
`192.168.0.0/24`. The real ones are in the workspace's untracked notes, not in
this repository. Everything else, including packet counts and digests, is
verbatim.
---
## 1. Test environment
| | |
| --- | --- |
| Host | Ubuntu 24.04.4 LTS, x86-64, 4 cores, 15 GB RAM, **no GPU** |
| Python | 3.12.3 |
| Node / npm | 22.23.1 / 10.9.8 |
| Docker | 29.7.2 |
| Ollama | 0.33.2 (`ollama/ollama@sha256:020e4134285e…`) |
| Narrator model | `qwen2.5:3b-instruct` (`qwen2.5:0.5b` in one earlier run) |
| Second machine | `inference.lan` (`192.168.0.50`), StartOS, Ollama 0.33.0 over HTTPS on 8443 |
| Embedding model | `nomic-embed-text:latest` |
| Application | this fork, working tree on `m1-production-baseline` |
| Browser | the maintainer's own desktop browser, against the native run (§6) |
The lack of a GPU matters and shows up twice below: a cold model load on four
CPU cores can exceed the application's hardcoded 120-second model timeout.
## 2. What was changed
Thirteen upstream files modified; twenty-two added, of which eleven are the
vendored font files and their licences. No upstream file was deleted.
| Change | Files |
| --- | --- |
| Fork lineage and licence provenance | merge commit `46d34dc`, `PROVENANCE.md` |
| Tokenizer no longer downloads | `backend/app/context/encoding.py`, `backend/app/context/vendor/cl100k_base.tiktoken`, `backend/app/context/builder.py` |
| Fonts self-hosted | `frontend/index.html`, `frontend/src/index.css`, `frontend/src/styles/fonts.css`, `frontend/public/fonts/*`, `frontend/tools/vendor_fonts.py`, `.gitattributes` |
| CSP narrowed to same-origin | `backend/app/main.py` |
| TLS verified against the machine's own CA store as well as certifi's | `backend/app/tlstrust.py`, `backend/app/providers/openai_compatible.py`, `backend/app/routers/settings.py`, `backend/requirements.txt` |
| `woff2` served with its real media type | `backend/app/main.py` |
| Loopback binding stated explicitly | `start.sh`, `start.ps1`, `docker-compose.yml`, `Dockerfile` |
| Reproducible environment | `backend/requirements.lock`, `DEVELOPMENT.md` |
| Regression tests | `backend/tests/test_offline_assets.py`, `backend/tests/test_tls_trust.py` |
| Research scratch ignored | `.gitignore` |
## 3. Regression suite
| Suite | Before M1 | After M1 |
| --- | --- | --- |
| `backend/tests` | 632 passed, 0 failed (184.7 s) | **648 passed, 0 failed** (212.1 s) |
| `frontend`: `npm run lint` | — | exit 0 (6 pre-existing `only-export-components` warnings) |
| `frontend`: `npm run build` | — | succeeds |
| `docker build` | — | succeeds |
The 632-test baseline was captured on the pinned upstream commit before any
change, and matches the Phase 0B figure. The sixteen new tests are
`test_offline_assets.py` (10) and `test_tls_trust.py` (6). **There are no known
failing tests and no documented exceptions.**
The two `dist`-reading tests in `test_offline_assets.py` skip if the SPA has
not been built; the run above had it built, so they executed.
### Proof the tokenizer guard is a real guard
With `TIKTOKEN_CACHE_DIR` pointed at an empty directory and Python's socket
functions replaced:
```text
UPSTREAM PATH raises: AssertionError socket opened
VENDORED PATH: 2 tokens, no socket opened
```
The vendored table's SHA-256 is
`223921b76ee99bde995b7ff738513eef100fb51d18c93597a113bcffe865b2a7`, identical
to the digest hardcoded in `tiktoken_ext/openai_public.py`, and the encoding it
produces was checked token-for-token against `tiktoken.get_encoding` over
ASCII, accented text, CJK, emoji, CRLF and special-token literals.
## 4. Run 1 — offline, same-host Ollama
**Setup.** An `--internal` Docker network (no NAT, no external DNS). Ollama
runs in one container; the production image runs in a second container that
**shares Ollama's network namespace**, so Ollama is genuinely on the
storyteller's own loopback and neither has any route out. Both were driven from
a client in the same namespace, over `127.0.0.1:8000`.
**Isolation confirmed from inside the application container, before any test:**
```text
blocked 1.1.1.1:443 OSError: Network is unreachable
blocked openaipublic.blob.core.windows.net:443 gaierror
blocked fonts.googleapis.com:443 gaierror
blocked fonts.gstatic.com:443 gaierror
blocked openrouter.ai:443 gaierror
blocked github.com:443 gaierror
```
**Listening sockets in that namespace:**
```text
127.0.0.1:8000 uvicorn (the storyteller)
[::]:11434 ollama
127.0.0.11:44683 Docker's embedded DNS
```
**Story play.** Campaign "Continuity Test (3b)" created and played for six
turns, twelve actions, all offline:
```text
turn 1: 'The old lighthouse looms dark against the stormy sky, its silence heavy as the wind…'
turn 2: "Gripping the lantern's heavy brass handle, you flick it on and off, each attempt a futile…"
turn 3: 'The lamp room is dim and musty, the thick air choking your breath…'
turn 4: 'You find the dated handwriting, the last entry noting the storm started three days ago…'
turn 5: 'The night outside is a tempest, waves crashing against the shore with a deafening roar…'
turn 6: 'The cabinet is cold and heavy, the lock stubbornly refusing to budge…'
```
**Browser asset graph, fetched over loopback with no route out:**
```text
GET / 200 text/html
/favicon.svg 200 9 522 bytes
/assets/index-*.js 200 933 695 bytes
/assets/index-*.css 200 59 902 bytes
/fonts/cinzel-normal-latin.woff2 200 font/woff2 25 904
/fonts/cinzel-normal-latin-ext.woff2 200 font/woff2 14 540
/fonts/crimson-pro-normal-latin.woff2 200 font/woff2 48 200
/fonts/crimson-pro-normal-latin-ext.woff2 200 font/woff2 37 988
/fonts/crimson-pro-italic-latin.woff2 200 font/woff2 51 432
/fonts/crimson-pro-italic-latin-ext.woff2 200 font/woff2 39 808
/fonts/inter-normal-latin.woff2 200 font/woff2 48 256
/fonts/inter-normal-latin-ext.woff2 200 font/woff2 85 068
```
Every URL `index.html` references is same-origin. The response carried:
```text
content-security-policy: default-src 'self'; script-src 'self';
style-src 'self' 'unsafe-inline'; font-src 'self'; img-src 'self' data:;
connect-src 'self'; object-src 'none'; base-uri 'none'; form-action 'self';
frame-ancestors 'none'
x-content-type-options: nosniff referrer-policy: same-origin x-frame-options: DENY
```
**Packet capture** (tcpdump in the same namespace for the whole run, 6 074
packets):
```text
total packets: 6074
loopback (127.0.0.0/8): 6062
non-loopback unicast: 0
remainder (12): received mDNS / ICMPv6 router solicitations from the
bridge — inbound multicast, not sent by this namespace
TCP connections opened: 127.0.0.1:8000 (storyteller API)
127.0.0.1:11434 (Ollama)
127.0.0.1:11499 (the deliberately dead port in A05)
two ephemeral loopback ports (Ollama's model runner)
```
**One DNS observation, and it is not the storyteller.** Four queries appear,
all failing:
```text
127.0.0.11.53 > … ServFail q: A? ollama.com.
127.0.0.11.53 > … ServFail q: AAAA? ollama.com. (×2 each)
```
`ollama.com` is queried by **the Ollama server itself**, in the shared
namespace, not by the storyteller. It failed, nothing depended on it, and no
story data could have been in it. It is recorded here rather than dismissed:
on a deployment with a route to the Internet, the inference server has its own
outbound behaviour, and the storyteller's local-only guarantee does not extend
to it. Confirming and, if wanted, suppressing that is an Ollama configuration
question — worth settling before release, and out of M1's scope.
## 5. Run 2 — offline, Ollama on a second machine on the LAN
**Setup.** Ollama 0.33.0 on `inference.lan` (`192.168.0.50`), a **separate
physical machine** on the trusted LAN, serving HTTPS on port 8443 with a
certificate issued by `CN = StartOS Local Intermediate CA`. `qwen2.5:3b-instruct`
and `nomic-embed-text` were installed there for this run.
The storyteller runs in a container on this host with `NET_ADMIN`, its default
route **deleted** and replaced by a route to `192.168.0.0/24` only, its
resolver pointed at nothing, and `inference.lan` supplied as a static hosts
entry. So: the LAN is reachable, the Internet is not, and the endpoint is
configured explicitly rather than discovered. Uvicorn binds `127.0.0.1:8000`
inside that container.
```text
route table: 172.17.0.0/16 dev eth0 …
192.168.0.0/24 via 172.17.0.1 dev eth0 (no default route)
listeners: LISTEN 127.0.0.1:8000 (nothing on 172.17.0.3)
```
**Isolation, checked from inside before anything else:**
```text
outbound Internet
blocked 1.1.1.1:443 OSError: Network is unreachable
blocked 140.82.121.4:443 OSError: Network is unreachable
blocked 104.16.0.1:443 OSError: Network is unreachable
name resolution
no resolution github.com / openrouter.ai / fonts.gstatic.com /
openaipublic.blob.core.windows.net (gaierror)
the approved LAN host
inference.lan -> 192.168.0.50
TLS OK, peer CN = inference.lan
```
**Model discovery over the LAN endpoint:**
```json
{"ok": true, "models": ["qwen2.5:3b-instruct", "nomic-embed-text:latest"]}
```
**Story play.** Nine turns, then a tenth after a restart. That machine has
faster hardware than this one, and it shows:
```text
turn 1 (13.0s): "The lamp flickers faintly, a single coal barely keeping the structure's shadow at bay."
turn 2 ( 4.9s): "My lantern's light has failed. Checking the wick, I find it's burnt down to a stub."
turn 3 ( 4.4s): 'I turn the wick higher, attempting to coax more flame from the almost-embers.'
turn 4 ( 5.9s): 'The storm shutter blocks out everything but the dark ocean…'
turn 5 ( 6.8s): 'I insert the key and hear the satisfying click as the lock turns…'
turn 6 ( 8.1s): 'Inside the box, I find a small vial of oil and a note warning…'
turn 7 (10.6s) … turn 9 (14.8s)
```
**State extraction, summaries and embeddings** ran through the same endpoint:
the memory bank produced memories and embedded them with `nomic-embed-text` on
the remote host, so the narrator, the summarizer and the embedder all went
over the LAN, not just the narrator.
**Restart and resume.** The storyteller container was restarted while the
inference host was left alone:
```text
before restart: 18 actions, head 18, digest f24caf86a744ab36
after restart: 18 actions, head 18, digest f24caf86a744ab36
endpoint still https://inference.lan:8443/v1
embedding model still nomic-embed-text:latest, memories intact
next turn (6.4s): 'You make your way back to the lighthouse, lantern in hand…'
```
**Packet capture** across the whole run:
```text
all packets: 1633
loopback (127.0.0.0/8): 730
to/from inference.lan 192.168.0.50: 893
any other unicast: 0
TCP connections opened: 192.168.0.50:8443 (19)
127.0.0.1:8000 (17)
DNS queries: none — no name was looked up at all
```
Zero packets to anything but the storyteller's own loopback and the approved
inference host, and not a single DNS query, because the endpoint was configured
rather than resolved.
### 5.1 What had to be fixed to get here: TLS trust
The first attempt failed, and the failure was the application's:
```text
{"ok": false, "detail": "Connection failed: [SSL: CERTIFICATE_VERIFY_FAILED]
certificate verify failed: self-signed certificate in certificate chain"}
```
`httpx` verifies against the `certifi` bundle, which carries the public web's
CAs and nothing else. The host's certificate comes from a local StartOS CA that
the user had already installed at `/usr/local/share/ca-certificates/local-ca.crt`,
which is why `curl` and the browser accepted the same endpoint on the same
machine. Confirmed directly:
```text
certifi bundle (httpx default) FAIL SSLCertVerificationError
system trust store OK peer CN=inference.lan
```
This is not an edge case for this product. A trusted-LAN inference host is a
first-class v1 deployment (`planning/DECISIONS/002-ollama-only-v1.md`), and such
a host is unlikely to hold a publicly-issued certificate.
`backend/app/tlstrust.py` builds one verification context that **unions** the
platform CA store with certifi's bundle, and all four outbound HTTP clients use
it. Deliberately a union rather than a swap: using the platform store alone
would be a behaviour change, and an image with an empty or stale system store
would start failing on endpoints that used to work. A union can only add trust
the user has already granted at the operating-system level.
Verification itself is untouched — `verify_mode=CERT_REQUIRED`,
`check_hostname=True`, and no "insecure" escape hatch was added. Measured after
the change:
```text
OK inference.lan (local CA) CN=inference.lan
OK github.com (public CA) CN=github.com
OK pypi.org (public CA) CN=pypi.org
certifi-only context still rejects inference.lan (so the union is what changed)
```
`backend/tests/test_tls_trust.py` holds the line in both directions: it asserts
every certifi root survives in the union, that verification is not weakened,
and — by walking the AST of the two modules that make outbound requests — that
no fifth HTTP client is ever added without the shared context.
### 5.2 An earlier container-only run
Before a second machine was available, the same sequence was run with Ollama in
a separate container, network namespace and IP on an `--internal` network. It produced the
same result (all 20 model requests to the configured endpoint, zero other
unicast packets) and is superseded by the run above, which has the physical
separation A06 actually asks for.
## 6. Run 3 — native (non-Docker) run, same-host Ollama
The production build run directly from the venv, SPA served by FastAPI, Ollama
on host loopback:
```text
LISTEN 0 2048 127.0.0.1:8000 users:(("uvicorn",pid=341269,fd=14))
LISTEN 0 4096 127.0.0.1:11434
connect to 192.168.0.10:8000 (this host's LAN address) -> Connection refused
GET http://127.0.0.1:8000/ -> 200
```
A campaign was created and played on this instance too. **This run had Internet
available** — its purpose was the native listener check and a real end-to-end
run outside Docker, not the offline proof, which Runs 1 and 2 carry.
**The UI was opened in a real browser.** No browser automation was available in
this session, so this step was done by hand: the maintainer loaded
<http://127.0.0.1:8000>, saw the campaign list render, clicked into the
"Continuity Test (native run)" campaign, and saw the adventure and its
transcript. So the inherited SPA loads, runs, and talks to the API from a
browser — the last M1 claim that had been resting on inference rather than
observation.
Two limits on what that shows, stated so the evidence is not read wider than it
is. It was this native run, which **had Internet available**, so it is not
itself offline evidence; the offline proof that the page needs nothing remote
is the asset-graph fetch in §4, made with no route out. And the browser's
network panel was not inspected, so "the browser requested nothing external" is
carried by §4 and by the CSP — which names no remote origin and would block one
— rather than by a devtools capture.
## 7. Acceptance results
| ID | Result | Evidence |
| --- | --- | --- |
| **A01** Start application offline | **PASS** | Run 1: app started, campaign created, six turns generated, with `1.1.1.1` unreachable and no name resolving. The "open UI" step is covered by the asset-graph fetch there and by the browser render in §6. |
| **A02** Storyteller loopback default | **PASS** | Run 1 and Run 2 listeners are `127.0.0.1:8000` only; Run 3 refuses connections on the host's LAN address; `docker-compose.yml` publishes to `127.0.0.1`. |
| **A03** No cloud API key | **PASS** | `api_key` empty in every run; connection test, turns, summaries and embeddings all succeeded. |
| **A04** Campaign survives restart | **PASS** | Run 1: 12 actions before and after a container restart, identical transcript and head. Run 2: identical digest `dd59e2a564df4e62` across restart, then play resumed. |
| **A05** Failed model call does not corrupt story | **PASS** | Two induced failures (nonexistent model; dead endpoint port) plus one natural timeout. Accepted-prefix digest `2ca6ab528178e44e` unchanged through all of it; AI-action count stayed at 6; recovery by `continue` produced turn 7 with the prefix still unchanged. See §7.1. |
| **A06** Trusted-LAN Ollama inference | **PASS** | Run 2 — Ollama on `inference.lan`, a second physical machine on the trusted LAN, over verified HTTPS, with the storyteller's Internet route removed and its listener on `127.0.0.1`. Nine turns plus a post-restart turn, summaries and embeddings included. Capture: 893 packets to the approved host, 730 loopback, **zero** elsewhere, **zero** DNS queries. Required a TLS trust fix first — §5.1. |
| **H01** No unexpected outbound connections | **PASS for the application** | Zero non-loopback unicast packets in Run 1; in Run 2, zero packets outside loopback and the approved host and zero DNS queries of any kind. One caveat, not the storyteller's: Ollama itself queried `ollama.com` (§4). |
| **H02** No telemetry | **PASS** | No outbound destination in either capture; the inherited `analytics.py` writes to two local SQLite tables and opens no socket (Phase 0B static analysis, re-confirmed by the captures). |
| **H03** No cloud provider required | **PASS as stated** | Nothing cloud was reachable in Runs 1 and 2 and everything worked. The test's *preferred* final state — "cloud provider controls are absent, not merely unused" — is **not** met and is M2's scope by design. |
| **H11** No first-use runtime asset download | **PASS** | First turn on a fresh database succeeded with no route out; the whole browser asset graph resolved same-origin; the tokenizer is built from a vendored, digest-checked table. |
### 7.1 A05 in detail, including a real behaviour worth knowing
Baseline: 12 actions, 6 of them AI, digest `2ca6ab528178e44e`.
```text
invalid local model -> events ['player','error']
"Endpoint or model not found (HTTP 404) … model
'no-such-model-v9' not found"
13 actions, still 6 AI actions
endpoint down (:11499)-> events ['player','error']
"Could not connect to http://127.0.0.1:11499/v1 —
is the AI server running?"
14 actions, still 6 AI actions
accepted prefix through the pre-failure head: 12 actions, digest 2ca6ab528178e44e — unchanged
the two added rows: [28] story 'I strike a match.'
[29] story 'I strike a match again.'
recovery (continue) -> 40 SSE events, done; 15 actions, 7 AI actions
prefix digest still 2ca6ab528178e44e
```
**The player's own typed action is committed before the model is called**
(`run_player_turn` in `backend/app/routers/adventures/turns.py`), so a failed
turn leaves the player's text at the head with no reply. No AI output is ever
partially committed. That satisfies A05 as written — prior story intact, the
failed turn not committed as accepted, retry available — and it is deliberate:
it means a model failure never eats what the player typed. It is worth stating
explicitly because "the story is unchanged" is not literally true; "the
accepted story is unchanged" is.
## 8. Findings and open items
Nothing here blocks M1. Each is recorded because it was observed, not inferred.
1. **The model timeout is 120 s and hardcoded** (`httpx.Timeout(120, connect=10)`
in `backend/app/providers/openai_compatible.py`). On this GPU-less
four-core host, a *cold* load of `qwen2.5:3b-instruct` — or two models
contending after the memory bank pulls in `nomic-embed-text` — exceeded it
three times during these runs. Once warm, a full turn took 9 s. This is
presented as an environment/tuning finding, not an application defect; a
configurable timeout is a small change, and changing product behaviour was
outside M1's scope.
2. **Ollama queries `ollama.com` on its own account** (§4). Outside the
storyteller's code, inside the user's trust boundary. Worth settling before
release: the local-only claim covers what this application sends, and a user
reading a packet capture will see that query.
3. **The memory bank is off per adventure by default** (`auto_summarize` and
`memory_bank_enabled` are false on a new adventure) even when an embedding
model is configured globally. Discovered while trying to exercise
embeddings; it is inherited behaviour, not a regression.
4. **`docs/*.html` still links Google Fonts.** That is upstream's GitHub Pages
project site; it is not served by the application, not part of any build,
and not covered by the runtime rule. Left alone deliberately, and the
regression tests scope themselves to `frontend/` so they do not give a false
signal about it.
5. **A trusted-LAN endpoint over HTTPS needed a code change to work at all**
(§5.1), and it was found only by pointing the application at a real
StartOS-hosted Ollama. Static review would not have found it: every
candidate report and every local run up to that point used plain HTTP to
loopback, where certificate verification never happens. It is fixed and
tested, and recorded here because the class of bug — "works for curl,
fails for us" — is worth remembering when M2 formalises endpoint policy.
6. **The browser's own network panel was never inspected** (§6). The UI has
now been rendered by hand and works, and §4 shows the page's whole asset
graph resolving same-origin with no route out, so nothing rests on
inference. A devtools capture during an offline session would still be the
most direct form of that evidence, and costs a minute if anyone wants it.
## 9. Definition of done
| Requirement | Status |
| --- | --- |
| Starts with outbound Internet blocked after setup | met — Runs 1 and 2 |
| Opens the inherited browser UI locally | met — rendered in a browser, campaign opened and read (§6) |
| Generates and persists turns through same-host Ollama | met — Run 1 |
| Generates and persists turns through a configured remote Ollama | met — Run 2, on a second physical machine |
| Restarts and resumes the campaign | met — Runs 1 and 2 |
| Survives a failed model call without corrupting accepted state | met — §7.1 |
| No first-use tokenizer/font/runtime-asset request | met — §3, §4 |
| Storyteller UI/API loopback-bound by default | met — §4, §5, §6 |
| Regression suite passes, or failures documented | met — 648 passed, 0 failed |
Every line of the definition of done is met, and none of them rests on an
untested assumption. **No M2 work has been started.**
## 10. Artefacts not committed
The packet captures (`offline-samehost.pcap` 2.7 MB, `offline-lan.pcap` 0.8 MB,
`lan-remote-host.pcap` 0.4 MB) and the throwaway driver scripts live in this
session's scratchpad, not in the repository. Everything drawn from them is
quoted above; the procedure in `DEVELOPMENT.md` regenerates them.
+1 -1
View File
@@ -1,7 +1,7 @@
# Starts backend (FastAPI, :8000) and frontend dev server (Vite, :5173) in separate windows. # Starts backend (FastAPI, :8000) and frontend dev server (Vite, :5173) in separate windows.
$root = $PSScriptRoot $root = $PSScriptRoot
Start-Process powershell -ArgumentList '-NoExit', '-Command', "Set-Location '$root\backend'; & '.\.venv\Scripts\uvicorn.exe' app.main:app --port 8000 --reload" Start-Process powershell -ArgumentList '-NoExit', '-Command', "Set-Location '$root\backend'; & '.\.venv\Scripts\uvicorn.exe' app.main:app --host 127.0.0.1 --port 8000 --reload"
Start-Process powershell -ArgumentList '-NoExit', '-Command', "Set-Location '$root\frontend'; npm run dev" Start-Process powershell -ArgumentList '-NoExit', '-Command', "Set-Location '$root\frontend'; npm run dev"
+4 -1
View File
@@ -14,7 +14,10 @@ if [ ! -d frontend/node_modules ]; then
(cd frontend && npm install) (cd frontend && npm install)
fi fi
(cd backend && .venv/bin/uvicorn app.main:app --port 8000 --reload) & # --host 127.0.0.1 is uvicorn's default, stated anyway: the storyteller API is
# privileged and single-user, and "loopback by default" is a requirement rather
# than a default worth inheriting silently.
(cd backend && .venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8000 --reload) &
BACKEND_PID=$! BACKEND_PID=$!
trap 'kill "$BACKEND_PID" 2>/dev/null' EXIT trap 'kill "$BACKEND_PID" 2>/dev/null' EXIT