Skip to content

Unknown terminal session_id defaults to a live STANDARD-security terminal #14961

Description

@mrveiss

Part of #14958.

Problem

api/terminal.py:837 — _init_terminal_handler resolves a session's configuration with a
dictionary .get() that defaults to an empty dict, and then proceeds to build and start a
live terminal from those defaults. An unknown, expired, deleted or fabricated session_id is
therefore indistinguishable from a valid one, and silently yields a working terminal at
SecurityLevel.STANDARD.

Evidence

api/terminal.py:837-851:

config = session_manager.session_configs.get(session_id, {})
security_level = SecurityLevel(config.get("security_level", SecurityLevel.STANDARD.value))
conversation_id = config.get("conversation_id")
...
terminal = TerminalWebSocket(websocket, session_id, security_level, conversation_id, redis_client)
session_manager.add_connection(session_id, terminal)
await terminal.start()

There is no existence check between the .get() and the TerminalWebSocket construction, and
no ownership check. SecurityLevel.STANDARD is applied as the fallback for a session that was
never created.

This is the "an absent result reads as a clean result" failure mode: a missing record and a
valid record take the same code path, and the missing one is the more permissive of the two
because it silently adopts defaults.

Note this is a distinct defect from the missing handshake authentication
(#14960). Fixing authentication alone still leaves an authenticated user able to
attach to an arbitrary, non-existent session id and receive a default-configured terminal;
fixing session validation alone still leaves the socket open to unauthenticated callers. Both
need to land.

Proposed approach

  • Look the session up explicitly; treat "not found" as a terminal condition, not a default:
    close the socket (1008, or 1003 for an unknown resource) and return without constructing
    a handler.
  • Never infer a security level for a session that does not exist — a session's security level
    must come from its creation record.
  • Assert the authenticated caller owns the session (see Terminal WebSocket accepts unauthenticated connections #14960; reuse
    security/session_ownership.py).

Risks / constraints

  • session_manager.session_configs is process-local; confirm whether a session created on one
    worker is visible to the worker terminating the WebSocket before treating "absent" as
    "nonexistent". If it is not, absent-here does not mean absent-everywhere and the lookup must
    go through shared state — this needs checking, not assuming.
  • Sessions created before the change may lack fields the stricter path now requires.

Acceptance criteria

  • A WebSocket connection naming a session_id that was never created is rejected; no
    TerminalWebSocket is constructed and no shell process starts.
  • No code path applies a default SecurityLevel to an unknown session.
  • A session created on one worker and attached from another still connects (or the
    cross-worker lookup is fixed as part of this work, not deferred).
  • A test asserts the unknown-session case is rejected — driving the real route, and
    checking for the rejection's presence rather than the absence of an error.

Implementation Order

Wave: 1
Depends on: none
Unblocks: none — independent defect; no downstream task depends on it

Activity

  1. mrveiss commented on Aug 25, 2026

    @mrveiss
    OwnerAuthor

    Closure audit — staying open. Verified against merged base Dev_new_gui (2bf56f158, PR #14989)

    The deny-by-default fix itself is done and well tested. Criterion 3 — the one the issue itself flagged as "needs checking, not assuming" — is not met, and the change makes that gap user-visible rather than latent.

    # Acceptance criterion Evidence on the merged base Verdict
    1 A session_id never created is rejected; no TerminalWebSocket constructed, no shell starts autobot-backend/api/terminal.py:731-733 — config = session_manager.session_configs.get(session_id) then if config is None: ... return None; caller closes 1008 before accept() (terminal.py:843-848). Test autobot-backend/api/terminal_websocket_auth_test.py:92-124 asserts close(code=1008), accept.assert_not_awaited(), _init_terminal_handler never awaited Met
    2 No code path applies a default SecurityLevel to an unknown session _init_terminal_handler now takes an already-validated config parameter (terminal.py:740-744); the SecurityLevel(config.get(...)) fallback at :765 is reachable only for a session that exists and is owned. The old .get(session_id, {}) shape is gone. The test pins this discrimination specifically: it asserts the "unknown session_id" log line is present and the "is not the owner" line is absent (terminal_websocket_auth_test.py:121-123), so reverting to .get(id, {}) fails rather than passing through the owner branch Met
    3 A session created on one worker and attached from another still connects (or the cross-worker lookup is fixed as part of this work, not deferred) Neither half happened. session_manager.session_configs is still a plain in-process dict — autobot-backend/api/terminal_handlers.py:1306 (self.session_configs = {}), single instance at :1504; create_terminal_session writes to it in-process (terminal.py:241). No shared-state lookup was added. The deployed backend runs uvicorn with 4 workers plus --limit-max-requests 1000 worker recycling (autobot-infrastructure/autobot-backend/templates/autobot-user-backend.service:18-24) Not met
    4 A test asserts the unknown-session case is rejected, driving the real route, checking presence of the rejection terminal_websocket_auth_test.py:92-124 invokes the decorated route callable imported from api.terminal and asserts the close code plus the distinguishing log line Met at function level (see caveat)

    Why criterion 3 matters more after this change than before

    Before, an absent config silently fell through to a default-configured terminal — the bug this issue is about, but it also meant a cross-worker or post-recycle attach happened to "work". Now absence is a hard 1008. On a 4-worker deployment the create (POST /sessions) and the WebSocket attach routinely land on different workers, and --limit-max-requests recycling drops session_configs for every session on that worker. So the correct security behaviour has been added on top of process-local state, which converts the old silent-permissive path into a silent-broken one for legitimate users.

    This is exactly the risk the issue text called out. It must be resolved here rather than deferred: move the session-config lookup to shared state (the pattern used for the same problem in autobot-backend/takeover_manager.py:9,189), or demonstrate with evidence that this router is only ever served single-worker.

    Caveat on criteria 1 and 4

    The guard tests call terminal_websocket directly. On the merged base the route cannot be reached through the ASGI stack at all: terminal.py:172-175 still carries dependencies=[Depends(check_admin_permission)], and check_admin_permission is Request-typed (autobot-backend/auth_middleware.py:972), which FastAPI cannot resolve in a WebSocket scope — the 500-on-every-handshake outage tracked in #14998 (open). The rejection logic is correct; a real client currently gets a 500 rather than the 1008. That does not by itself unmeet criteria 1/2/4, but it does mean criterion 3's "still connects" cannot be satisfied until #14998 is fixed either.

    What is outstanding

    1. Cross-worker (and post-worker-recycle) session-config lookup — fix it here, or prove single-worker operation with evidence.
    2. bug(terminal): both terminal WebSocket routes 500 on every handshake — a router-level Depends cannot resolve in a WS scope #14998 fixed, so "connects" is testable end to end at all.

    Criteria 1, 2 and 4 have landed. Re-audit for closure once the above are addressed.

  2. mrveiss commented on Aug 26, 2026

    @mrveiss
    OwnerAuthor

    Closure check — staying open: 3 of 4 criteria met, criterion 4 outstanding

    Verified per criterion against merged Dev_new_gui (16c104be5 in history), not against the PR
    diff. #15100 is deliberately Refs, not Closes, and that turns out to be right.

    # Criterion (verbatim, abridged) Verdict Evidence in merged base Evidence kind
    1 A WebSocket connection naming a session_id that was never created is rejected; no TerminalWebSocket is constructed and no shell process starts. Met api/terminal.py:725-728 — session_manager.session_configs.get(session_id) with no default; None logs "unknown session_id" and returns None. The route refuses at :843-846 with 1008, before accept() at :848; _init_terminal_handler (the only construction site, :851) is never reached. Route-level proof that the route really executes this lookup pre-accept comes from the sibling non-owner test, api/terminal_websocket_route_test.py:377-392. merged code; the unknown-session case itself is exercised only handler-level (api/terminal_websocket_auth_test.py:116-146)
    2 No code path applies a default SecurityLevel to an unknown session. Met The single SecurityLevel(config.get("security_level", ...)) in the module is api/terminal.py:761, inside _init_terminal_handler, which by contract (:740-763) receives an already-existence- and ownership-validated config — so the default now covers only a field missing from a real record. No .get(session_id, {}) remains on any terminal WS path; the one surviving default-dict lookup, api/terminal_handlers.py:1451, is a stats reporter behind an existence check at :1441 and applies no SecurityLevel. merged code (static)
    3 A session created on one worker and attached from another still connects (or the cross-worker lookup is fixed as part of this work, not deferred). Met, via the second branch The lookup was fixed, not deferred: services/terminal_session_store.py replaces the process-local dict with a Redis-backed, dict-protocol store on the canonical client, fail-closed on an outage; wired in production at api/terminal_handlers.py:1303. Proven at services/terminal_session_store_test.py:41-56 (two independent clients, one backing store: worker B resolves worker A's session, round-tripped rather than sharing an object) and :83-87 (two TerminalManager instances, same result). unit + production wiring
    4 A test asserts the unknown-session case is rejected — driving the real route, and checking for the rejection's presence rather than the absence of an error. NOT met The only unknown-session test is api/terminal_websocket_auth_test.py:116-146, which calls the decorated handler directly (await terminal_websocket(ws, unknown_session_id)), bypassing routing and dependency resolution entirely. Its assertions are otherwise exactly right — 1008, accept not awaited, _init_terminal_handler not awaited, and caplog separating the "unknown session_id" branch from the "is not the owner" branch, since both exit through the same close(1008) at api/terminal.py:845. api/terminal_websocket_route_test.py does drive the real route via TestClient, but only for the owner, unauthenticated and non-owner cases (:348, :364, :377) — there is no route-level unknown-session handshake anywhere in the repo. —

    What is outstanding

    Exactly one thing: a route-level test of the unknown-session handshake. Concretely, a case in
    api/terminal_websocket_route_test.py::TestTerminalWebsocketRouteHandshake that connects through
    TestClient to /ws/{a-uuid-never-written-to-the-store} as an authenticated user, and asserts
    1008 on the wire, an accept-spy count of 0, and — via caplog — that the "unknown session_id"
    branch fired and "is not the owner" did not.

    Why this is not a formality: the criterion says "driving the real route" for the same reason
    #14960's criterion 2 was rewritten to demand it. #14998 is the precedent — handler-level terminal
    WebSocket tests stayed green for the entire period the route 500'd inside solve_dependencies
    for every caller, because a test that calls the handler function cannot observe a routing or
    dependency-resolution failure. The fixture the route-level file already carries (:324-335, a
    fakeredis-backed SessionConfigStore) makes this a short addition.

    Criteria 1-3 are met and need no further work; only criterion 4 blocks closure.

    No sub-issues (sub_issues endpoint returns empty).

    Also worth recording, not a criterion: the merge commit for #15100 showed python-suite shard
    11/12 red; base head is green on all 12 shards, so that resolved on the following commits.

  3. github-actions commented on Aug 26, 2026

    @github-actions
    Contributor

    PR #15101 (merged to Dev_new_gui) references this issue with a close keyword.

    test(terminal): drive the unknown-session rejection through the real route (#14961)

    If this issue is fully resolved, close it manually. If work remains, no action is needed.

  4. mrveiss commented on Aug 26, 2026

    @mrveiss
    OwnerAuthor

    Delivered across #15100 (16c104be5) and #15101 (2a148523d). AC4 was the last gap and is now met.

    # Criterion Evidence Kind
    1 An unknown session_id is refused, not defaulted api/terminal.py:725-727 — no-default lookup route-level as of #15101 (was handler-only)
    2 A default config is applied only to a validated session api/terminal.py:761 static
    3 Session state resolves across workers services/terminal_session_store.py, wired at terminal_handlers.py:1303; cross-worker test terminal_session_store_test.py:41-56 unit + wiring
    4 The refusal is proven through the real route terminal_websocket_route_test.py::test_unknown_session_is_refused route-level

    AC4 is the one worth explaining. Before #15101 the unknown-session case existed only as a handler-level test (terminal_websocket_auth_test.py:116-146, which awaits terminal_websocket(ws, id) directly). That is not a small gap in rigour — it is the same blind spot that let #14998 hide: the terminal WebSocket route returned 500 to every caller while its handler-level guards stayed green throughout, because calling the handler bypasses routing and dependency resolution entirely.

    The new test drives a real TestClient handshake to a fresh UUID and asserts 1008 on the wire, an accept-spy count of 0, _init_terminal_handler.assert_not_awaited(), and — the load-bearing part — a caplog pair: "unknown session_id" present, "is not the owner" absent. Both branches close 1008 from the same line (api/terminal.py:845), so without that assertion the test cannot distinguish them, and a regression that turned "unknown" into "not the owner" would pass silently.

    Two contrast mutations, and the second is the interesting one:

    Mutation Result
    Unknown path returns {} instead of None DID NOT RAISE WebSocketDisconnect — the handshake succeeds and the test fails before reaching the log checks
    Log branches swapped Only the caplog assertion fails. 1008, the accept-spy and assert_not_awaited all still pass — proving that assertion is what carries the distinction rather than decorating it

    A trap the store's own design creates, handled explicitly. terminal_session_store fails closed, so an unreachable Redis answers every lookup with None — every session looks unknown and the route closes 1008 for entirely the wrong reason. The test therefore reads the real owned_session_id back out of the store first as a positive witness before asserting the fresh UUID misses. A store that round-trips a real session is demonstrably live, so the miss is a genuine absence rather than an outage. The same fixture supplies the "would have been a valid owner" half, so the rejection cannot be attributed to authentication either.

    Repaired on the way: terminal_websocket_route_test.py was at 613 lines against a 600 max with no KNOWN_LARGE entry — a base-red ratchet violation introduced by 16c104be5. #15101 split it along the seam its own docstring already described (structural router/dependency-wiring needs no fixtures, fakeredis or auth stubbing; the handshake tests need all three): 613 → 412 plus a new 264-line terminal_router_dependency_wiring_test.py. No entry added, no ceiling raised. --audit-ceilings on the merged base now reports 4872 files scanned, 509 grandfathered, all live and at size, rc 0. Why that violation reached the base with ten required contexts reading green is tracked separately as #15102.

    Not proven: deployed behaviour. The running install is more than ten commits behind and still carries the pre-#15085 router-level admin Depends, so terminal WebSocket handshakes still fail there. No criterion here is worded about deployment, so this closes on merged code — but it is not yet true of the host.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions