Repository navigation
fix(terminal): stop the router-level admin Depends 500ing both terminal WS handshakes - #15085
Conversation
✅ SSOT Configuration Compliance: Passing🎉 No new hardcoded values of either class — Known backlog in |
|
Holding this back one commit: as pushed, the split would have removed the admin gate from four routes that run system commands. Fixed at
This PR moves every HTTP route onto a new That is the security carve-out in miniature. Union the checks, never the allowances; a refactor that fixes one surface must not quietly widen another. It also sharpens #15084, which I had understood as "the tools router has no auth" — accurate about the module, but incomplete about the deployed state. Their protection is entirely positional, which is exactly why a router split is the thing that breaks it. Fix: the include site moves with the routes — And a guard so position can't silently change again.
The rest of the PR stands and is good work: the router-level One correction to the issue text I wrote: Verified: 15 passed · ratchet rc 0 · bandit rc 0 · black and isort clean · 0 behind base. |
|
Addressed the shard-12 red (job 98074498060, 6 failures in Root cause of the test failure (not the fix's correctness): Fix (
Contrast mutation re-verified after the rewrite: temporarily reparented then restored ( Verification: Branch is a fast-forward push ( |
|
Pushed `ea37833dc`: the dump no longer swallows a partial/anomalous result. What changed:
``` On the local fastapi/starlette version gap: this box has `fastapi==0.135.2`/`starlette==1.0.0` installed, which does not satisfy `requirements.txt`'s `fastapi>=0.141.1` -- so my local `include_router` behavior may simply not match what a fresh `pip install -r requirements.txt` resolves under CI's Python 3.14 runner. I cannot install packages on this box to confirm directly; the next CI run's `fastapi`/`starlette` fields plus the per-route `type` will say definitively whether this is a version-behavior change in `include_router` (in which case I'll fix the wiring or file it as its own cross-linked issue per the earlier direction) or something else entirely. Re-verified before pushing:
|
…al WS handshakes (#14998) check_admin_permission is Request-typed, and FastAPI's add_api_websocket_route copies a router's dependencies onto every WS route it owns, so the single router-level Depends() raised TypeError inside solve_dependencies for every /ws/{session_id} and /ws/ssh/{host_id} handshake before either handler ran. Split the router: HTTP endpoints move to a merged-in admin_router that keeps the admin gate, the WS routes stay on the un-gated router and rely on the explicit per-route auth already added in #14989/#14991.
…s the router split (#14998)
…ubprocess (#14998) python-suite shard 12/12 (CI job 98074498060) observed api.terminal.router with zero routes when read in-process from terminal_websocket_route_test.py -- some earlier-collected test in the same pytest-xdist worker leaves shared state this file's own module-level import inherited. The four tool-route gate tests and the WS/HTTP structural checks now source their route and dependency data from a fresh interpreter (mirroring the subprocess-per-class pattern in models/core_test.py), immune to whatever the worker did before them, and keep the non-vacuity assertion: an empty enumeration fails loudly rather than passing vacuously. Filed the unidentified pollution separately as #15087.
…l answer (#14998) CI job 98083466678 showed the subprocess returning 3 routes instead of 26, one with no identifiable path -- and the per-path assertions only reported "missing from a clean import", discarding the one signal (route type, framework versions, subprocess stderr) that would explain the truncation. _run_terminal_route_dump now checks the subprocess exit code and raises with stdout/stderr attached on non-zero, rejects any route entry whose path could not be identified instead of folding it into the mapping, and on every failure path prints the full raw route list plus the resolved python/fastapi/starlette versions. The dump itself now reports each route's concrete type and those three versions in one round trip.
dad5013 to
c5102d7
Compare
|
Root cause found, and it was never pollution — it is a FastAPI behaviour change the loud diagnostics named in one run. CI job Under 0.141.1, My first correction — mount a The repo already had the answer, in one file. That four files now carry this traversal by hand is its own finding — a true fact about the framework living in three test files and nowhere a fourth author would look. A sweep for other Worth stating: the loud-diagnostics change is what made this solvable. Two CI runs, each naming the exact object and the resolved versions, replaced an unreproducible bisect. The earlier guard swallowed its subprocess's stderr and reported "'/terminal/check-tool' missing from a clean import", which is the symptom, not the cause. Verified at Related: #15091 (this box below the declared |
|
Fourth and final shape, at Three CI runs established that under fastapi 0.141.1 the prefix passed to Chasing it a fourth time would have been the wrong instinct. The prefix is incidental to the property under guard — that the four routes which install packages and run system commands carry The non-vacuity assertion is kept and strengthened — an empty dump now fails with "every assertion below would pass vacuously" rather than silently matching nothing. That property is what caught this whole class in the first place. Track record of this PR, worth recording because the method is the point: four CI runs, four different causes, each named outright by the guard's own diagnostics rather than guessed at.
An unreproducible local bisect cost hours and produced nothing; a guard that prints its subprocess's stderr, the route types and the resolved versions produced a cause per run. That is the durable lesson, and it is why the diagnostics stay in even though the assertion no longer depends on them. Verified: 15 passed in the route suite, 22 across all three terminal WS suites. Contrast mutation — reparent Related: #15093 (the repo carries this traversal by hand in four places and guards it in none — and one copy is version-locked the other way) and #15091 (this box runs below the declared |
Thinking Path
The issue reports
autobot-backend/api/terminal.py:172-175declaringdependencies=[Depends(check_admin_permission)]at router level, andcheck_admin_permission(autobot-backend/auth_middleware.py:972, notautobot_shared/auth/auth_middleware.py-- that path in the issue body doesn'texist in this tree) being
Request-typed. I read FastAPI's ownadd_api_websocket_route/add_api_route(fastapi/routing.py): both docurrent_dependencies = self.dependencies.copy()and pass that into the routeconstructor -- so a router-level
dependencies=genuinely does apply to@router.websocketroutes exactly like HTTP ones, unconditionally, atregistration time. A
Request-typed dependency then fails FastAPI'ssolve_dependenciesfor a WS scope (noRequestobject exists there),raising
TypeErrorbefore the handler is ever invoked. I reproduced thisempirically in isolation with
TestClientbefore touching the fix (see thecontrast-mutation test below) -- confirmed, not assumed.
check_admin_permission's own auth logic is not the reported bug: it alreadyruns correctly for the router's HTTP routes. The two
@router.websocketroutes (
terminal_websocket:824,ssh_terminal_websocket:871) both alreadycall
enforce_ws_origin/enforce_ws_authentication(andis_admin_roleonthe SSH route) explicitly before
accept()-- that per-route guard landed in#14989/#14991 and is what the fix needs to lean on, not remove.
What Changed
autobot-backend/api/terminal.py:router = APIRouter(tags=["terminal"])(nodependencies -- owns both
@router.websocketroutes, which keep theirexisting explicit per-route auth) and
admin_router = APIRouter(dependencies=[Depends(check_admin_permission)]).@router.get/post/delete(...), 20 routes)became
@admin_router.get/post/delete(...)-- a rename only, same paths,same handlers, same admin gate.
router.include_router(admin_router)at the end of the file, once everyadmin_routerroute exists, merges the HTTP routes back intoroutersothe module still exports one
routerobject for the loader(
initialization/router_registry/terminal_routers.py).ssh_terminal_websocketdocstring, which described therouter-level
Dependsas declared-but-inert; that clause is now false (it'snot declared on
routerat all), so it's rewritten and shortened to netzero lines against
terminal.py's grandfathered 1299-line ceiling(verified: file is exactly 1299 lines after the change, no ceiling edit
needed).
HTTP routes keep their gate
router.include_router(admin_router)mergesadmin_router's routes intorouterusing FastAPI's owninclude_router/add_api_route, which combinescurrent_dependencies = self.dependencies.copy() + route.dependencies--since
admin_router's own routes already carryDepends(check_admin_permission)at decoration time, the merged routes on
routercarry it too. Verified twoways:
TestTerminalRouterDependencyWiringinspectsroute.dependencieson the liveapi.terminal.routerobject and assertscheck_admin_permissionis present on the merged/route and absent fromboth WS routes, by identity -- independent of any auth-middleware mocking.
TestTerminalHttpRouteAdminGatedrivesGET /through a realTestClient, using FastAPI'sdependency_overridesto forcecheck_admin_permissionto deny/allow, and asserts 403 vs 200.Verification
Existing test that was green against a broken route: none of the WS routes
had a passing handshake test at all in the true sense -- but
api/terminal_websocket_auth_test.pyandapi/ssh_terminal_websocket_auth_test.pycall
terminal_websocket/ssh_terminal_websocketas plain functions(
await terminal_websocket(ws, session_id)), bypassing FastAPI routing anddependency resolution entirely. Both suites were green on
Dev_new_guiwhileevery real handshake 500'd -- exactly the blind spot the issue describes. They
required zero changes here (the functions/module-level names they patch never
moved), and still pass.
New file
autobot-backend/api/terminal_websocket_route_test.pydrives thereal routes via
starlette.testclient.TestClient(the same pattern astests/test_websocket_auth_smoke.py):TestTerminalWebsocketRouteHandshake/TestSshTerminalWebsocketRouteHandshake:authenticated caller connects (
WebSocket.acceptspied via a real function,not a Mock, so it stays a proper bound-method descriptor -- asserted called
once), unauthenticated caller is refused with
close(1008)beforeaccept()(spy asserts zero calls), for both WS routes.TestTerminalHttpRouteAdminGate: non-admin caller on the HTTP surface gets403, admin gets 200.
TestRouterLevelDependsBreaksWebSocketScope(the contrast mutation): anisolated router built with the exact broken shape (
APIRouter(dependencies= [Depends(<Request-typed fn>)])owning a@router.websocketroute) raisesTypeErroronTestClient.websocket_connect-- the same failuresolve_dependenciesproduces in the real bug. The split-router shape thisfix uses, built the same way, does not raise. I could not reuse
check_admin_permissionitself for this: this repo'sconftest.pystubsauth_middleware.check_admin_permissionwith a no-arg signaturespecifically to dodge an unrelated FastAPI validation issue (feat(ui/slm): pluggable theme packages — upload + install themes via /slm (Phase 2 of #10461) #10472), so it
can't reproduce a
Request-typed WS failure in this harness -- the mutationtest builds a matching-shape dependency directly instead, so it targets the
real FastAPI mechanic rather than the test harness's own auth stub.
This also discharges #14960's AC3 ("an authenticated owner still connects
normally") in the sense that it is now checkable at all through a real route --
test_authenticated_owner_connectsin the new file proves it. I am notclosing #14960 here; its other three ACs (unknown-session-id rejection,
non-owner rejection, guard-test-fails-on-removal) are already covered by
api/terminal_websocket_auth_test.pyand need their own verification pass onthat issue, not a claim riding along on this PR.
Discovered problem, filed separately
While confirming which HTTP routes on this router "genuinely need" the admin
gate, I found
api/terminal_tools.py's router (/install-tool,/check-tool,/validate-command,/package-managers) carries no authdependency at all, and is mounted twice (once included into
terminal.py'srouter, once as its own top-level entry in
initialization/router_registry/terminal_routers.py).install_toolrunsarbitrary system commands via
SystemCommandAgent. Different router, differentfailure shape (never declared vs. declared-but-unresolvable), different risk
(unauthenticated command execution vs. functional 500) -- filed as
#15084 rather than bundled here.
Model Used
Claude Opus 5
Closes #14998