R-624: the bench (only) seeds vaultwarden through its admin invite; run secrets redacted and shredded

`09` §3 decision 146. vaultwarden's fixture tries the household's own
/identity/accounts/register first (400 while sign-up is closed, R-512); on
the BENCH ONLY it then signs in to /admin with the ADMIN_TOKEN the bench
generated for this run, invites the drill address and registers it — the
route measured on 9202 2026-09-15 (E1-vaultwarden-spike). The token goes to
curl on stdin, the admin cookie in a 0600 header file that is shredded.
The dead /api/accounts/register (404 on 1.36) is gone.

bench_admin_seed_allowed(): the venue is the bench's (upgrade_boxport.Venue
VENUE="bench"), FELHOM_BENCH_ADMIN_SEED=1, and /opt/docker/stacks does not
exist (every Felhom box has it). Any one missing refuses; the edge stays
inconclusive with what was tried.

upgrade-test.py: the run's .env is written 0600 and shredded after the
teardown; every printed line and every evidence file is redacted of the
generated deploy secrets and the fixture's own password/key.
zipline needs no held secret: its first-run /api/setup already makes the
SUPERADMIN with a per-run password (measured 2026-09-30), now redacted too.
Tests: BenchAdminSeedGuard, SecretHygiene (red-proved).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-10-06 11:26:23 +02:00
parent b0939cf309
commit aed80ee143
4 changed files with 382 additions and 14 deletions
+167
View File
@@ -146,5 +146,172 @@ class MarkerIgnore(unittest.TestCase):
self.assertTrue(suffix and path and max_size > 0 and len(reason) > 40, (app, path))
# --- R-624 (`09` §3 decision 146): the bench-only admin seed, and the run's secrets -----------------------------------
import sys # noqa: E402
from unittest import mock # noqa: E402
sys.path.insert(0, HERE)
import upgrade_fixtures_box as fxbox # noqa: E402
ADMIN = "a" * 8 + "0123456789abcdef0123456789abcdef0123456789abcdef01234567" # stands in for a generated hex:32
class FakeVenue:
"""walk.py's interface without a network: records every call; answers like vaultwarden 1.36 did on 9202
(audits/evidence-p1fixes-2026-09-15/E1-vaultwarden-spike.txt): a stranger's register 400, POST /admin with the
right token sets VW_ADMIN, /admin/invite 200 with that cookie, an invited address registers 200."""
def __init__(self, bench):
if bench:
self.VENUE = "bench"
self.GENERATED = {"vaultwarden": {"ADMIN_TOKEN": ADMIN, "DOMAIN": "gate.invalid"}}
self.calls, self.invited = [], set()
def wait_app(self, *a, **k):
return True
def app_curl(self, sub, path, *extra, method=None, data=None, timeout=45):
headers = []
for i, x in enumerate(extra):
if x == "-H" and extra[i + 1].startswith("@"):
headers.append(open(extra[i + 1][1:]).read().strip())
self.header_file = extra[i + 1][1:]
self.calls.append({"path": path, "argv": list(extra), "data": data, "file_headers": headers})
if path == "/identity/accounts/register":
email = json.loads(data)["email"]
return 0, ("200" if email in self.invited else "400"), '{"message":"Registration not allowed"}'
if path == "/admin":
if data == "token=" + ADMIN:
return 0, "200", "HTTP/1.1 200 OK\r\nset-cookie: VW_ADMIN=jwt.admin.session; Path=/admin\r\n\r\n<html>"
return 0, "401", "HTTP/1.1 401\r\n\r\nInvalid admin token"
if path == "/admin/invite":
if "Cookie: VW_ADMIN=jwt.admin.session" in headers:
self.invited.add(json.loads(data)["email"])
return 0, "200", "{}"
return 0, "401", ""
return 0, "404", ""
import json # noqa: E402
class BenchAdminSeedGuard(unittest.TestCase):
"""The admin seed runs on the bench ONLY. RED-PROOF (REPORT): make bench_admin_seed_allowed return (True, "") —
test_the_box_walk_never_signs_in_as_admin and each single-condition refusal fail."""
def setUp(self):
self.tmp = tempfile.mkdtemp(prefix="bench-guard-")
self.nobox = os.path.join(self.tmp, "no-such-stacks")
self.said = []
def tearDown(self):
shutil.rmtree(self.tmp, ignore_errors=True)
def allowed(self, venue, env, box_marker):
with mock.patch.dict(os.environ, env, clear=False), mock.patch.object(fxbox, "BOX_MARKER", box_marker):
if "FELHOM_BENCH_ADMIN_SEED" not in env:
os.environ.pop("FELHOM_BENCH_ADMIN_SEED", None)
return fxbox.bench_admin_seed_allowed(venue)
def test_all_three_conditions_allow(self):
self.assertEqual(self.allowed(FakeVenue(True), {"FELHOM_BENCH_ADMIN_SEED": "1"}, self.nobox), (True, ""))
def test_each_condition_alone_refuses(self):
ok, why = self.allowed(FakeVenue(False), {"FELHOM_BENCH_ADMIN_SEED": "1"}, self.nobox)
self.assertFalse(ok)
self.assertIn("not the bench venue", why)
ok, why = self.allowed(FakeVenue(True), {}, self.nobox)
self.assertFalse(ok)
self.assertIn("FELHOM_BENCH_ADMIN_SEED", why)
ok, why = self.allowed(FakeVenue(True), {"FELHOM_BENCH_ADMIN_SEED": "yes"}, self.nobox)
self.assertFalse(ok)
ok, why = self.allowed(FakeVenue(True), {"FELHOM_BENCH_ADMIN_SEED": "1"}, self.tmp) # a box: stacks exists
self.assertFalse(ok)
self.assertIn("Felhom box", why)
def test_the_bench_venue_names_itself(self):
import upgrade_boxport
self.assertEqual(upgrade_boxport.Venue.VENUE, "bench")
def seed(self, venue, env, box_marker):
with mock.patch.dict(os.environ, env, clear=False), mock.patch.object(fxbox, "BOX_MARKER", box_marker):
if "FELHOM_BENCH_ADMIN_SEED" not in env:
os.environ.pop("FELHOM_BENCH_ADMIN_SEED", None)
fx_ = fxbox.Vaultwarden()
return fx_, fx_.seed(venue, "vault", self.said.append)
def test_the_box_walk_never_signs_in_as_admin(self):
v = FakeVenue(False)
fx_, got = self.seed(v, {"FELHOM_BENCH_ADMIN_SEED": "1"}, self.nobox)
self.assertIsNone(got)
self.assertEqual([c["path"] for c in v.calls], ["/identity/accounts/register"])
self.assertIn("NOT tried", fx_.tried)
def test_the_bench_seeds_through_the_admin_invite_and_never_shows_the_token(self):
v = FakeVenue(True)
_, got = self.seed(v, {"FELHOM_BENCH_ADMIN_SEED": "1"}, self.nobox)
self.assertIsNotNone(got)
self.assertEqual([c["path"] for c in v.calls], ["/identity/accounts/register", "/admin", "/admin/invite",
"/identity/accounts/register"])
for c in v.calls:
self.assertFalse(any(ADMIN in a or "VW_ADMIN" in a for a in c["argv"]), "a secret on the command line")
self.assertEqual(v.calls[2]["file_headers"], ["Cookie: VW_ADMIN=jwt.admin.session"])
self.assertFalse(os.path.exists(v.header_file), "the cookie header file was not shredded")
self.assertFalse(any(ADMIN in s or "jwt.admin.session" in s for s in self.said), "a secret was printed")
class SecretHygiene(unittest.TestCase):
"""The run's secrets: .env 0600 and shredded, every evidence file redacted. RED-PROOF (REPORT): make redact_tree
return [] without rewriting — test_evidence_files_are_redacted fails."""
FELHOM = ("deploy_fields:\n - env_var: DOMAIN\n type: domain\n - env_var: ADMIN_TOKEN\n type: secret\n"
" generate: \"hex:32\"\n - env_var: SIGNUPS_ALLOWED\n type: text\n default: \"false\"\n")
def setUp(self):
self.tmp = tempfile.mkdtemp(prefix="bench-secrets-")
def tearDown(self):
shutil.rmtree(self.tmp, ignore_errors=True)
def test_secret_values_are_the_generated_fields_and_the_seed_password(self):
if ut.cvp is None:
import importlib.util as iu
sp = iu.spec_from_file_location("cvp", os.path.join(HERE, "check-volume-persistence.py"))
m = iu.module_from_spec(sp)
sp.loader.exec_module(m)
ut.cvp = m
env = {"DOMAIN": "gate.invalid", "ADMIN_TOKEN": ADMIN, "SIGNUPS_ALLOWED": "false"}
got = ut.secret_values(self.FELHOM, env, {"email": "drill-1@gate.invalid", "pw": "Drill-0011223344"})
self.assertEqual(set(got), {ADMIN, "Drill-0011223344"})
def test_env_is_0600_and_shredded(self):
p = ut.Path(self.tmp) / ".env"
ut.write_secret_file(p, "ADMIN_TOKEN=%s\n" % ADMIN)
self.assertEqual(os.stat(p).st_mode & 0o777, 0o600)
ut.write_secret_file(p, "ADMIN_TOKEN=%s\n" % ADMIN) # a re-render replaces it, still 0600
self.assertEqual(os.stat(p).st_mode & 0o777, 0o600)
ut.shred_file(p)
self.assertFalse(p.exists())
ut.shred_file(p) # a missing file is fine
def test_evidence_files_are_redacted(self):
ev = ut.Path(self.tmp) / "evidence" / "MV-vaultwarden"
(ev / "sub").mkdir(parents=True)
(ev / "run.log").write_text("token=%s ok\n" % ADMIN)
(ev / "sub" / "to-full.log").write_text("clean line\n")
(ev / "verdict.json").write_text(json.dumps({"abort_detail": "pw Drill-0011223344"}))
held = ut.redact_tree(ev, [ADMIN, "Drill-0011223344"])
self.assertEqual(len(held), 2)
for p in ev.rglob("*"):
if p.is_file():
t = p.read_text()
self.assertNotIn(ADMIN, t)
self.assertNotIn("Drill-0011223344", t)
self.assertIn(ut.REDACTED, (ev / "run.log").read_text())
self.assertEqual((ev / "sub" / "to-full.log").read_text(), "clean line\n")
def test_redact_longest_first(self):
self.assertEqual(ut.redact("x abcdefgh123 y", ["abcdefgh123", "abcdefgh"]), "x <redacted> y")
if __name__ == "__main__":
unittest.main(verbosity=2)
+92 -2
View File
@@ -205,10 +205,93 @@ def render(app: str, images: dict, workdir: Path, env: dict, template: str = Non
out.append(line)
workdir.mkdir(parents=True, exist_ok=True)
(workdir / "docker-compose.yml").write_text(apply_bench_overrides(app, pg_mounts_for("\n".join(out) + "\n")))
(workdir / ".env").write_text("".join(f"{k}={v}\n" for k, v in env.items()))
write_secret_file(workdir / ".env", "".join(f"{k}={v}\n" for k, v in env.items()))
return workdir / "docker-compose.yml"
# --- R-624 (`09` §3 decision 146): the run's secrets never reach a file that outlives the run, or any output --------
#
# The bench GENERATES each app's deploy secrets per run (build_env) — vaultwarden's ADMIN_TOKEN among them, which the
# bench-only admin seed uses. They live in memory and in ONE file compose must read: the run's `.env`, written 0600 and
# shredded after the teardown. Every line the run prints and every evidence file it leaves is passed through redact()
# (pinned by scripts/test_upgrade_bench.py: SecretHygiene).
REDACTED = "<redacted>"
SECRET_FIELD_TYPES = ("password", "secret", "secret_input")
SEED_SECRET_KEYS = ("pw", "key", "password", "token", "admin_token")
def write_secret_file(path: Path, text: str):
"""Write `text` to `path` readable by its owner only (0600 from the first byte, not chmod after)."""
path = Path(path)
try:
path.unlink()
except FileNotFoundError:
pass
fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(fd, "w") as fh:
fh.write(text)
def shred_file(path: Path):
"""Overwrite with zeros, fsync, remove. A missing file is fine."""
try:
n = os.path.getsize(path)
with open(path, "r+b") as fh:
fh.write(b"\0" * n)
fh.flush()
os.fsync(fh.fileno())
os.unlink(path)
except OSError:
pass
def secret_values(felhom_text: str, env: dict, seeded=None) -> list:
"""Every value this run must never print: each deploy field the template generates or types as a secret, and the
fixture's own secret fields (its password / key). Longest first, so a value inside another is not half-redacted.
Values shorter than 8 characters are not secrets the bench made (and would redact ordinary words)."""
out = set()
for f in cvp.parse_deploy_fields(felhom_text) if cvp else []:
if f.get("generate") or f.get("type") in SECRET_FIELD_TYPES:
v = env.get(f["env_var"])
if v:
out.add(str(v))
if isinstance(seeded, dict):
for k, v in seeded.items():
if k in SEED_SECRET_KEYS and isinstance(v, str):
out.add(v)
return sorted((v for v in out if len(v) >= 8), key=len, reverse=True)
def redact(text, secrets_: list):
if not text or not secrets_:
return text
for v in secrets_:
text = text.replace(v, REDACTED)
return text
def redact_tree(root: Path, secrets_: list) -> list:
"""Redact every file under the run's evidence directory in place; returns the files that held a secret."""
hit = []
if not secrets_:
return hit
enc = [(v.encode(), REDACTED.encode()) for v in secrets_]
for p in sorted(Path(root).rglob("*")):
if not p.is_file() or p.is_symlink():
continue
try:
b = p.read_bytes()
except OSError:
continue
nb = b
for v, r in enc:
nb = nb.replace(v, r)
if nb != b:
p.write_bytes(nb)
hit.append(str(p))
return hit
def compose(workdir: Path, project: str, *args, timeout=1800):
return cvp._sh(["docker", "compose", "-p", project, "-f", str(workdir / "docker-compose.yml"),
"--env-file", str(workdir / ".env")] + list(args), timeout=timeout)
@@ -877,9 +960,10 @@ def run_edge(edge_id: str) -> dict:
"duration_s": 0, "measured_at": None, "evidence": f"evidence/{edge_id}"}
t0 = time.time()
log = []
secrets_ = secret_values(felhom, env) # R-624: grows by the fixture's own secrets after the seed
def say(msg):
line = f"[{datetime.now(timezone.utc).strftime('%H:%M:%S')}] {msg}"
line = redact(f"[{datetime.now(timezone.utc).strftime('%H:%M:%S')}] {msg}", secrets_)
print(line, flush=True)
log.append(line)
@@ -911,6 +995,7 @@ def run_edge(edge_id: str) -> dict:
say("no fixture for this app — inconclusive")
return rec
seeded = fixture.seed(container_ip, say)
secrets_[:] = sorted(set(secrets_) | set(secret_values(felhom, env, seeded)), key=len, reverse=True)
if seeded is None:
rec["verdict"] = "inconclusive"
rec["abort_detail"] = "no non-browser seed route" + (
@@ -1038,6 +1123,11 @@ def run_edge(edge_id: str) -> dict:
(ev / "compose-final.log").write_text((lg.stdout + lg.stderr)[-400000:]) # post-abort state only — see to-full.log
(ev / "verdict.json").write_text(json.dumps(rec, indent=2))
compose(workdir, project, "down", "-v", "--remove-orphans", timeout=900)
# R-624: no evidence file keeps a secret this run made, and the run's .env does not outlive it.
held = redact_tree(ev, secrets_)
if held:
print(f"[redact] a run secret was removed from {len(held)} evidence file(s): {held}", flush=True)
shred_file(workdir / ".env")
SOAK_SECONDS = 600
+4
View File
@@ -72,6 +72,10 @@ def routes(compose_text):
class Venue:
"""walk.py's interface, on the bench."""
# R-624: the ONE place the bench names itself. upgrade_fixtures_box.bench_admin_seed_allowed reads it (with the
# run's opt-in and the not-a-box check); walk.py's object on a box has no VENUE, so an admin seed refuses there.
VENUE = "bench"
def __init__(self, compose_text, env, ipfn):
self.routes = routes(compose_text)
self.env = env
+119 -12
View File
@@ -283,26 +283,131 @@ class Navidrome:
# =============================================================================================
# --- R-624 (`09` §3 decision 146): the BENCH may hold an app's admin secret to seed it, the bench ONLY ----------------
BENCH_ADMIN_SEED_ENV = "FELHOM_BENCH_ADMIN_SEED" # the run's explicit opt-in; the bench command sets it to 1
BOX_MARKER = "/opt/docker/stacks" # every Felhom box has it: the controller syncs templates there
def bench_admin_seed_allowed(w):
"""(True, "") only on the test bench; (False, why) everywhere else. THE BENCH IS RECOGNISED BY ALL THREE:
1. the venue is the bench's (`upgrade_boxport.Venue` sets VENUE = "bench"; the box walk's object has none);
2. the run opted in: FELHOM_BENCH_ADMIN_SEED=1 in the environment (the bench command sets it, nothing else);
3. the machine is not a Felhom box: /opt/docker/stacks does not exist (a box's controller syncs the catalog
there — this is what tells guest 9202, which also ran /opt/upg on 2026-09-23, from the bench LXC 9401).
Pinned by scripts/test_upgrade_bench.py (BenchAdminSeedGuard: each condition alone refuses)."""
if getattr(w, "VENUE", None) != "bench":
return False, "not the bench venue (the box walk never holds an admin secret)"
if os.environ.get(BENCH_ADMIN_SEED_ENV) != "1":
return False, "%s=1 is not set for this run" % BENCH_ADMIN_SEED_ENV
if os.path.exists(BOX_MARKER):
return False, "%s exists — this machine is a Felhom box, not the bench" % BOX_MARKER
return True, ""
def _curl_with_header_file(w, sub, path, header, *extra, **kw):
"""w.app_curl with ONE secret header read by curl from a 0600 temp file (`-H @file`), so the value is never
on a command line; the file is overwritten and removed afterwards, whatever happens."""
import tempfile
fd, hp = tempfile.mkstemp(prefix=".felhom-h-")
try:
os.fchmod(fd, 0o600)
os.write(fd, (header + "\n").encode())
os.close(fd)
fd = None
return w.app_curl(sub, path, "-H", "@" + hp, *extra, **kw)
finally:
if fd is not None:
os.close(fd)
_shred(hp)
def _shred(path):
"""Overwrite a small secret file with zeros, then remove it. A missing file is fine."""
try:
n = os.path.getsize(path)
with open(path, "r+b") as fh:
fh.write(b"\0" * n)
fh.flush()
os.fsync(fh.fileno())
os.unlink(path)
except OSError:
pass
def _encstring(n=32):
"""A Bitwarden EncString-shaped opaque value ("2.<iv>|<ct>|<mac>"). The server stores it and never opens it."""
b = lambda k: base64.b64encode(secrets.token_bytes(k)).decode()
return "2.%s|%s|%s" % (b(16), b(n), b(32))
class Vaultwarden:
"""Vaultwarden's own account API: register an account, then prove it survives by asking the app
to issue a token for it (its own login endpoint, the household's own route)."""
"""Vaultwarden's own account API. The catalog CLOSES self-registration on purpose (SIGNUPS_ALLOWED=false, R-512), so
a stranger's `POST /identity/accounts/register` answers 400 „Registration not allowed" — measured on 9202
2026-09-15 (`audits/evidence-p1fixes-2026-09-15/E1-vaultwarden-spike.txt`), where the route that DOES make an
account was measured too: the admin page signs in with ADMIN_TOKEN (`POST /admin`, form `token=`), invites an
address (`POST /admin/invite`, JSON `{"email"}`; with mail off the invitation is stored, nothing is sent), and that
address may then register (`POST /identity/accounts/register` → 200). `/api/accounts/register` is 404 on 1.36.
ON THE BENCH ONLY (R-624, `09` §3 decision 146; bench_admin_seed_allowed). ADMIN_TOKEN is the deploy secret the
bench itself GENERATED for this run (build_env, `generate: hex:32`) — held in memory, sent to curl on stdin, never
printed; the admin session cookie travels in a 0600 header file that is shredded. Everywhere else the seed tries
the household's own route only and the edge stays `inconclusive`, with what was tried — the honest answer while
sign-up is closed. The readback asks the app to issue a token for the account (its own login endpoint)."""
sub = "vault"
def _register(self, w, sub, email, key):
body = json.dumps({"email": email, "name": "drill", "masterPasswordHash": key, "masterPasswordHint": None,
"key": _encstring(),
"keys": {"encryptedPrivateKey": _encstring(64),
"publicKey": base64.b64encode(secrets.token_bytes(64)).decode()},
"kdf": 0, "kdfIterations": 600000})
return w.app_curl(sub, "/identity/accounts/register", "-H", "Content-Type: application/json",
data=body, method="POST")
def seed(self, w, sub, say):
if not w.wait_app(sub, "/alive", want=("200",)):
return None
email = f"drill-{secrets.token_hex(4)}@gate.invalid"
# Vaultwarden stores an already-hashed master key; the value is opaque to the server.
key = base64.b64encode(secrets.token_bytes(32)).decode()
body = json.dumps({"email": email, "name": "drill", "masterPasswordHash": key,
"key": "0." + base64.b64encode(secrets.token_bytes(48)).decode(),
"kdf": 0, "kdfIterations": 600000})
rc, code, out = w.app_curl(sub, "/api/accounts/register",
"-H", "Content-Type: application/json",
data=body, method="POST")
say(f" vaultwarden: register http={code}")
rc, code, out = self._register(w, sub, email, key)
say(f" vaultwarden: self-registration http={code} (closed by design, R-512 — 400 expected)")
if code in ("200", "204"):
return {"email": email, "key": key}
tried = f"POST /identity/accounts/register -> {code} (sign-up closed by design)"
ok, why = bench_admin_seed_allowed(w)
if not ok:
self.tried = tried + f"; the admin invite was NOT tried: {why}"
say(f" vaultwarden: {self.tried}")
return None
token = (getattr(w, "GENERATED", {}).get("vaultwarden") or {}).get("ADMIN_TOKEN") or ""
if not token:
self.tried = tried + "; the bench generated no ADMIN_TOKEN for this run"
say(f" vaultwarden: {self.tried}")
return None
import urllib.parse
rc, code, page = w.app_curl(sub, "/admin", "-i", "-H", "Content-Type: application/x-www-form-urlencoded",
data="token=" + urllib.parse.quote(token, safe=""), method="POST")
cookie = "; ".join(p for p in _set_cookies(page).split("; ") if p.startswith("VW_ADMIN="))
say(f" vaultwarden: bench admin sign-in http={code} session={'yes' if cookie else 'no'}")
if not cookie:
self.tried = tried + f"; POST /admin -> {code}, no admin session"
return None
try:
rc, code, out = _curl_with_header_file(w, sub, "/admin/invite", "Cookie: " + cookie,
"-H", "Content-Type: application/json",
data=json.dumps({"email": email}), method="POST")
finally:
cookie = None
say(f" vaultwarden: bench admin invite http={code}")
if code != "200":
self.tried = tried + f"; POST /admin/invite -> {code}"
return None
rc, code, out = self._register(w, sub, email, key)
say(f" vaultwarden: invited registration http={code}")
if code not in ("200", "204"):
say(f" vaultwarden: refused {out[:250]}")
self.tried = tried + f"; invited POST /identity/accounts/register -> {code}"
say(f" vaultwarden: refused {out[:200]}")
return None
return {"email": email, "key": key}
@@ -310,17 +415,19 @@ class Vaultwarden:
if not w.wait_app(sub, "/alive", want=("200",), tries=36):
return False
def login(pwhash):
import urllib.parse
return w.app_curl(sub, "/identity/connect/token",
"-H", "Content-Type: application/x-www-form-urlencoded",
data=("grant_type=password&scope=api%20offline_access"
f"&client_id=web&deviceType=9&deviceIdentifier=drill"
f"&deviceName=drill&username={t['email']}&password={pwhash}"),
f"&deviceName=drill&username={urllib.parse.quote(t['email'], safe='')}"
f"&password={urllib.parse.quote(pwhash, safe='')}"),
method="POST")
rc, code, _ = login(base64.b64encode(secrets.token_bytes(32)).decode())
if code == "200":
say(" vaultwarden: READBACK UNUSABLE — a wrong master key authenticated")
return False
rc, code, out = login(t["key"].replace("+", "%2B").replace("=", "%3D").replace("/", "%2F"))
rc, code, out = login(t["key"])
ok = code == "200" and "access_token" in out
say(f" vaultwarden: token for the seeded account http={code} ok={ok}")
if not ok: