agent-bios 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -27,8 +27,10 @@ Input: the semantic payload as one JSON object on stdin.
27
27
  Host: --host claude|codex (default: the tool prefix of supporting_sessions[0]).
28
28
  Home: --config-dir, else $CLAUDE_CONFIG_DIR / $CODEX_HOME by host, else ~/.claude / ~/.codex.
29
29
  After the local writes it best-effort uploads not-yet-delivered records to
30
- {ingest-url}/api/ingest/learnings via a watermark over learnings.jsonl (Phase 2
31
- transport; skipped when the dashboard hook is not installed, or with --no-upload).
30
+ {base}/api/ingest/learnings via a watermark over learnings.jsonl. The base and
31
+ token resolve from the agent-bios-owned slot ~/.config/agent-bios/{ingest-url,
32
+ token} — the one transport contract, written by whoever adopts this install;
33
+ skipped when it is unset, or with --no-upload.
32
34
  """
33
35
  import argparse
34
36
  import datetime
@@ -38,8 +40,10 @@ import os
38
40
  import pathlib
39
41
  import shutil
40
42
  import sys
43
+ import http.client
41
44
  import time
42
45
  import urllib.error
46
+ import urllib.parse
43
47
  import urllib.request
44
48
  import uuid
45
49
 
@@ -314,35 +318,139 @@ UPLOAD_LIMIT = 25 # max POSTs per invocation
314
318
  UPLOAD_BUDGET_S = 5.0 # total wall-clock budget for the whole drain
315
319
  UPLOAD_TIMEOUT_S = 3.0 # per-request socket timeout
316
320
  PERMANENT_STATUSES = frozenset({400, 413}) # never succeeds → settle as dead
321
+ # A record or token urllib cannot turn into a request will never succeed either,
322
+ # so it settles like a 400 rather than stopping the drain: classed transient, one
323
+ # poison record wedged every later upload behind it and reported the wedge as a
324
+ # server outage (PR #44 regression review).
325
+ UNSENDABLE = "unsendable"
317
326
 
318
327
 
319
- def transport_config(home):
320
- """Read the dashboard hook's token + ingest base URL (READ-ONLY; the hook dir
321
- is dashboard-owned we never write it). Returns ((token, base), None) or
322
- (None, reason) when the hook is not installed (Q-E: caller prints a notice)."""
323
- hook_dir = home / "hooks"
324
- token_file = hook_dir / "token"
325
- if not token_file.is_file():
326
- return None, "dashboard hook not installed (no token)"
328
+ def invalid_base(base):
329
+ """None when `base` can carry a POST, else the reason. urllib turns a
330
+ scheme-less or host-less value into a ValueError at request construction,
331
+ which is a crash rather than a contract answer."""
327
332
  try:
328
- token = token_file.read_text(encoding="utf-8").strip()
333
+ parts = urllib.parse.urlsplit(base)
334
+ except ValueError as e:
335
+ return f"unparseable ({e})"
336
+ if parts.scheme not in ("http", "https"):
337
+ return "no http(s) scheme"
338
+ try:
339
+ parts.port # raises for a port urllib will not accept
340
+ except ValueError as e:
341
+ return f"unusable port ({e})"
342
+ try:
343
+ parts.hostname.encode("idna") # a label urllib cannot encode at send
344
+ except (UnicodeError, AttributeError):
345
+ if parts.hostname:
346
+ return "host is not encodable"
347
+ if not parts.hostname:
348
+ # netloc alone is not enough: a value that is only a scheme and a port
349
+ # has a netloc and no host at all.
350
+ return "no host"
351
+ if "?" in base or "#" in base:
352
+ # The request path is APPENDED to this value as a string, so a base
353
+ # carrying a query or fragment produces `...?tenant=1/api/ingest/...` —
354
+ # a different selector than the contract names, which a server may
355
+ # answer 2xx to. The concatenation is the reason, so the refusal belongs
356
+ # here rather than at the send.
357
+ #
358
+ # Asked of the RAW value, not of `parts.query`/`parts.fragment`: urlsplit
359
+ # represents a bare `https://h.test?` as an empty string, which is falsy,
360
+ # so a truthiness test accepted exactly the delimiters that corrupt the
361
+ # selector. Present-but-empty is not absent.
362
+ return "carries a query or fragment"
363
+ return None
364
+
365
+
366
+ def transport_config(slot_root=None):
367
+ """Resolve the upload transport (READ-ONLY; the slot is never written here).
368
+
369
+ ONE contract, no fallback: the agent-bios-owned slot
370
+ ~/.config/agent-bios/{ingest-url,token}, one slot for both hosts. A slot
371
+ with either file present is claimed and must be complete and non-empty,
372
+ failing loud rather than degrading. Nothing about any organization is known
373
+ here — filling the slot is the adopter's side of the contract
374
+ (ENDPOINTS.md §Transport configuration), which is what lets this file ship
375
+ to anyone.
376
+
377
+ Returns ((token, base), None) or (None, reason); caller prints a notice."""
378
+ slot = (slot_root or pathlib.Path.home()) / ".config" / "agent-bios"
379
+ slot_token, slot_url = slot / "token", slot / "ingest-url"
380
+ # Two questions, deliberately separated. FIRST: can the slot be looked at at
381
+ # all? A path predicate answers False for both "absent" and "cannot look" (an
382
+ # EACCES at or above the slot), and reading the second as the first silently
383
+ # selected the legacy endpoint. Only ENOENT means absent; every other OSError
384
+ # is a claim we cannot read. The listing is CONSUMED because iterdir() may be
385
+ # a lazy generator — an unconsumed call can raise nothing at all.
386
+ # SECOND (below): is each file there? That is asked of the filesystem rather
387
+ # than of the listing's names, because a name-set test is case-sensitive and
388
+ # a case-insensitive filesystem (APFS) then stopped claiming a working slot
389
+ # holding TOKEN/INGEST-URL, resolving the legacy endpoint with no notice.
390
+ try:
391
+ list(slot.iterdir())
392
+ except FileNotFoundError:
393
+ pass
394
+ except NotADirectoryError:
395
+ return None, (f"transport slot {slot} is not a directory "
396
+ "(a claimed slot never falls back)")
329
397
  except OSError as e:
330
- return None, f"cannot read hook token ({e})"
331
- if not token:
332
- return None, "dashboard hook token is empty"
333
- base = ""
334
- # The installer writes `dashboard-url`; a migrated install may also have
335
- # `ingest-url`. Prefer ingest-url, fall back to dashboard-url.
336
- for name in ("ingest-url", "dashboard-url"):
337
- f = hook_dir / name
338
- if f.is_file():
339
- candidate = f.read_text(encoding="utf-8").strip()
340
- if candidate:
341
- base = candidate
342
- break
343
- if not base:
344
- return None, "dashboard hook ingest URL not found"
345
- return (token, base.rstrip("/")), None
398
+ return None, (f"transport slot {slot} cannot be read ({e})"
399
+ "a claimed slot never falls back")
400
+ # EXISTENCE and SHAPE are separate questions, and collapsing them is what
401
+ # this seam keeps getting wrong. `is_file()` answers False for a directory
402
+ # and for a dangling symlink as readily as for an absent path, so a slot
403
+ # holding either stopped being claimed and fell through to the legacy
404
+ # endpoint with no notice. `lstat` answers only "is something here",
405
+ # without following the link or judging its type; the shape is then a
406
+ # separate, loud requirement.
407
+ def present(path):
408
+ try:
409
+ os.lstat(path)
410
+ except FileNotFoundError:
411
+ return False
412
+ except OSError:
413
+ return True # something is there and we cannot look at it
414
+ return True
415
+
416
+ if present(slot_token) or present(slot_url):
417
+ for f, what in ((slot_token, "token"), (slot_url, "ingest-url")):
418
+ if not present(f):
419
+ return None, (f"transport slot {slot} is missing {what} "
420
+ "(a claimed slot never falls back)")
421
+ if not f.is_file():
422
+ return None, (f"transport slot {slot} has {what} but it is not a "
423
+ "readable file (a claimed slot never falls back)")
424
+ # One read per try, so the reason can name the file that actually
425
+ # failed — a shared try knows only that something did.
426
+ values = {}
427
+ for f, what in ((slot_token, "token"), (slot_url, "ingest-url")):
428
+ try:
429
+ values[what] = f.read_text(encoding="utf-8").strip()
430
+ except (OSError, ValueError) as e:
431
+ # ValueError covers UnicodeDecodeError: a slot file that is not
432
+ # UTF-8 is a broken claim, and raising here killed learn! the
433
+ # same way a bad URL did.
434
+ return None, f"cannot read transport slot file {f} ({e})"
435
+ token, base = values["token"], values["ingest-url"]
436
+ if not token:
437
+ return None, f"transport slot token is empty ({slot_token})"
438
+ if not token.isascii() or not token.isprintable():
439
+ # A token urllib will not put in a header is a config error, and
440
+ # discovering that at send time costs the whole drain.
441
+ return None, (f"transport slot token is not usable as a header value "
442
+ f"({slot_token})")
443
+ if not base:
444
+ return None, f"transport slot ingest-url is empty ({slot_url})"
445
+ bad = invalid_base(base)
446
+ if bad:
447
+ # Validated where the value is PRODUCED: a hand-typed url reaching
448
+ # http_post raised out of the drain and killed learn! after its
449
+ # local writes, and a transient skip would have hidden the typo.
450
+ return None, (f"transport slot ingest-url is not a usable endpoint "
451
+ f"({bad}: {base!r} in {slot_url})")
452
+ return (token, base.rstrip("/")), None
453
+ return None, f"no transport configured (no {slot} slot)"
346
454
 
347
455
 
348
456
  def read_jsonl_records(jsonl):
@@ -391,6 +499,10 @@ def save_state(home, state, present_ids):
391
499
 
392
500
 
393
501
  def classify_status(status):
502
+ if status == UNSENDABLE:
503
+ # Before any numeric comparison: this is not a status at all, and the
504
+ # range checks below raise TypeError on it.
505
+ return "permanent"
394
506
  if status is None:
395
507
  return "transient" # network error / timeout
396
508
  if 200 <= status < 300:
@@ -400,29 +512,72 @@ def classify_status(status):
400
512
  return "transient" # 401/403/404/429/503/5xx → retry next time
401
513
 
402
514
 
515
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
516
+ """Refuse redirects: urllib re-sends a redirected POST as a bodyless GET, so
517
+ a 3xx (e.g. an http→https proxy hop) would settle the record while delivering
518
+ nothing. The 3xx surfaces as its own status instead → transient (fix the
519
+ configured URL; the record stays queued)."""
520
+ def redirect_request(self, *args, **kwargs):
521
+ return None
522
+
523
+
524
+ _NO_REDIRECT_OPENER = urllib.request.build_opener(_NoRedirect)
525
+
526
+
403
527
  def http_post(base, token, record, timeout=UPLOAD_TIMEOUT_S):
404
- """POST one record; return the HTTP status int, or None on a network error."""
405
- body = json.dumps(record, ensure_ascii=False).encode("utf-8")
406
- req = urllib.request.Request(base + INGEST_PATH, data=body, method="POST")
407
- req.add_header("Content-Type", "application/json")
408
- req.add_header("X-Hook-Token", token)
528
+ """POST one record. Returns the HTTP status int, None when the endpoint could
529
+ not be reached or used (transient — the record stays queued), or UNSENDABLE
530
+ when THIS RECORD can never be serialized (permanent it settles).
531
+
532
+ Construction is inside the try with the send: a value urllib refuses to turn
533
+ into a request (a scheme-less base, a header it cannot encode) is an
534
+ unreachable endpoint like any other, and raising here killed learn! after
535
+ its local writes. transport_config rejects such a base up front, with the
536
+ file named — this is the belt behind that."""
537
+ try:
538
+ body = json.dumps(record, ensure_ascii=False).encode("utf-8")
539
+ except (ValueError, UnicodeError):
540
+ # THIS RECORD can never be sent — settle it so the queue keeps moving.
541
+ return UNSENDABLE
409
542
  try:
410
- with urllib.request.urlopen(req, timeout=timeout) as resp:
543
+ req = urllib.request.Request(base + INGEST_PATH, data=body, method="POST")
544
+ req.add_header("Content-Type", "application/json")
545
+ req.add_header("X-Hook-Token", token)
546
+ with _NO_REDIRECT_OPENER.open(req, timeout=timeout) as resp:
411
547
  return resp.status
412
548
  except urllib.error.HTTPError as e:
413
549
  return e.code
414
- except (urllib.error.URLError, TimeoutError, OSError):
550
+ except (urllib.error.URLError, TimeoutError, OSError,
551
+ http.client.HTTPException, ValueError, UnicodeError):
552
+ # A broken hop — a refused connection, a timeout, a gateway emitting a
553
+ # malformed status line — is the network, so the record stays pending.
554
+ #
555
+ # The same answer covers the endpoint and its configuration: a base
556
+ # urllib rejects (InvalidURL inherits both HTTPException and
557
+ # ValueError), a host label it cannot encode, a token it will not put in
558
+ # a header. Those are transient BY DESIGN — the records must survive so
559
+ # that fixing the configured value delivers them — and transport_config
560
+ # refuses such values up front anyway, with the file named. A second
561
+ # `return None` used to sit below this one to say so; it was
562
+ # unreachable, and a statement no execution can reach is a comment
563
+ # wearing code's clothes. Measured, not noticed.
415
564
  return None
416
565
 
417
566
 
418
567
  def drain_uploads(home, post_fn=http_post, now=time.monotonic,
419
- limit=UPLOAD_LIMIT, budget_s=UPLOAD_BUDGET_S):
420
- """Best-effort upload of not-yet-settled learnings. post_fn/now are injectable
421
- for the self-test (no sockets). Returns a summary dict."""
422
- config, reason = transport_config(home)
568
+ limit=UPLOAD_LIMIT, budget_s=UPLOAD_BUDGET_S, slot_root=None):
569
+ """Best-effort upload of not-yet-settled learnings. post_fn/now/slot_root are
570
+ injectable for tests (no sockets, no $HOME mutation). Returns a summary dict.
571
+
572
+ `status` separates two skips a single value used to blur: "unconfigured" is
573
+ the benign default install, "misconfigured" is a transport that WAS claimed
574
+ and is broken — the caller says so differently, because the second is the
575
+ one a user must act on."""
576
+ config, reason = transport_config(slot_root=slot_root)
423
577
  if config is None:
424
- return {"status": "skipped", "reason": reason,
425
- "uploaded": 0, "dead": 0, "pending": None}
578
+ claimed = reason is not None and not reason.startswith("no transport configured")
579
+ return {"status": "misconfigured" if claimed else "unconfigured",
580
+ "reason": reason, "uploaded": 0, "dead": 0, "pending": None}
426
581
  token, base = config
427
582
 
428
583
  records = read_jsonl_records(home / "personal" / "learnings.jsonl")
@@ -461,9 +616,16 @@ def drain_uploads(home, post_fn=http_post, now=time.monotonic,
461
616
 
462
617
 
463
618
  def print_upload_summary(s):
464
- if s["status"] == "skipped":
619
+ if s["status"] == "misconfigured":
620
+ # Not the benign skip: a transport was claimed and is broken, so the
621
+ # record will keep not uploading until someone fixes the file named.
622
+ print(f" upload -> NOT SENT: {s['reason']}")
623
+ print(" the record is captured and stays queued; fix the file "
624
+ "above, then run learn! again")
625
+ return
626
+ if s["status"] == "unconfigured":
465
627
  print(f" upload -> skipped ({s['reason']}); "
466
- "retries on a later learn! once the hook is installed")
628
+ "retries on a later learn! once a transport is configured")
467
629
  return
468
630
  tail = f" (stopped: {s['reason']})" if s["reason"] else ""
469
631
  print(f" upload -> uploaded={s['uploaded']} dead={s['dead']} "
@@ -477,18 +639,44 @@ def _self_test():
477
639
  non-zero on any failure."""
