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:
co-authored by
Claude Opus 5
parent
7f182a86e9
commit
c1a73b3d77
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
@@ -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
@@ -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.
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
+100256
File diff suppressed because it is too large
Load Diff
+25
-4
@@ -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")
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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}"}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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}"
|
||||||
@@ -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
@@ -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
@@ -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&D</title>
|
<title>AI D&D</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
Binary file not shown.
Binary file not shown.
@@ -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 */
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
@@ -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,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"
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user