avoigt1121 Claude Fable 5 commited on
Commit
b676112
·
1 Parent(s): 99e8ddc

verify: routing leg passes; dispatch blocked by private-Space 404 (HF_TOKEN unset)

Browse files

Signed-in test against the orchestrator dev Space: it correctly classified a DNA
question and routed to PDAC Genomics Agent. Dispatch then failed with a repo-lookup
404 because this Space is private and the orchestrator sends no token — consistent
with the caller-side HF_TOKEN root cause. Nothing to fix in this repo.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Files changed (4) hide show
  1. TODO.md +33 -0
  2. memory.md +109 -0
  3. tests/conftest.py +109 -0
  4. tests/test_hermetic.py +69 -0
TODO.md CHANGED
@@ -202,6 +202,39 @@ the design doc's M0–M4 is the intent behind it — see that doc's §0).
202
  statistical-honesty stance (no cross-gene correction, `testable=false` + reason, small-n
203
  caveats), and metadata-label vs live-PurIST provenance. No behaviour change.
204
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
205
  ## Open questions
206
 
207
  - ~~HF account / Space name~~ **RESOLVED 2026-07-24** — Space `anne-voigt/pdac-genomics-agent` created
 
202
  statistical-honesty stance (no cross-gene correction, `testable=false` + reason, small-n
203
  caveats), and metadata-label vs live-PurIST provenance. No behaviour change.
204
 
205
+ ## Test-suite hermeticity ✅ (2026-07-28)
206
+
207
+ - [x] **Enforce "no live network" instead of asserting it** — session-wide socket guard in
208
+ `tests/conftest.py` (module scope, so it covers *collection*; an autouse fixture would run
209
+ too late for import-time leaks). Non-loopback `connect`/`connect_ex`/`create_connection`
210
+ raise, naming the host. Loopback + AF_UNIX allowed.
211
+ - [x] **Close the last hole: the three UI modules.** `test_ui_handler_gating` /
212
+ `test_deploy_surface` / `test_machine_api_auth` each `import gradio_ui`, whose Blocks graph
213
+ is built at module scope; off a Space, gradio's `attach_oauth` mocks the login button by
214
+ calling HF `whoami`. conftest stubs `gradio.oauth._get_mocked_oauth_info` — the narrowest
215
+ seam, and **test-only: `gradio_ui.py` is unchanged**, so the module-level `demo` that HF
216
+ Spaces launches still works. Restructuring `gradio_ui` was considered and rejected as
217
+ disproportionate.
218
+ - [x] **`tests/test_hermetic.py`** — the guard is itself tested (installed / refuses / message
219
+ names the host / loopback still works / all three UI modules import). Without this the guard
220
+ decays into the same unchecked docstring it replaced.
221
+ - [x] **Verified: 143 pass with sockets blocked outright, no `--ignore`** (was 111 with the three
222
+ UI modules ignored). Mutation-tested — reverting the `study_metadata` patch reproduces the
223
+ original bug and the guard catches it. Runtime 22s → 4s.
224
+ - [x] **CI no longer needs an HF login** (previously those three modules could not import without
225
+ an HF token at all). Verified with `HF_TOKEN` unset.
226
+ - [ ] **Watch on gradio upgrades:** the stub targets gradio's private
227
+ `_get_mocked_oauth_info`. If a bump renames it, `test_hermetic.py`'s UI-import test fails —
228
+ that failure is the signal, not a flake. Re-point the stub.
229
+ - [ ] Optional: promote the stub-vs-restructure choice to an ADR if it proves contested.
230
+ **Next free is 0008** — `main` has 0006 (variant×subtype join) *and* 0007 (re-curation
231
+ cadence) published. Claim in `RESERVED.md` on `main`, alone, first.
232
+ - [ ] ⚠️ **This branch's `docs/adr/RESERVED.md` is stale — it stops at 0005 and says "next free
233
+ 0006", both of which are wrong.** It is the per-lane-copy failure the ledger's own postmortem
234
+ describes: the shared-row conflict cannot fire between branches, so `ls docs/adr/` and the
235
+ local ledger will both hand you a burned number. Check with
236
+ `git show main:docs/adr/RESERVED.md` before claiming, and rebase this branch.
237
+
238
  ## Open questions
239
 
240
  - ~~HF account / Space name~~ **RESOLVED 2026-07-24** — Space `anne-voigt/pdac-genomics-agent` created
memory.md CHANGED
@@ -839,6 +839,7 @@ by running the suite with `socket.connect` blocked outright — **111 tests pass
839
  all** (the three UI test modules still touch it, but that is Gradio's mocked-OAuth HF call at
840
  import in a local dev env, not our code and not present on a Space).
841
 
 
842
 
843
  ---
844
 
@@ -865,3 +866,111 @@ misled again. One contract, with instructions when you miss it.
865
  Ordering is gate-first: an unauthenticated caller gets `denied` and learns nothing about our
866
  inputs — pinned by `test_argument_errors_are_reported_only_after_the_auth_gate`. 132 tests (7 new,
867
  alongside the denial-diagnosis guards they mirror).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
839
  all** (the three UI test modules still touch it, but that is Gradio's mocked-OAuth HF call at
840
  import in a local dev env, not our code and not present on a Space).
841
 
842
+ <<<<<<< HEAD
843
 
844
  ---
845
 
 
866
  Ordering is gate-first: an unauthenticated caller gets `denied` and learns nothing about our
867
  inputs — pinned by `test_argument_errors_are_reported_only_after_the_auth_gate`. 132 tests (7 new,
868
  alongside the denial-diagnosis guards they mirror).
869
+ =======
870
+ ---
871
+
872
+ ## 2026-07-28 — Hermetic is now *enforced*, and the whole suite is hermetic
873
+
874
+ Finishes the item above. That fix patched the one hole that had been found; this one removes the
875
+ reason holes could survive — and closes the last remaining one.
876
+
877
+ **The lesson being encoded:** the "NO live network" claim lived in a docstring for weeks while
878
+ being false. Nothing checked it. So the property is now enforced by a guard and the guard is
879
+ itself under test. Do not "simplify" either away.
880
+
881
+ - **Socket guard** (`tests/conftest.py`, module scope) — wraps `socket.socket.connect` /
882
+ `connect_ex` / `socket.create_connection` for the whole session and raises on any non-loopback
883
+ address, naming the host and pointing at conftest for the fix. Installed at *import* of
884
+ conftest, not as an autouse fixture, deliberately: the leaks that mattered happened at
885
+ **collection** time (a test module's `import gradio_ui` runs the app graph at module scope), and
886
+ a fixture runs too late to catch those. Loopback and AF_UNIX are allowed — local sockets are not
887
+ what the guard is about.
888
+ - **The three UI modules no longer touch the network.** `test_ui_handler_gating.py`,
889
+ `test_deploy_surface.py`, `test_machine_api_auth.py` all `import gradio_ui`, whose `gr.Blocks`
890
+ graph is built at module scope. Off a Space, gradio's `attach_oauth` falls back to
891
+ `_add_mocked_oauth_routes`, which calls HF `whoami` to mock a local login profile. **That is
892
+ gradio's debug convenience, not our code**, and it does not happen on the deployed Space (there
893
+ `SPACE_ID` is set and the real OAuth routes are used). conftest now stubs
894
+ `gradio.oauth._get_mocked_oauth_info`.
895
+ - **Why that seam and not the others considered:** it is the only function on that path that
896
+ touches the network, so stubbing it leaves every route gradio registers intact and the UI
897
+ tests still exercise the real graph. Restructuring `gradio_ui.py` to build Blocks inside a
898
+ function was rejected as far more invasive for a test-only problem — and its module-level
899
+ `demo` is what HF Spaces launches, so it must keep working. **`gradio_ui.py` is unchanged;
900
+ the entire fix is test-only.**
901
+ - **Known coupling:** `_get_mocked_oauth_info` is a gradio private. A gradio upgrade that
902
+ renames it would break the stub — which is why `test_hermetic.py` imports all three UI
903
+ modules explicitly, so that breakage surfaces as a named test failure rather than as a
904
+ mystery connection at collection time.
905
+ - **`tests/test_hermetic.py`** (new, 7 tests) — asserts the guard is installed, that it refuses a
906
+ routable address, that the message names the host *and* points at the fix (a guard with an
907
+ unhelpful message gets disabled by the next person who trips it), that loopback still works, and
908
+ that the three UI modules import cleanly.
909
+ - **Bonus: CI no longer needs an HF login.** `_get_mocked_oauth_info` raises `ValueError` when the
910
+ machine has no HF token, so those three modules could not have been imported on a clean CI box
911
+ at all. Verified with `HF_TOKEN` unset and `HF_HOME` pointed at an empty dir.
912
+
913
+ **Verified — 143 passed with sockets blocked outright and no `--ignore`** (136 before + 7 new),
914
+ using the same method that exposed the original bug: stub `socket.socket.connect` /
915
+ `socket.create_connection` to raise, then `pytest.main(["-q"])`. Previously 111 passed that way
916
+ with the three UI modules ignored.
917
+
918
+ **Mutation-tested, so the guard is not vacuous:** removing the `study_metadata` patch and the
919
+ `_req` stub reproduces the original bug, and the guard catches it with
920
+ `AssertionError: the test suite attempted a network connection to ('www.cbioportal.org', 443)`.
921
+ Restored after.
922
+
923
+ **Suite runtime 22s → 4s** — that difference *was* the network, on a good day. On a bad one it was
924
+ the 54-second, 12-error run that started this.
925
+
926
+ **No ADR.** The contested part (stub a gradio private vs. restructure `gradio_ui`) is test-only,
927
+ reversible, and documented at both the seam and the test. If it should be one, **next free is
928
+ 0008** — `main` has 0006 (variant×subtype join) *and* 0007 (re-curation cadence) published — and
929
+ per `RESERVED.md` the claim must land on `main` alone, first.
930
+
931
+ ⚠️ **This branch's `RESERVED.md` is stale: it stops at 0005 and states "next free 0006".** Both
932
+ 0006 and 0007 are burned. This is the third instance of the same failure the ledger's own
933
+ postmortem describes — per-lane copies mean the one-shared-row conflict cannot fire between
934
+ branches, so *the ledger in your working tree is exactly the thing you cannot trust for this*.
935
+ Verify with `git show main:docs/adr/RESERVED.md` before claiming a number. (The rollup suggests
936
+ `origin/main`, but `origin` here IS the HF Space; local `main` is the reliable read.) Found by
937
+ reading `SHOWCASE_STATUS.md` before updating it — the same habit that caught collision #3.
938
+
939
+ **Environment note:** this repo now has a venv at `../../.venv` (Python 3.14) created for this
940
+ work — `gradio[oauth]==6.18.0` (the `oauth` extra is required or the UI modules fail to import at
941
+ all), plus the rest of `requirements.txt` and pytest. Previously the memory said to borrow
942
+ `DecoupleRpy_Agent/.venv`.
943
+ >>>>>>> dbaeaa3 (tests: enforce hermeticity with a socket guard; last 3 network-touching modules closed)
944
+
945
+ ---
946
+
947
+ ## 2026-07-28 — Routing leg VERIFIED end-to-end; dispatch blocked by a private-Space 404
948
+
949
+ The one thing the orchestrator registration task could not check headlessly — does the router
950
+ actually *classify* a DNA question and dispatch here — is now verified in a signed-in browser
951
+ session against the orchestrator **dev** Space (`5cbf1d9`).
952
+
953
+ Asked: *"Which samples in the TCGA pancreatic cohort carry a KRAS G12D mutation, and what is the
954
+ KRAS alteration frequency?"*
955
+
956
+ **Classification: PASS.** The orchestrator answered *"Routing to PDAC Genomics Agent for
957
+ computation."* — it recognised a DNA question and picked this specialist over the subtype and
958
+ DE agents. That is the registration working, including the deliberate choice to drop bare `KRAS`
959
+ from the trigger keywords (the hotspot allele `G12D` carried the intent instead).
960
+
961
+ **Dispatch: FAIL, and the error names its own cause.** The call came back:
962
+
963
+ > The specialist tool server could not be reached… `404 Client Error. Repository Not Found for
964
+ > url: https://huggingface.co/api/spaces/anne-voigt/pdac-genomics-agent`. If you are trying to
965
+ > access a private or gated repo, make sure you are authenticated…
966
+
967
+ This **Space is PRIVATE**, so without credentials the orchestrator cannot even *resolve* the repo,
968
+ let alone reach the gate. The failure is a repo-lookup 404, not an allow-list denial — consistent
969
+ with the caller-side root cause already found: the orchestrator attaches its token only when
970
+ `HF_TOKEN` is set, and that secret is unset on its Space.
971
+
972
+ **Nothing to change here.** The fix is one secret on the *orchestrator's* Space, and it is a
973
+ credential operation for a human. Worth noting the order of failure for whoever does it: with
974
+ `HF_TOKEN` unset the symptom is a 404 (invisible private repo); once set, the next thing exercised
975
+ is the allow-list — and the identity it resolves to must be on `ALLOWED_IDS` here
976
+ (`anne-voigt,cpelz741` today).
tests/conftest.py CHANGED
@@ -2,8 +2,16 @@
2
 
3
  Mirrors the sibling specialists: capture a few genes×samples of real API JSON once, commit
4
  it, and route the client's fetch functions through it so CI is hermetic.
 
 
 
 
 
 
 
5
  """
6
  import json
 
7
  from pathlib import Path
8
 
9
  import pytest
@@ -11,6 +19,107 @@ import pytest
11
  _FIXTURE = Path(__file__).parent / "fixtures" / "cbioportal_paad_mini.json"
12
 
13
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  @pytest.fixture
15
  def mini():
16
  return json.loads(_FIXTURE.read_text())
 
2
 
3
  Mirrors the sibling specialists: capture a few genes×samples of real API JSON once, commit
4
  it, and route the client's fetch functions through it so CI is hermetic.
5
+
6
+ "Hermetic" is **enforced**, not asserted: this module blocks outbound sockets for the whole
7
+ session (see below). It is enforced because the docstring alone was wrong for weeks — the
8
+ fixture curated, curation fetched `study_metadata`, and nothing patched it, so every
9
+ fixture-using test hit cbioportal.org. It surfaced as a run that failed with 12 errors and
10
+ passed on retry: a third party's latency reported as our bug. A property no test checks is a
11
+ property you do not have.
12
  """
13
  import json
14
+ import socket
15
  from pathlib import Path
16
 
17
  import pytest
 
19
  _FIXTURE = Path(__file__).parent / "fixtures" / "cbioportal_paad_mini.json"
20
 
21
 
22
+ # --------------------------------------------------------------------------- #
23
+ # Hermetic enforcement — no outbound sockets for the duration of the session
24
+ # --------------------------------------------------------------------------- #
25
+ # Installed at import of this module rather than as an autouse fixture, because the leaks we
26
+ # need to catch happen at *collection* time (a test module's `import gradio_ui` runs the whole
27
+ # app graph at module scope). conftest is imported before collection; a fixture would be too
28
+ # late.
29
+
30
+ _LOOPBACK = frozenset({"127.0.0.1", "::1", "localhost", "", "0.0.0.0"})
31
+
32
+ _real_connect = socket.socket.connect
33
+ _real_connect_ex = socket.socket.connect_ex
34
+ _real_create_connection = socket.create_connection
35
+
36
+
37
+ def _is_local(address) -> bool:
38
+ """Loopback and AF_UNIX are allowed; everything routable is not.
39
+
40
+ Local sockets are how a test server or an ASGI transport talks to itself — legitimate, and
41
+ not what this guard is about. The guard is about reaching a *third party*.
42
+ """
43
+ if isinstance(address, (str, bytes)): # AF_UNIX path
44
+ return True
45
+ if isinstance(address, tuple) and address:
46
+ host = address[0]
47
+ if isinstance(host, bytes):
48
+ host = host.decode("utf-8", "replace")
49
+ return str(host) in _LOOPBACK
50
+ return False
51
+
52
+
53
+ def _blocked(address):
54
+ raise AssertionError(
55
+ f"the test suite attempted a network connection to {address!r}.\n"
56
+ "This suite is hermetic by contract: fixtures are pinned JSON captures and the request "
57
+ "path reads committed curated artifacts (ADR-0005 C4). A real connection here means "
58
+ "something is unpatched — patch it in tests/conftest.py rather than relaxing this "
59
+ "guard. Loopback is allowed if you genuinely need a local server."
60
+ )
61
+
62
+
63
+ def _guarded_connect(self, address):
64
+ if not _is_local(address):
65
+ _blocked(address)
66
+ return _real_connect(self, address)
67
+
68
+
69
+ def _guarded_connect_ex(self, address):
70
+ if not _is_local(address):
71
+ _blocked(address)
72
+ return _real_connect_ex(self, address)
73
+
74
+
75
+ def _guarded_create_connection(address, *args, **kwargs):
76
+ if not _is_local(address):
77
+ _blocked(address)
78
+ return _real_create_connection(address, *args, **kwargs)
79
+
80
+
81
+ socket.socket.connect = _guarded_connect
82
+ socket.socket.connect_ex = _guarded_connect_ex
83
+ socket.create_connection = _guarded_create_connection
84
+
85
+
86
+ # --------------------------------------------------------------------------- #
87
+ # Gradio's mocked-OAuth path — the one legitimate import-time connection, removed
88
+ # --------------------------------------------------------------------------- #
89
+ # `gradio_ui` builds its `gr.Blocks` graph at module scope (that module-level `demo` is what HF
90
+ # Spaces launches, so it stays that way). Off a Space, gradio's `attach_oauth` falls back to
91
+ # `_add_mocked_oauth_routes`, which calls huggingface_hub `whoami` to mock a local profile.
92
+ # That is gradio debug convenience, not our code, and it does not happen on the deployed Space
93
+ # (there `SPACE_ID` is set and the real OAuth routes are used instead). It also requires the
94
+ # developer's machine to be HF-logged-in, which CI is not.
95
+ #
96
+ # So we stub the mock. Patching `_get_mocked_oauth_info` — rather than `whoami` — is the
97
+ # narrowest seam: it is the only function on that path that touches the network, and replacing
98
+ # it leaves every route gradio registers intact, so the UI tests still exercise the real graph.
99
+ # Nothing here changes the deployed app; conftest is test-only.
100
+ def _install_offline_oauth_mock() -> None:
101
+ import gradio.oauth
102
+
103
+ gradio.oauth._get_mocked_oauth_info = lambda: {
104
+ "access_token": "test-token",
105
+ "token_type": "bearer",
106
+ "expires_at": 0,
107
+ "expires_in": 28800,
108
+ "scope": "openid profile",
109
+ "userinfo": {
110
+ "sub": "test-user-id",
111
+ "name": "Test User",
112
+ "preferred_username": "test-user",
113
+ "profile": "https://huggingface.co/test-user",
114
+ "picture": None,
115
+ "email": None,
116
+ },
117
+ }
118
+
119
+
120
+ _install_offline_oauth_mock()
121
+
122
+
123
  @pytest.fixture