478
640
  import tempfile
479
641
 
480
- def make_home(records, with_token=True):
481
- home = pathlib.Path(tempfile.mkdtemp(prefix="learn-selftest-"))
642
+ # One scratch tree for the whole run, removed at the end: every mkdtemp here
643
+ # used to survive the process, and the gate that calls this ran on every
644
+ # commit — 10k directories before anyone counted.
645
+ scratch = pathlib.Path(tempfile.mkdtemp(prefix="learn-selftest-"))
646
+ made = {"n": 0}
647
+
648
+ def scratch_dir(tag):
649
+ made["n"] += 1
650
+ d = scratch / f"{tag}-{made['n']}"
651
+ d.mkdir(parents=True)
652
+ return d
653
+
654
+ # The slot root is passed EXPLICITLY rather than steered through $HOME: a
655
+ # test that has to mutate the environment to stay off the operator's live
656
+ # endpoint is patching the caller instead of the seam.
657
+ empty_root = scratch_dir("empty-root")
658
+
659
+ def make_slot(name, token="tok", url="https://example.test/"):
660
+ """A slot the core accepts — the ADOPTER's side of the contract, planted
661
+ explicitly. Transport no longer follows from the home a record is
662
+ written to: the home holds the records, the slot root holds the
663
+ endpoint, and the tests keep them separate because the code does."""
664
+ root = scratch_dir(name)
665
+ slot = root / ".config" / "agent-bios"
666
+ slot.mkdir(parents=True)
667
+ (slot / "token").write_text(token + "\n", encoding="utf-8")
668
+ (slot / "ingest-url").write_text(url + "\n", encoding="utf-8")
669
+ return root
670
+
671
+ live_root = make_slot("live-root")
672
+
673
+ def make_home(records):
674
+ home = scratch_dir("home")
482
675
  (home / "personal").mkdir(parents=True)
