"""M11 §18 and §24: a container with no network, and the packaging path.
python -m tools.m11_offline --out
[--no-build]
Run from `backend/`. Needs Docker.
## Why a container rather than a namespace
§18 asks for a true offline run: fresh data, **no route to the public Internet**,
no external DNS. The obvious tool is an unprivileged network namespace, and on
this machine that is refused — Ubuntu 24.04 sets
`kernel.apparmor_restrict_unprivileged_userns=1`, so `unshare -rn` cannot map a
uid. `docker run --network none` gives the same isolation and more: the
container has a loopback interface and nothing else, no resolver, no route, and
a fresh volume. It also happens to be the packaging path §24 wants exercised, so
one run answers both.
The exercise runs *inside* the container over `docker exec`, because with no
network there is no published port to reach from the host. That is not a
workaround; it is the only honest way to drive an isolated process.
## What an offline run can and cannot prove here
Everything that does not need a model: first page load, the SPA's own assets,
campaign creation, a story turn's *attempt*, knowledge import and retrieval,
export, import into a fresh campaign, restart, and the M10 media module
importing and staying inert.
Inference is **not** exercised offline, and the report says so rather than
implying otherwise. This deployment's Ollama is on another machine on the
trusted LAN, which `SECURITY-THREAT-MODEL.md` §73 permits and which is not an
Internet dependency — but it is also not reachable from a container with no
network. What the offline run proves about inference is the useful half: with no
model reachable, the application degrades to a reported error and the campaign
stays intact.
"""
from __future__ import annotations
import argparse
import json
import subprocess
import sys
import time
from datetime import datetime
from pathlib import Path
HERE = Path(__file__).resolve().parent
ROOT = HERE.parent.parent
IMAGE = "interactive-story:m11-offline"
NAME = "m11-offline"
#: The script that runs inside the container. Written to a file and copied in,
#: so it is readable evidence rather than a wall of `-c` quoting.
INSIDE = r'''
import json, os, socket, sys, time, urllib.error, urllib.request
BASE = "http://127.0.0.1:8000"
results = []
def check(name, ok, detail=""):
results.append({"check": name, "result": "PASS" if ok else "FAIL",
"detail": str(detail)[:300]})
def api(method, path, payload=None, timeout=60):
data = json.dumps(payload).encode() if payload is not None else None
req = urllib.request.Request(BASE + "/api" + path, data=data, method=method,
headers={"Content-Type": "application/json"} if data else {})
with urllib.request.urlopen(req, timeout=timeout) as r:
body = r.read().decode()
return json.loads(body) if body else None
# --- 1. There is genuinely no way out. ---
try:
socket.setdefaulttimeout(5)
socket.create_connection(("1.1.1.1", 443), timeout=5)
check("no route to the public Internet", False, "a connection succeeded")
except OSError as exc:
check("no route to the public Internet", True, type(exc).__name__)
try:
socket.getaddrinfo("example.com", 443)
check("no external DNS", False, "resolution succeeded")
except OSError as exc:
check("no external DNS", True, type(exc).__name__)
# --- 2. First page load, from a fresh data directory. ---
with urllib.request.urlopen(BASE + "/", timeout=30) as r:
page = r.read().decode()
csp = r.headers.get("Content-Security-Policy", "")
check("the first page load succeeds offline", "" in page)
check("the page names no remote origin",
"http://" not in page.replace('http://www.w3.org', '') and "https://" not in page)
check("a CSP is served", bool(csp), csp[:120])
# --- 3. Every asset the page asks for is local, and resolves. ---
import re
assets = re.findall(r'(?:src|href)="([^"]+)"', page)
missing = []
for href in assets:
if href.startswith("http"):
missing.append("REMOTE:" + href); continue
try:
with urllib.request.urlopen(BASE + href, timeout=30) as r:
r.read(64)
except Exception as exc:
missing.append(f"{href}:{type(exc).__name__}")
check("every asset the shell references is served locally", not missing, missing)
# --- 4. A campaign, offline. ---
adv = api("POST", "/adventures", {"title": "Offline", "opening": "Rain.",
"canon_rules": ["The dead do not return."],
"persona_name": "Aldric"})["id"]
check("a campaign can be created offline", isinstance(adv, int))
api("POST", f"/adventures/{adv}/state/corrections", {"events": [
{"type": "create_entity", "entity": "aldric", "entity_type": "character",
"name": "Aldric"},
{"type": "set_scene", "summary": "Aldric by the fire.", "location": "tavern",
"present": ["aldric"]}], "note": ""})
state = api("GET", f"/adventures/{adv}/state")["document"]
check("state extraction works offline", state["entities"]["aldric"]["name"] == "Aldric")
# --- 5. Knowledge import and retrieval, offline. ---
boundary = "----m11offline"
body = (
f"--{boundary}\r\nContent-Disposition: form-data; name=\"classification\"\r\n\r\ncanon\r\n"
f"--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"canon.md\"\r\n"
f"Content-Type: text/markdown\r\n\r\n# Westhaven\n\nThe crypt is sealed.\r\n"
f"--{boundary}--\r\n").encode()
req = urllib.request.Request(BASE + f"/api/adventures/{adv}/knowledge", data=body,
method="POST",
headers={"Content-Type": f"multipart/form-data; boundary={boundary}"})
with urllib.request.urlopen(req, timeout=60) as r:
source = json.loads(r.read().decode())
check("a local file imports offline", source["id"] > 0)
report = api("GET", f"/adventures/{adv}/context")
check("the prompt assembles offline", report["tokens"]["total"] > 0)
check("imported knowledge is searchable offline",
any("crypt" in u["text"].lower() for u in report["knowledge"]["used"])
or report["knowledge"]["considered"] is not None)
# --- 6. A turn with no model reachable: reported, and nothing corrupted. ---
page_before = api("GET", f"/adventures/{adv}/actions?limit=50")
before = page_before["total"]
ai_before = [a["id"] for a in page_before["actions"] if a["type"] == "ai"]
events = []
req = urllib.request.Request(BASE + f"/api/adventures/{adv}/actions",
data=json.dumps({"type": "do", "text": "I look around."}).encode(),
method="POST", headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=120) as r:
for raw in r:
line = raw.decode(errors="replace").strip()
if line.startswith("data:"):
try: events.append(json.loads(line[5:].strip()))
except Exception: pass
except Exception as exc:
events.append({"type": "error", "detail": f"{type(exc).__name__}"})
errors = [e for e in events if e.get("type") == "error"]
page_after = api("GET", f"/adventures/{adv}/actions?limit=50")
ai_after = [a["id"] for a in page_after["actions"] if a["type"] == "ai"]
check("a turn with no model reachable is reported as a failure", bool(errors),
json.dumps(events)[:200])
# L01, stated the way the product states it. A05 deliberately *keeps* the
# player's submitted text so it can be tried again, and the head sits on it —
# so the total action count is expected to rise by one. What must not happen is
# an accepted narration, or state moving for a turn that did not occur. The
# first version of this check compared totals and called the retained input a
# corruption, which is a harness defect of exactly the kind that would have hidden
# a real one.
check("no narration was accepted", ai_after == ai_before,
f"{len(ai_before)} -> {len(ai_after)}")
check("the player's own words were kept, as A05 intends",
page_after["total"] == before + 1, f"{before} -> {page_after['total']}")
check("and the state is unchanged by it",
api("GET", f"/adventures/{adv}/state")["document"] == state)
check("and the earlier story is still there",
all(a["id"] in [x["id"] for x in page_after["actions"]]
for a in page_before["actions"]))
# --- 7. Export and import, offline. ---
bundle = api("GET", f"/adventures/{adv}/export")
check("a campaign exports offline", bundle["format"].startswith("ai-dnd-adventure"))
copy_id = api("POST", "/adventures/import", bundle)["id"]
copy_state = api("GET", f"/adventures/{copy_id}/state")["document"]
check("and imports offline, with its state", copy_state["entities"]["aldric"]["name"] == "Aldric")
check("no secret is present in the export",
not any(k in json.dumps(bundle).lower() for k in ("api_key", "apikey", "secret.key")))
# --- 8. The M10 media layer imports and stays inert. ---
sys.path.insert(0, "/app/backend")
from app.media import packet, profiles, providers # noqa: E402
check("the media module imports offline", providers.registered() == {})
packet_out = api("GET", f"/adventures/{adv}/scene-packet")
check("a scene packet builds offline", packet_out["scene_id"].startswith("c"))
check("no media provider is required", providers.registered() == {})
print("M11-OFFLINE-RESULTS " + json.dumps(results))
'''
def run(*args, **kwargs):
return subprocess.run(args, capture_output=True, text=True, **kwargs)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--out", required=True)
parser.add_argument("--no-build", action="store_true")
args = parser.parse_args()
out = Path(args.out)
out.mkdir(parents=True, exist_ok=True)
started = datetime.now()
if not args.no_build:
print("building the image with --no-cache …")
build = run("docker", "build", "--no-cache", "-t", IMAGE, ".", cwd=str(ROOT))
(out / "docker-build.log").write_text(build.stdout + build.stderr)
if build.returncode != 0:
print(f"build failed; see {out / 'docker-build.log'}")
return 1
print(" built")
run("docker", "rm", "-f", NAME)
script = out / "inside.py"
script.write_text(INSIDE)
print("starting the container with --network none …")
start = run("docker", "run", "-d", "--name", NAME, "--network", "none", IMAGE)
if start.returncode != 0:
print(start.stderr[:500])
return 1
container = start.stdout.strip()[:12]
try:
# Wait for the application inside, over exec rather than over a port.
ready = False
for _ in range(120):
probe = run("docker", "exec", NAME, "python", "-c",
"import urllib.request;"
"urllib.request.urlopen('http://127.0.0.1:8000/api/settings',"
" timeout=3)")
if probe.returncode == 0:
ready = True
break
time.sleep(1)
if not ready:
logs = run("docker", "logs", NAME)
(out / "container.log").write_text(logs.stdout + logs.stderr)
print(f"the container never became ready; see {out / 'container.log'}")
return 1
print(f" container {container} is serving (no network)")
run("docker", "cp", str(script), f"{NAME}:/tmp/inside.py")
result = run("docker", "exec", NAME, "python", "/tmp/inside.py")
(out / "inside-stdout.txt").write_text(result.stdout + "\n---\n" + result.stderr)
results = []
for line in result.stdout.splitlines():
if line.startswith("M11-OFFLINE-RESULTS "):
results = json.loads(line[len("M11-OFFLINE-RESULTS "):])
if not results:
print("no results came back; see inside-stdout.txt")
return 1
# §24: persistence across a container restart, on the same volume.
run("docker", "restart", NAME)
for _ in range(120):
probe = run("docker", "exec", NAME, "python", "-c",
"import urllib.request,json;"
"print(urllib.request.urlopen("
"'http://127.0.0.1:8000/api/adventures', timeout=3)"
".read().decode()[:200])")
if probe.returncode == 0:
break
time.sleep(1)
survived = "Offline" in probe.stdout
results.append({"check": "campaigns survive a container restart",
"result": "PASS" if survived else "FAIL",
"detail": probe.stdout[:200]})
print(f" restart: {'campaigns survived' if survived else 'DATA LOST'}")
logs = run("docker", "logs", NAME)
(out / "container.log").write_text(logs.stdout + logs.stderr)
report = {
"image": IMAGE,
"container": container,
"network": "none",
"started": started.isoformat(timespec="seconds"),
"seconds": round((datetime.now() - started).total_seconds()),
"checks": results,
"passed": len([r for r in results if r["result"] == "PASS"]),
"failed": len([r for r in results if r["result"] == "FAIL"]),
}
(out / "offline-report.json").write_text(json.dumps(report, indent=2))
for row in results:
mark = "ok " if row["result"] == "PASS" else "FAIL"
print(f" {mark} {row['check']}"
+ (f" — {row['detail'][:80]}" if row["result"] == "FAIL" else ""))
print(f"\n{report['passed']} passed, {report['failed']} failed "
f"-> {out / 'offline-report.json'}")
return 1 if report["failed"] else 0
finally:
run("docker", "rm", "-f", NAME)
if __name__ == "__main__":
raise SystemExit(main())