124
  def mini():
125
  return json.loads(_FIXTURE.read_text())
tests/test_hermetic.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The suite is hermetic, and the guard that makes it so is itself under test.
2
+
3
+ `tests/conftest.py` claimed "NO live network" in a docstring for weeks while every
4
+ fixture-using test called cbioportal.org, because curation fetched `study_metadata` and
5
+ nothing patched it. The visible symptom was a run that failed with 12 errors and passed on
6
+ retry — a third party's latency arriving as our bug, at whatever moment they were slow.
7
+
8
+ The fix for *that* hole was to patch the function. The fix for the *class* of hole is this
9
+ file: the socket guard is enforcement, and enforcement nobody tests decays into the same
10
+ docstring. So these tests assert the guard is installed, that it actually refuses, and that
11
+ it names the host it refused — because a guard that fires with an unhelpful message gets
12
+ disabled by the next person who trips it.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import socket
18
+
19
+ import pytest
20
+
21
+ import conftest
22
+
23
+
24
+ def test_socket_guard_is_installed():
25
+ """If someone removes the guard, this is the test that notices."""
26
+ assert socket.socket.connect is conftest._guarded_connect
27
+ assert socket.create_connection is conftest._guarded_create_connection
28
+
29
+
30
+ def test_outbound_connection_is_refused():
31
+ """The guard must refuse a routable address — no DNS, no packets, no third party."""
32
+ with pytest.raises(AssertionError) as exc:
33
+ socket.create_connection(("www.cbioportal.org", 443), timeout=1)
34
+ assert "cbioportal" in str(exc.value)
35
+
36
+
37
+ def test_refusal_names_the_host_and_says_what_to_do():
38
+ """A guard is only as good as the message it fails with."""
39
+ with pytest.raises(AssertionError) as exc:
40
+ socket.socket().connect(("huggingface.co", 443))
41
+ message = str(exc.value)
42
+ assert "huggingface.co" in message
43
+ assert "conftest" in message # points at the fix, not just the symptom
44
+
45
+
46
+ def test_loopback_is_allowed():
47
+ """Local sockets are not what this guard is about; blocking them would break test servers.
48
+
49
+ Asserted via `connect_ex` to a port nothing is listening on: a refused connection proves
50
+ the guard let the call through to the OS, which is the property under test.
51
+ """
52
+ with socket.socket() as s:
53
+ s.settimeout(1)
54
+ assert s.connect_ex(("127.0.0.1", 1)) != 0 # reached the OS and was refused there
55
+
56
+
57
+ @pytest.mark.parametrize(
58
+ "module", ["test_ui_handler_gating", "test_deploy_surface", "test_machine_api_auth"]
59
+ )
60
+ def test_ui_modules_import_without_network(module):
61
+ """These three import `gradio_ui`, which builds its Blocks graph at module scope.
62
+
63
+ Off a Space, gradio's `attach_oauth` mocks the login button by calling HF `whoami` — a
64
+ real request, at import, before any test runs. conftest stubs that mock out. If the stub
65
+ stops matching gradio's internals (an upgrade renames `_get_mocked_oauth_info`), these
66
+ modules start hitting the network at *collection* time, which is exactly the failure this
67
+ suite is supposed to have stopped having. Importing them here proves the seam still holds.
68
+ """
69
+ __import__(module)