483
676
  with open(home / "personal" / "learnings.jsonl", "w", encoding="utf-8") as f:
484
677
  for r in records:
485
678
  f.write(r if isinstance(r, str) else json.dumps(r))
486
679
  f.write("\n")
487
- if with_token:
488
- (home / "hooks").mkdir(parents=True)
489
- (home / "hooks" / "token").write_text("tok", encoding="utf-8")
490
- (home / "hooks" / "dashboard-url").write_text(
491
- "https://example.test/", encoding="utf-8")
492
680
  return home
493
681
 
494
682
  def rec(n):
@@ -500,19 +688,19 @@ def _self_test():
500
688
  checks = []
501
689
 
502
690
  # 1) no token → skipped, no state file written (watermark unadvanced).
503
- h = make_home([rec(1)], with_token=False)
504
- s = drain_uploads(h)
505
- checks.append(("no-token skip", s["status"] == "skipped"
691
+ h = make_home([rec(1)])
692
+ s = drain_uploads(h, slot_root=empty_root)
693
+ checks.append(("no-token skip", s["status"] == "unconfigured"
506
694
  and not (h / "personal" / STATE_NAME).is_file()))
507
695
 
508
696
  # 2) all ok → all uploaded, pending 0; re-run uploads nothing new.
509
697
  h = make_home([rec(1), rec(2), rec(3)])
510
- s = drain_uploads(h, post_fn=lambda *a: 200)
698
+ s = drain_uploads(h, slot_root=live_root, post_fn=lambda *a: 200)
511
699
  calls = {"n": 0}
512
700
  def count_post(*a):
513
701
  calls["n"] += 1
514
702
  return 200
515
- s2 = drain_uploads(h, post_fn=count_post)
703
+ s2 = drain_uploads(h, slot_root=live_root, post_fn=count_post)
516
704
  checks.append(("all-ok then idempotent re-run",
517
705
  s["uploaded"] == 3 and s["pending"] == 0
518
706
  and calls["n"] == 0 and s2["uploaded"] == 0))
@@ -520,8 +708,8 @@ def _self_test():
520
708
  # 3) transient (500) → nothing settled, drain stops, pending == N; a later
521
709
  # 200 run delivers everything (multi-day-outage recovery).
522
710
  h = make_home([rec(1), rec(2)])
523
- s = drain_uploads(h, post_fn=lambda *a: 500)
524
- s2 = drain_uploads(h, post_fn=lambda *a: 200)
711
+ s = drain_uploads(h, slot_root=live_root, post_fn=lambda *a: 500)
712
+ s2 = drain_uploads(h, slot_root=live_root, post_fn=lambda *a: 200)
525
713
  checks.append(("transient stop then recover",
526
714
  s["uploaded"] == 0 and s["pending"] == 2
527
715
  and s["reason"] == "transient"
@@ -529,24 +717,24 @@ def _self_test():
529
717
 
530
718
  # 4) permanent (400) → settled as dead, not retried.
531
719
  h = make_home([rec(1)])
532
- s = drain_uploads(h, post_fn=lambda *a: 400)
720
+ s = drain_uploads(h, slot_root=live_root, post_fn=lambda *a: 400)
533
721
  hits = {"n": 0}
534
722
  def once(*a):
535
723
  hits["n"] += 1
536
724
  return 400
537
- s2 = drain_uploads(h, post_fn=once)
725
+ s2 = drain_uploads(h, slot_root=live_root, post_fn=once)
538
726
  checks.append(("permanent drop, not retried",
539
727
  s["dead"] == 1 and s["pending"] == 0 and hits["n"] == 0))
540
728
 
541
729
  # 5) network error (None) is transient.
542
730
  h = make_home([rec(1)])
543
- s = drain_uploads(h, post_fn=lambda *a: None)
731
+ s = drain_uploads(h, slot_root=live_root, post_fn=lambda *a: None)
544
732
  checks.append(("network error is transient",
545
733
  s["uploaded"] == 0 and s["pending"] == 1))
546
734
 
547
735
  # 6) poison line skipped, valid records still delivered.
548
736
  h = make_home(["{ not json", rec(1), ""])
549
- s = drain_uploads(h, post_fn=lambda *a: 200)
737
+ s = drain_uploads(h, slot_root=live_root, post_fn=lambda *a: 200)
550
738
  checks.append(("poison line skipped", s["uploaded"] == 1 and s["pending"] == 0))
551
739
 
552
740
  # 7) wall-clock budget stops the drain (fake clock jumps past the deadline).
@@ -555,8 +743,31 @@ def _self_test():
555
743
  clock["t"] += 10.0
556
744
  return clock["t"]
557
745
  h = make_home([rec(1), rec(2)])
558
- s = drain_uploads(h, post_fn=lambda *a: 200, now=fake_now, budget_s=5.0)
559
- checks.append(("budget stop", s["reason"] == "budget"))
746
+ budget_sent = {"n": 0}
747
+ def budget_post(*a):
748
+ budget_sent["n"] += 1
749
+ return 200
750
+ s = drain_uploads(h, slot_root=live_root, post_fn=budget_post, now=fake_now, budget_s=5.0)
751
+ # ZERO sends, not just the reason: the deadline is checked BEFORE a send,
752
+ # and a reordering that posts first still reports reason "budget".
753
+ checks.append(("budget stop", s["reason"] == "budget" and budget_sent["n"] == 0))
754
+
755
+ # 7b) the BOUNDARY, not just the far side: a clock still inside the budget
756
+ # must send. A deadline test widened to "will probably overrun" passes
757
+ # the check above while suppressing a send that was still allowed.
758
+ inside = {"t": 0.0}
759
+ def near_now():
760
+ inside["t"] += 0.1 # 0.1, 0.2, ... — never reaches a 5.0s budget
761
+ return inside["t"]
762
+ h = make_home([rec(1)])
763
+ sent_inside = {"n": 0}
764
+ def inside_post(*a):
765
+ sent_inside["n"] += 1
766
+ return 200
767
+ s = drain_uploads(h, slot_root=live_root, post_fn=inside_post,
768
+ now=near_now, budget_s=5.0)
769
+ checks.append(("a send still inside the budget is not suppressed",
770
+ sent_inside["n"] == 1 and s["uploaded"] == 1))
560
771
 
561
772
  # 8) capture-time secret redaction is wired into build_record, so secrets in
562
773
  # free text never reach the durable log / upload / curator export.
@@ -598,6 +809,326 @@ def _self_test():
598
809
  import_line_index(f"# e\n```\n{CLAUDE_CENTRAL_IMPORT}\n```\n",
599
810
  CLAUDE_CENTRAL_IMPORT) is None))
600
811
 
812
+ # 11) transport resolution: the slot is the whole contract, and the
813
+ # instrument is held to the exact planted values — a bare non-None
814
+ # result would pass on a value nobody planted.
815
+ own_root = scratch_dir("own-root")
816
+ slot = own_root / ".config" / "agent-bios"
817
+ slot.mkdir(parents=True)
818
+ (slot / "token").write_text("own-tok\n", encoding="utf-8")
819
+ (slot / "ingest-url").write_text("https://own.test/\n", encoding="utf-8")
820
+ cfg, why = transport_config(slot_root=own_root)
821
+ checks.append(("the slot resolves to exactly what was planted",
822
+ cfg == ("own-tok", "https://own.test") and why is None))
823
+
824
+ # 12) a claimed slot missing a half fails loud — BOTH directions, since
825
+ # either half alone claims the slot.
826
+ (slot / "ingest-url").unlink()
827
+ cfg, why = transport_config(slot_root=own_root)
828
+ checks.append(("claimed slot never falls back (url missing)",
829
+ cfg is None and "missing ingest-url" in (why or "")))
830
+ (slot / "ingest-url").write_text("https://own.test/\n", encoding="utf-8")
831
+ (slot / "token").unlink()
832
+ cfg, why = transport_config(slot_root=own_root)
833
+ checks.append(("claimed slot never falls back (token missing)",
834
+ cfg is None and "missing token" in (why or "")))
835
+ (slot / "token").write_text("own-tok\n", encoding="utf-8")
836
+
837
+ # 12c) an unreadable slot is loud, never a silent legacy fallback — and the
838
+ # EACCES is planted one level ABOVE the slot, which is where a path
839
+ # predicate answers "absent" instead of "cannot look". Skipped for
840
+ # uid 0, which ignores the permission bits and would make this control
841
+ # quietly test nothing (or fail for the wrong reason).
842
+ if os.geteuid() != 0:
843
+ os.chmod(own_root / ".config", 0o600)
844
+ try:
845
+ cfg, why = transport_config(slot_root=own_root)
846
+ finally:
847
+ os.chmod(own_root / ".config", 0o700)
848
+ checks.append(("unreadable slot is loud (EACCES above it)",
849
+ cfg is None and "cannot be read" in (why or "")))
850
+
851
+ # 12c-2) the two remaining shapes of "something is there and we cannot read
852
+ # it", both found by measuring which statements the self-test never
853
+ # executed rather than by imagining them. This seam has leaked six
854
+ # times on exactly this question, and these branches existed for it
855
+ # while nothing exercised them.
856
+ notdir_root = scratch_dir("notdir-root")
857
+ (notdir_root / ".config").mkdir(parents=True)
858
+ (notdir_root / ".config" / "agent-bios").write_text("not a directory\n",
859
+ encoding="utf-8")
860
+ cfg, why = transport_config(slot_root=notdir_root)
861
+ checks.append(("a file where the slot directory belongs is loud",
862
+ cfg is None and "is not a directory" in (why or "")))
863
+
864
+ if os.geteuid() != 0:
865
+ # Readable but not searchable (r without x): the listing succeeds and
866
+ # naming the entries works, while lstat on them raises EACCES. A slot
867
+ # that answers "cannot look" per FILE rather than per directory.
868
+ nox_root = scratch_dir("nox-root")
869
+ nox_slot = nox_root / ".config" / "agent-bios"
870
+ nox_slot.mkdir(parents=True)
871
+ (nox_slot / "token").write_text("t\n", encoding="utf-8")
872
+ (nox_slot / "ingest-url").write_text("https://x.test/\n", encoding="utf-8")
873
+ os.chmod(nox_slot, 0o400)
874
+ try:
875
+ cfg, why = transport_config(slot_root=nox_root)
876
+ finally:
877
+ os.chmod(nox_slot, 0o700)
878
+ checks.append(("a slot whose entries cannot be stat'ed is loud, not absent",
879
+ cfg is None and "not a readable file" in (why or "")))
880
+
881
+ # 13) an empty slot value is its own loud failure, not a skip or fallback —
882
+ # each half through its own door (a shared door would let one branch's
883
+ # rejection vanish while the other keeps the check green).
884
+ (slot / "ingest-url").write_text("\n", encoding="utf-8")
885
+ cfg, why = transport_config(slot_root=own_root)
886
+ checks.append(("empty slot url is loud",
887
+ cfg is None and "transport slot ingest-url is empty" in (why or "")))
888
+ (slot / "ingest-url").write_text("https://own.test/\n", encoding="utf-8")
889
+ (slot / "token").write_text("\n", encoding="utf-8")
890
+ cfg, why = transport_config(slot_root=own_root)
891
+ checks.append(("empty slot token is loud",
892
+ cfg is None and "transport slot token is empty" in (why or "")))
893
+ (slot / "token").write_text("own-tok\n", encoding="utf-8")
894
+
895
+ # 13b) a base urllib cannot turn into a request is rejected where the value
896
+ # is PRODUCED, naming the file — reaching http_post with it raised out
897
+ # of the drain and killed learn! after the local writes.
898
+ # The host-less case is spelled in two pieces so this fixture is not itself
899
+ # a hardcoded URL in a shipped file (gates/check-endpoints.py urls leg).
900
+ for bad, needle in (("dashboard.example.com", "no http(s) scheme"),
901
+ ("https:" "//", "no host"),
902
+ ("ftp://x.example", "no http(s) scheme"),
903
+ # The path is appended by concatenation, so these two
904
+ # produce a selector the contract never names.
905
+ ("https://own.test/?tenant=1", "query or fragment"),
906
+ ("https://own.test/#x", "query or fragment"),
907
+ # The EMPTY delimiters, which urlsplit reports as ""
908
+ # and a truthiness test therefore accepted.
909
+ ("https://own.test/?", "query or fragment"),
910
+ ("https://own.test/#", "query or fragment"),
911
+ # A value urlsplit itself refuses. The reason must come
912
+ # back as a reason, not as a raise out of the drain.
913
+ ("http:" "//[::1", "unparseable")):
914
+ (slot / "ingest-url").write_text(bad + "\n", encoding="utf-8")
915
+ cfg, why = transport_config(slot_root=own_root)
916
+ checks.append((f"unusable slot url is loud ({bad!r})",
917
+ cfg is None and needle in (why or "")))
918
+ (slot / "ingest-url").write_text("https://own.test/\n", encoding="utf-8")
919
+
920
+ # 13c) and a misconfigured transport is reported through its OWN channel:
921
+ # the benign "nothing configured" default and a broken claimed slot
922
+ # are the two cases a single "skipped" status used to blur.
923
+ (slot / "ingest-url").write_text("not-a-url\n", encoding="utf-8")
924
+ h = make_home([rec(1)])
925
+ s = drain_uploads(h, slot_root=own_root, post_fn=lambda *a: 200)
926
+ checks.append(("broken slot reports misconfigured, not skipped",
927
+ s["status"] == "misconfigured" and s["uploaded"] == 0))
928
+ (slot / "ingest-url").write_text("https://own.test/\n", encoding="utf-8")
929
+
930
+ # 12d) a slot entry that EXISTS but is not a readable file is claimed and
931
+ # loud, never a silent legacy fallback: a dangling symlink and a
932
+ # directory both answer False to is_file(), exactly as an absent path
933
+ # does, and that is the third shape this seam has leaked through.
934
+ shape_root = scratch_dir("shape-root")
935
+ shape_slot = shape_root / ".config" / "agent-bios"
936
+ shape_slot.mkdir(parents=True)
937
+ (shape_slot / "token").symlink_to(shape_root / "nothing-here")
938
+ (shape_slot / "ingest-url").symlink_to(shape_root / "nothing-here-either")
939
+ cfg, why = transport_config(slot_root=shape_root)
940
+ checks.append(("dangling slot symlinks are loud, not a silent fallback",
941
+ cfg is None and "not a readable file" in (why or "")))
942
+ (shape_slot / "token").unlink()
943
+ (shape_slot / "token").mkdir()
944
+ cfg, why = transport_config(slot_root=shape_root)
945
+ checks.append(("a directory where a slot file belongs is loud",
946
+ cfg is None and "not a readable file" in (why or "")))
947
+
948
+ # 12d-2) the reason names the FILE that is unreadable, and a non-UTF-8 slot
949
+ # file is a misconfiguration rather than a crash — the whole point of
950
+ # catching ValueError beside OSError on every config read.
951
+ utf_root = scratch_dir("utf-root")
952
+ utf_slot = utf_root / ".config" / "agent-bios"
953
+ utf_slot.mkdir(parents=True)
954
+ (utf_slot / "token").write_bytes(b"\xff\xfe not utf-8")
955
+ (utf_slot / "ingest-url").write_text("https://own.test/\n", encoding="utf-8")
956
+
957
+ def resolved(root):
958
+ """A RAISE is the failure this pair exists to catch, so it becomes a
959
+ reason string rather than killing the self-test alongside its subject."""
960
+ try:
961
+ return transport_config(slot_root=root)
962
+ except Exception as e: # noqa: BLE001
963
+ return None, f"RAISED {type(e).__name__}: {e}"
964
+
965
+ cfg, why = resolved(utf_root)
966
+ checks.append(("a non-UTF-8 slot file is loud and names itself",
967
+ cfg is None and str(utf_slot / "token") in (why or "")))
968
+
969
+ # 12e-2) a token urllib cannot put in a header is refused at the source; a
970
+ # config-caused send failure must stay TRANSIENT so the records
971
+ # survive the fix, while a record-caused one settles.
972
+ (utf_slot / "token").write_text("tok en\twith control\n", encoding="utf-8")
973
+ cfg, why = transport_config(slot_root=utf_root)
974
+ checks.append(("an unusable token is refused at the source",
975
+ cfg is None and "header value" in (why or "")))
976
+ checks.append(("a config-caused send failure stays transient (records survive)",
977
+ classify_status(http_post("https:" "//" + "a" * 64 + ".test", "t",
978
+ rec(1), timeout=0.2)) == "transient"))
979
+ checks.append(("and a host urllib cannot encode is refused at the source",
980
+ invalid_base("https:" "//" + "a" * 64 + ".test") is not None))
981
+
982
+ # 12f) the hostname requirement, on a base that HAS a netloc: reverting to a
983
+ # netloc test passes every other fixture, since the host-less one is a
984
+ # bare scheme with an empty netloc.
985
+ # Spelled in pieces so this fixture is not itself a hardcoded URL in a
986
+ # shipped file (the urls leg), while still being a netloc with no host.
987
+ checks.append(("a netloc without a host is refused",
988
+ invalid_base("https:" "//" ":443") is not None))
989
+
990
+ # 12e) a base whose port urllib rejects is refused where the value is
991
+ # produced; if one reaches http_post anyway it settles rather than
992
+ # wedging, because InvalidURL inherits the network exception too.
993
+ checks.append(("an unusable port is refused at the source",
994
+ invalid_base("http://host.test:abc") is not None))
995
+ checks.append(("and an unparseable base stays transient at send (config, "
996
+ "not a dead record)",
997
+ classify_status(http_post("http://host.test:abc", "t",
998
+ rec(1), timeout=0.2)) == "transient"))
999
+
1000
+ # 13d) a record or token that can never be SENT settles like a 400 instead
1001
+ # of stopping the drain: classed transient, one poison record wedged
1002
+ # every later upload behind it and reported the wedge as an outage.
1003
+ poison = json.loads('{"learning_id": "0f8c1c2a-4d1e-4abc-9def-000000000099",'
1004
+ ' "schema_version": 1, "domain": "core",'
1005
+ ' "created": "2026-08-20T00:00:00Z",'
1006
+ ' "supporting_sessions": ["claude:abcd1234"],'
1007
+ ' "lesson": "\\ud800"}')
1008
+ checks.append(("unsendable record settles permanent, never transient",
1009
+ classify_status(http_post("https://x.test", "tok", poison,
1010
+ timeout=0.2)) == "permanent"))
1011
+ h = make_home([poison, rec(1)])
1012
+ # By VALUE, not identity: the drain re-parses each record out of the JSONL,
1013
+ # so `is` compares against an object the drain never sees.
1014
+ s = drain_uploads(h, slot_root=own_root,
1015
+ post_fn=lambda base, tok, r: (http_post(base, tok, r, 0.2)
1016
+ if r.get("lesson") != "x" * 12
1017
+ else 200))
1018
+ checks.append(("a poison record does not wedge the queue behind it",
1019
+ s["dead"] == 1 and s["uploaded"] == 1 and s["pending"] == 0))
1020
+
1021
+ # 13d-2) the classifier is checked EXHAUSTIVELY, not on sampled statuses.
1022
+ # A sampled matrix tests the codes someone thought of: 202 was in no
1023
+ # sample and no static literal, so a carve-out excluding it from 2xx
1024
+ # would have made every accepted-but-async delivery retry forever
1025
+ # while every test stayed green. The rule is derived from the same
1026
+ # constants the contract publishes, so this compares behaviour with
1027
+ # declaration rather than with a second copy of itself.
1028
+ wrong = []
1029
+ for status in range(100, 600):
1030
+ want = ("ok" if 200 <= status < 300
1031
+ else "permanent" if status in PERMANENT_STATUSES
1032
+ else "transient")
1033
+ if classify_status(status) != want:
1034
+ wrong.append((status, classify_status(status), want))
1035
+ checks.append((f"classify_status agrees with the declared sets on every "
1036
+ f"status 100-599 (first divergences: {wrong[:3]})", not wrong))
1037
+ # and the two values that are not statuses at all keep their own answers
1038
+ checks.append(("a network error is transient and UNSENDABLE is permanent",
1039
+ classify_status(None) == "transient"
1040
+ and classify_status(UNSENDABLE) == "permanent"))
1041
+
1042
+ # 13e) presence is asked of the FILESYSTEM, not of a listing's names: on a
1043
+ # case-insensitive filesystem a slot holding TOKEN/INGEST-URL is the
1044
+ # same slot, and a name-set test silently reported it unconfigured.
1045
+ ci_root = scratch_dir("ci-root")
1046
+ ci_slot = ci_root / ".config" / "agent-bios"
1047
+ ci_slot.mkdir(parents=True)
1048
+ (ci_slot / "TOKEN").write_text("ci-tok\n", encoding="utf-8")
1049
+ (ci_slot / "INGEST-URL").write_text("https://ci.test/\n", encoding="utf-8")
1050
+ cfg, why = transport_config(slot_root=ci_root)
1051
+ if (ci_slot / "token").is_file(): # only meaningful where the FS folds case
1052
+ checks.append(("case-folded slot is still claimed",
1053
+ cfg == ("ci-tok", "https://ci.test") and why is None))
1054
+ else:
1055
+ checks.append(("case-folded slot check skipped (case-sensitive filesystem)",
1056
+ cfg is None or cfg[1] != "https://ci.test"))
1057
+
1058
+ # 13f) the promise is that a broken slot names THE FILE, and that the two
1059
+ # skips read differently to a user — the message is the whole product of
1060
+ # a loud failure, so it is asserted rather than assumed.
1061
+ import io, contextlib
1062
+ def summary_text(s):
1063
+ buf = io.StringIO()
1064
+ with contextlib.redirect_stdout(buf):
1065
+ print_upload_summary(s)
1066
+ return buf.getvalue()
1067
+
1068
+ (slot / "ingest-url").write_text("not-a-url\n", encoding="utf-8")
1069
+ cfg, why = transport_config(slot_root=own_root)
1070
+ broken = summary_text({"status": "misconfigured", "reason": why,
1071
+ "uploaded": 0, "dead": 0, "pending": None})
1072
+ benign = summary_text({"status": "unconfigured", "reason": "no transport configured",
1073
+ "uploaded": 0, "dead": 0, "pending": None})
1074
+ checks.append(("a broken slot's reason names the file",
1075
+ str(slot / "ingest-url") in (why or "")))
1076
+ checks.append(("the two skips read differently to a user",
1077
+ "NOT SENT" in broken and "NOT SENT" not in benign
1078
+ and broken != benign))
1079
+ (slot / "ingest-url").write_text("https://own.test/\n", encoding="utf-8")
1080
+
1081
+ # 14) no slot → the skip reason names the slot it looked for, and names it
1082
+ # as an absolute path: under a redirected HOME a tilde would be a lie
1083
+ # about where the adopter must write.
1084
+ cfg, why = transport_config(slot_root=empty_root)
1085
+ checks.append(("default resolves no transport",
1086
+ cfg is None and "no transport configured" in (why or "")
1087
+ and str(empty_root / ".config" / "agent-bios") in (why or "")))
1088
+
1089
+ # 15) the drain posts to the slot's base — the resolved value reaches the
1090
+ # wire, rather than merely being returned by the resolver.
1091
+ h = make_home([rec(1)])
1092
+ seen = []
1093
+ def spy(base, token, record):
1094
+ seen.append((base, token))
1095
+ return 200
1096
+ s = drain_uploads(h, slot_root=own_root, post_fn=spy)
1097
+ checks.append(("drain posts to the slot base",
1098
+ s["uploaded"] == 1 and seen == [("https://own.test", "own-tok")]))
1099
+
1100
+ # 16) the organization provider is gone from the core, measured through the
1101
+ # DRAIN rather than the resolver: a home carrying the old dashboard hook
1102
+ # files, with no slot, must degrade to unconfigured and send nothing. A
1103
+ # re-added fallback would resolve that home and upload — which is the
1104
+ # failure this asserts against, and why the check is made where a send
1105
+ # could actually happen.
1106
+ orphan = make_home([rec(1)])
1107
+ (orphan / "hooks").mkdir(parents=True)
1108
+ (orphan / "hooks" / "token").write_text("tok", encoding="utf-8")
1109
+ (orphan / "hooks" / "dashboard-url").write_text("https://example.test/",
1110
+ encoding="utf-8")
1111
+ sent = {"n": 0}
1112
+ def orphan_post(*a):
1113
+ sent["n"] += 1
1114
+ return 200
1115
+ s = drain_uploads(orphan, slot_root=empty_root, post_fn=orphan_post)
1116
+ checks.append(("a host-home hook dir is not a transport",
1117
+ s["status"] == "unconfigured" and sent["n"] == 0))
1118
+
1119
+ # 17) the POST limit is a hard cap, measured: limit=1 over two records sends
1120
+ # exactly one and stops with reason "limit" — an off-by-one flip in the
1121
+ # comparison sends a 26th POST that no anchor can see.
1122
+ h = make_home([rec(1), rec(2)])
1123
+ capped = {"n": 0}
1124
+ def cap(*a):
1125
+ capped["n"] += 1
1126
+ return 200
1127
+ s = drain_uploads(h, slot_root=live_root, post_fn=cap, limit=1)
1128
+ checks.append(("post limit is a hard cap",
1129
+ capped["n"] == 1 and s["uploaded"] == 1 and s["reason"] == "limit"))
1130
+
1131
+ shutil.rmtree(scratch, ignore_errors=True)
601
1132
  failed = [name for name, ok in checks if not ok]
602
1133
  if failed:
603
1134
  for name in failed: