Repository navigation
Recording-proxy egress test, and doctor no longer checks connectors offline mode turns off (#185) - #321
Conversation
…ctor checking connectors offline mode turns off A new test runs the tool in a child process whose only proxy is a recording proxy that answers every request itself and forwards nothing. With connectors, a judge and a webhook on made-up hosts and Hashing embeddings, it drives start-up, doctor, a judged chat call, a classify call and the dashboard's pages, and asserts every host the proxy saw is on an egress line of EgressList.For for that configuration; offline, the proxy sees nothing. RecordingProxy moves to tests/Support so OnnxTelemetryTests shares it. The test found that doctor (and the dashboard's connector check) sent each connector's credentials to its host in offline mode, although its own egress line says the host is never contacted. A connector that offline mode or the machine policy turns off is now reported as "not checked". Refs #185
siebe41
left a comment
There was a problem hiding this comment.
Needs changes, small: the doctor fix is right and uses the same MachinePolicy.Blocks the router and EgressList use, but the test's judge step proves nothing, and its host matcher would accept a host the list says is never contacted.
What holds up:
- The proxy really carries the router's traffic. Nothing in
src/setsUseProxy = falseor its ownProxy. Every router client comes fromIHttpClientFactorywith the default handler, and .NET readsHTTP(S)_PROXY/ALL_PROXYfirst on all three OSes. The test also proves this: the*.egress-test.examplehosts can't be resolved, so the chat call succeeds, andbackupshows up, only if the requests went through the proxy. - No network is used, and no environment variables leak. The proxy env vars go only on the child's
ProcessStartInfo.Environment; nothing process-wide is set. The test's own client usesUseProxy = false. Embeddings are the template's Hashing, so there is no download. - The doctor fix:
RouterDoctor.cs:96callsRouterRuntimeProvider.Policy.Blocks(connector), the same checkChatConnectorRegistryandEgressListuse, so the check and the router can't drift apart. Before the fix, offline mode turned the models off,models.Length == 0, and the "still worth a probe" branch sent the key anyway. Loopback andOfflineHostsconnectors are still checked in offline mode. The newHostTestscase would fail without the fix.
Findings
-
Medium: the judge wait is already met before the chat call, so the test never shows that the judge's grading call happens or goes through the proxy.
tests/SemanticRouter.Tests/Host/EgressRecordingTests.cs:56waits forSeen(proxy, Judge). Butdoctorruns first (:146-149) and checks thegraderconnector, which sendsGET http://judge.egress-test.example/v1/models(ConnectorProbe.cs:80). SoDestinationsholds the judge host before the chat request is even sent, the 2-minute wait at:173ends on its first check, and the test can finish before the background judge sends anything. The same goes for theContain([Llm, Judge, Backup])assertion at:60: doctor alone meets it for all three hosts. Fix: wait ford.Host == Judge && d.AbsolutePath.EndsWith("/chat/completions")(and assert it), or clear or mark the recording after doctor. Until then, "a chat call the judge grades" indocs/network.md:105and the PR body says more than the test checks. -
Low:
Namesaccepts a host that its egress line says is never contacted. AtEgressRecordingTests.cs:204-209, any egress line that contains the authority counts, includingconnector x: host (never contacted: …)andprice list: host (never contacted: …). The bug this PR fixes is exactly a contacted host that its own line calls never contacted. The online test would not catch it under a machine policy (AllowedEndpoints,DisabledConnectorTypes); only the offline test's stricter "no non-loopback host" check does. Skip lines that containnever contactedwhen matching. -
Low: connections without a parseable request line are not counted. At
Support/RecordingProxy.cs:90-93, a destination is recorded only whenDestinationOf(line)parses. A connection that sends nothing within 5 s, or sends TLS straight away, lands inRequestsas(connected without a request line)but not inDestinations, and both tests only assert onDestinations. The offline test should also fail on anyRequestsentry without a destination. Related limit that is worth one sentence indocs/network.md: traffic that skips the proxy (a future handler withUseProxy = false, or native code) fails DNS on.examplehosts and goes unseen. The connector clients are covered by the chat call and thebackuphost; webhook, price-list and embeddings clients are not. -
Low: there is a gap between picking the port and using it.
FreePort()(:211-216) closes its listener, and the child binds that port seconds later, after a slow Release start-up. A parallel test that binds port 0 in between makes the child fail to start. It's rare, but this suite already has known flaky socket tests under load. Passing--urls http://127.0.0.1:0and reading the bound address from the child's output or from status would remove the race. -
nit: a hung child makes a slow failure. The waits are 3 min for start-up, 3 min for doctor, 2 min for the judge, and a 2-minute
HttpClient.Timeouton each of the roughly 25 dashboard and asset requests (:126,:157-171). A child that hangs can keep one test running for most of an hour before it fails. An overallCancellationTokenSource(say 6 min) shared by all the calls would cap it. -
nit: the egress list and the child can read different settings.
EgressAsync(:113-117) builds the router in the test process, whereWebApplicationreads every environment variable, including anySemanticRouter__*on the runner. The child has those removed (:254-258). A developer'sSemanticRouter__…variable could then make the list and the observed traffic disagree. Clear them for the in-process build too, or compare against the egress lines the child'sdoctorprints.
CHANGELOG: the line goes under Unreleased → Security, which is right because doctor was sending keys to hosts the policy blocks. The dashboard claim also holds: the connector-check endpoint calls the same RouterDoctor.CheckConnectorAsync.
…-egress-recording-proxy
…r's own egress lines, and fails on unaddressed connections - Wait for and assert chat requests to the worker and judge hosts, not just any connection (doctor reaches both first). - A host on a "never contacted" egress line no longer counts as listed. - RecordingProxy.WithoutDestination: connections with no parseable request line; both tests require it empty. - Listen on port 0 and read the bound address from the banner, so no other test can take the port. - One 8-minute limit for the whole drive, with what the proxy and the tool wrote on timeout. - Compare with the egress lines the child's doctor prints, not an in-process build that sees this machine's SemanticRouter__ variables. - docs/network.md: what the test does not see.
|
Review addressed. Merged
Verified: |
siebe41
left a comment
There was a problem hiding this comment.
Looks right. All six findings from the 2026-10-21 review are fixed at b416928, the doctor fix uses the same Policy.Blocks as the connector registry, and CI is green on Linux, Windows and macOS. One thing to do before squash-merging: update the PR body (see Optional).
Re-review at b416928, fresh eyes. I read EgressRecordingTests.cs, Support/RecordingProxy.cs, the RouterDoctor hunk, the new HostTests case, and the OnnxTelemetryTests, docs/network.md and CHANGELOG changes. I pushed nothing.
Previous findings (review of 4127efc)
- Doctor met the judge wait: fixed.
Chatted(proxy, Judge)(EgressRecordingTests.cs:223-224) requires a/chat/completionsrequest to the judge host, and the test waits for it and asserts it (:61,:66). Doctor'sGET …/modelsno longer meets either. The worker's chat call is asserted too (:65). Namesaccepted a "never contacted" line: fixed (:234).- Silent connections weren't counted: fixed.
RecordingProxy.WithoutDestination(RecordingProxy.cs:114-117) collects every connection without a parseable destination, and both tests require it to be empty.docs/network.mdnow says which clients the test does not exercise, and that a client which skips the proxy goes unseen. - Port race: fixed. The child gets
--urls http://127.0.0.1:0, and the test reads the bound address from the banner (:115,:248-249). - A hung child failed slowly: fixed. One 8-minute
CancellationTokenSourcecovers every step (:127), theHttpClienttimeout included. A timeout throws aTimeoutExceptionthat shows what the proxy saw and what the tool printed. - The in-process list could read different settings: fixed. The test now parses the
egresslines that the childdoctorprinted (:198). That child hasSemanticRouter__*/SEMANTIC_ROUTER_*and all*proxy*variables removed (:277-281).
Checked and fine
- The doctor fix.
RouterDoctor.cs:96asksRouterRuntimeProvider.Policy.Blocks(connector), the sameMachinePolicy.Effective(policy, Network:Offline)thatChatConnectorRegistryuses. It returns before themodels.Length == 0"still worth a probe" branch that sent the key. The dashboard'sPOST connectors/{name}/checkgoes through the same method.HostTests.Doctor_does_not_check_a_connector_that_offline_mode_turns_offrequires the stub handler to receive no request, so it fails without the fix. - The proxy carries the traffic on all three OSes. The
.examplehosts can't resolve, so the chat succeeding andbackupshowing up prove the requests went through the proxy. On Windows,ProcessStartInfo.Environmentis case-insensitive, so the lower-case writes overwrite the same keys, which is harmless. - The dashboard routes the test calls all exist (
MapGetfor each of the 18,MapPost("/classify")).classifyis asserted 200, which shows the loopback API is reachable without a session. OnnxTelemetryTestsbehaves as before with the shared proxy (no reply means nothing is answered). The CHANGELOG line is under Unreleased › Security, which fits: doctor sent credentials to blocked hosts.
Optional
- The PR body describes the previous commit. Since the body usually becomes the squash commit message, update it before merging:
- "What changed" still says the hosts are compared with "
EgressList.For(...)for that configuration (the router is built the way the tool builds it)". That in-process build is gone: the test compares against the childdoctor's printed lines. - "One flake, already fixed" still gives the per-step limits (3 min / 2 min / 2 min). It's now one 8-minute limit for the whole drive.
- The class summary (
EgressRecordingTests.cs:17) still points atEgressList.For. That's accurate as the source of doctor's lines, but "the lines the child'sdoctorprinted" would be clearer.
- "What changed" still says the hosts are compared with "
RecordingProxy.cs:96: a connection that stays silent is only added toWithoutDestinationafter its 5-second timeout. The online test asserts right after the judge's chat arrives (:68), so a silent connection opened in the last 5 seconds isn't counted. The offline test's 30-second settle covers it there. A short settle before the online assertions, or asserting only afterproxy.Dispose()has drained, would close it.EgressRecordingTests.cs:174-181: the 18 dashboard GETs don't assert their status. If one starts answering 401 or 404, it stops exercising anything and the test still passes. AShould().NotBe(Unauthorized), or checkingIsSuccessStatusCodewhere 2xx is expected, would keep that coverage honest.
(Cloud reviewer, review-lane checklist. Reviewed at b416928.)
Generated by Claude Code
What changed
EgressRecordingTests(tests/SemanticRouter.Tests/Host/). It starts the tool (semantic-router.dll) in a child process. The child's only proxy is a recording proxy (HTTPS_PROXY,HTTP_PROXY,ALL_PROXY, upper- and lower-case; the machine's own proxy and router variables are removed). Embeddings are Hashing, so there is no model download and no network. The configuration puts a chat model, a judge model (SampleRate = 1), a third HTTPS connector and a webhook on made-up*.egress-test.examplehosts. The test then runs start-up,doctor(a second child process), a chat call the judge grades, a classify call, the dashboard page with its assets, and the/router/api/...reads the dashboard's pages make when they open. It asserts that the proxy saw the chat, judge and backup hosts, and that every non-loopback host it saw is named by anegressline printed by the child tool'sdoctorfor that configuration. With--SemanticRouter:Network:Offline=true, the proxy must see no non-loopback host at all, and the chat call must fail.RecordingProxymoved totests/SemanticRouter.Tests/Support/.OnnxTelemetryTestsuses the shared copy without behaviour changes: with no reply function it still answers nothing. With a reply function, it reads the request headers and body and answers a plain HTTP request with a stub JSON reply. It answers aCONNECTwith 502. It never forwards anything.doctorand the dashboard's connector check no longer check a connector that offline mode or the machine policy turns off (RouterDoctor.CheckConnectorAsync). The new test found this. In offline mode,doctorprintedegress: connector X: host (never contacted: … offline mode …)and then sent that connector's credentials to that host anyway. It now prints[ OK ] connector X: not checked: <reason>.A unit test covers it:HostTests.Doctor_does_not_check_a_connector_that_offline_mode_turns_off, with a stub handler that must receive no request.docs/network.md: one sentence sayingdoctorskips blocked connectors, and a paragraph on what the new test checks and what the weeklyegress-hosts.ymlrun still covers (the ONNX download and update redirects).doctorfix.Why
#185's done-when: "a recording-proxy check in CI keeps the egress list honest". Before this PR,
egress-hosts.ymlonly followed the download redirects.OnnxTelemetryTestsran a recording proxy for ONNX start-up only (Linux only) and never compared what it saw withEgressList. The new test compares what the tool really contacts with the listdoctorprints for the same configuration. A new outbound call that is missing from the list now fails CI.How it was verified
On this PC (Windows 11, shared with other sprint lanes), all on the final commit:
dotnet build -c Release tests/SemanticRouter.Tests(builds every project the change touches): succeeded, no warnings.dotnet test -c Release --filter "EgressRecordingTests|HostTests.Doctor|OnnxTelemetryTests": 12/12 passed. The two egress tests alone take 35 s.dotnet test -c Release: 1465/1465 passed (run withOPENAI_API_KEYunset, see H09).dotnet format --verify-no-changes: clean.dotnet pack -c Release src/SemanticRouter.AspNetCore(package validation against 0.5.0): succeeded. The doctor fix is internal.One flake, already fixed. An earlier full run on a heavily loaded machine failed
Offline_the_router_contacts_nothingafter 11m54s. The two Desktop tests that are known to time out under load failed in the same run. The egress test's limits were 90 s for start-up anddoctorand 30 s per HTTP call, the same asOnnxTelemetryTests, and too tight when the suite runs it next to other child-process tests on a loaded machine. One 8-minute cancellation limit now covers the whole drive, including start-up, doctor, HTTP calls and the judge wait. Each wait ends as soon as its condition is met. The offline test watches background activity for a fixed 30 s. After the change, the full suite passed (above).Other operating systems
The test is not OS-gated, so it runs on Linux, Windows and macOS in CI.
HttpClient.DefaultProxyreadsHTTP_PROXY/HTTPS_PROXY/ALL_PROXYfirst on all three systems. Only when those are unset does it fall back to WinINet (Windows) or the system configuration (macOS). The test sets all of them, in both cases, and removes every inherited*proxy*variable, so a runner's own proxy cannot hide a request.DOTNET_HOST_PATH(set bydotnet test), falling back todotnet, and killed withentireProcessTree. On Windows, the temp directory is deleted with retries because the killed process can hold its SQLite files for a moment.Not done
WebhookTestscover delivery.egress-hosts.ymlrun still follows them against the live hosts.Refs #185
Latest follow-up: the proxy stops accepting and drains every accepted connection before assertions, including silent connections. Every dashboard API read must return a successful status. Merged the latest main while retaining the offline doctor fix.