Repository navigation
fix(remote-control): authenticate the VNC and terminal websockets, deny unknown terminal sessions - #14989
Conversation
✅ SSOT Configuration Compliance: Passing🎉 No new hardcoded values of either class — Known backlog in |
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
…websocket (#14989) simple_terminal_e2e_test.py connected with a fabricated session_id and no credential -- both now rejected (#14960, #14961). Creates a real session via POST /api/terminal/sessions first, and presents the SSOT-configured internal-service key (the same credential auth_middleware.verify_internal_api_key checks, reused rather than a parallel path) on both the REST call and the WebSocket handshake.
… URLs (#14989) useWebSocket.ts logged the full connection URL on open and on an invalid-URL error; TerminalService's _validateWsUrl embedded it in a thrown message. Those URLs carried no credential before this PR -- buildAuthenticatedWsUrl (#6700) now puts a JWT in `?token=...`, so every one of those lines wrote a live token into the browser console (and the thrown message reaches a further logger.error via _handleConnectCatchError). redactUrlForLogging masks the VALUE of token/credential-shaped query params, keeping the parameter name visible for debugging, and never throws -- the Invalid URL branch is exactly where the input may not parse, so a failed `new URL()` falls back to a string substitution instead of raising.
…nection URLs (#14989) Same fix as the autobot-frontend composable, for @autobot/terminal's own useWebSocket.ts -- SshTerminal.vue's connection URL now carries a JWT (#14991). createLogger() here is a no-op today, so there is no live console exposure through it yet, but the log calls must not start leaking the token the day that stops being true. Duplicated rather than shared: @autobot/terminal is a standalone package (peerDependencies only -- vue/pinia/xterm) consumed by both autobot-frontend and autobot-slm-frontend, with no shared frontend utility package between them to hold one copy in. Same logic, same reasoning, kept in sync by hand.
Drives the real composable/service, not redactUrlForLogging() in isolation -- the bug was that the caller logged the raw value, so a helper-only test would pass even if connect() stopped calling it. Spies on the real console.info/console.error the createLogger wrapper writes to (matching this suite's existing convention, e.g. useLocalStorage.test.ts), using the established MockWebSocket/websocket-mock fixture. Covers: a successful connection's "Connected to:" log has the token replaced with REDACTED while other query params survive; an empty/invalid URL logs without throwing and without leaking; TerminalService's _validateWsUrl throws a redacted message (a defensive path -- baseUrl is always resolved to a valid ws://wss:// prefix in normal use, so this one is not reachable through connect()'s public happy path today, unlike the composable tests above).
…actor (#14989) redactUrlForLogging's URL-API branch only ever touches searchParams -- new URL('wss://h/ws?x=1#token=SECRET').toString() leaves #token=SECRET completely untouched, so a credential placed in a fragment bypassed redaction entirely. No current call site does that, but the doc comment already claimed general coverage. The regex substitution now always runs as a second pass over the final string (query-redacted or not), covering the fragment case the URL API branch cannot reach on its own. The doc comment also claimed "never throws", which was not literally true: a non-string argument reaching the catch fallback called .replace() on the original uncoerced value. Every call site is TS-typed string so this was unreachable through the compiled app, but made it literally true rather than only true for well-typed callers. Same fix in both copies (autobot-frontend and autobot-plugins/terminal -- #15002 tracks the duplication itself).
…essages (#14989) useWebSocket.ts's connect() has no upstream scheme validation -- buildAuthenticatedWsUrl is plain string concatenation with no scheme check -- so a misconfigured base URL (http:// where ws:// was meant) reaches new WebSocket(), which throws a SyntaxError whose .message embeds the full attempted URL verbatim in every major browser (Chrome: "The URL's scheme must be either 'ws' or 'wss'. '<url>' is not allowed."). That raw error, JWT included, went to a real logger.error. TerminalService._handleConnectCatchError has the same shape: _validateWsUrl closes the wrong-scheme case, but not every WHATWG parse failure (unusual host, percent-encoding), so the same native-throw path applies there too. redactErrorForLogging rewrites only the message text -- name and stack are preserved -- so the error stays useful for debugging rather than being swallowed. Applied to both useWebSocket.ts copies (the plugin's own createLogger() is a no-op today, so this one has no live exposure yet, but the log call must not start leaking the day that changes) and to TerminalService.ts.
…action paths (#14989) redactUrlForLogging.test.ts tests the helper directly for the fragment and non-string cases -- neither has a real call site to drive today, unlike the composable/service bugs. useWebSocket.redaction.test.ts and TerminalService.redaction.test.ts gain a case each that forces a real new WebSocket() throw (or its private-method equivalent) and asserts the resulting console.error call is redacted, driving the real catch block rather than asserting against redactErrorForLogging() in isolation.
97f2103 to
a5d28f5
Compare
…websocket (#14989) simple_terminal_e2e_test.py connected with a fabricated session_id and no credential -- both now rejected (#14960, #14961). Creates a real session via POST /api/terminal/sessions first, and presents the SSOT-configured internal-service key (the same credential auth_middleware.verify_internal_api_key checks, reused rather than a parallel path) on both the REST call and the WebSocket handshake.
… URLs (#14989) useWebSocket.ts logged the full connection URL on open and on an invalid-URL error; TerminalService's _validateWsUrl embedded it in a thrown message. Those URLs carried no credential before this PR -- buildAuthenticatedWsUrl (#6700) now puts a JWT in `?token=...`, so every one of those lines wrote a live token into the browser console (and the thrown message reaches a further logger.error via _handleConnectCatchError). redactUrlForLogging masks the VALUE of token/credential-shaped query params, keeping the parameter name visible for debugging, and never throws -- the Invalid URL branch is exactly where the input may not parse, so a failed `new URL()` falls back to a string substitution instead of raising.
…nection URLs (#14989) Same fix as the autobot-frontend composable, for @autobot/terminal's own useWebSocket.ts -- SshTerminal.vue's connection URL now carries a JWT (#14991). createLogger() here is a no-op today, so there is no live console exposure through it yet, but the log calls must not start leaking the token the day that stops being true. Duplicated rather than shared: @autobot/terminal is a standalone package (peerDependencies only -- vue/pinia/xterm) consumed by both autobot-frontend and autobot-slm-frontend, with no shared frontend utility package between them to hold one copy in. Same logic, same reasoning, kept in sync by hand.
Drives the real composable/service, not redactUrlForLogging() in isolation -- the bug was that the caller logged the raw value, so a helper-only test would pass even if connect() stopped calling it. Spies on the real console.info/console.error the createLogger wrapper writes to (matching this suite's existing convention, e.g. useLocalStorage.test.ts), using the established MockWebSocket/websocket-mock fixture. Covers: a successful connection's "Connected to:" log has the token replaced with REDACTED while other query params survive; an empty/invalid URL logs without throwing and without leaking; TerminalService's _validateWsUrl throws a redacted message (a defensive path -- baseUrl is always resolved to a valid ws://wss:// prefix in normal use, so this one is not reachable through connect()'s public happy path today, unlike the composable tests above).
…actor (#14989) redactUrlForLogging's URL-API branch only ever touches searchParams -- new URL('wss://h/ws?x=1#token=SECRET').toString() leaves #token=SECRET completely untouched, so a credential placed in a fragment bypassed redaction entirely. No current call site does that, but the doc comment already claimed general coverage. The regex substitution now always runs as a second pass over the final string (query-redacted or not), covering the fragment case the URL API branch cannot reach on its own. The doc comment also claimed "never throws", which was not literally true: a non-string argument reaching the catch fallback called .replace() on the original uncoerced value. Every call site is TS-typed string so this was unreachable through the compiled app, but made it literally true rather than only true for well-typed callers. Same fix in both copies (autobot-frontend and autobot-plugins/terminal -- #15002 tracks the duplication itself).
…essages (#14989) useWebSocket.ts's connect() has no upstream scheme validation -- buildAuthenticatedWsUrl is plain string concatenation with no scheme check -- so a misconfigured base URL (http:// where ws:// was meant) reaches new WebSocket(), which throws a SyntaxError whose .message embeds the full attempted URL verbatim in every major browser (Chrome: "The URL's scheme must be either 'ws' or 'wss'. '<url>' is not allowed."). That raw error, JWT included, went to a real logger.error. TerminalService._handleConnectCatchError has the same shape: _validateWsUrl closes the wrong-scheme case, but not every WHATWG parse failure (unusual host, percent-encoding), so the same native-throw path applies there too. redactErrorForLogging rewrites only the message text -- name and stack are preserved -- so the error stays useful for debugging rather than being swallowed. Applied to both useWebSocket.ts copies (the plugin's own createLogger() is a no-op today, so this one has no live exposure yet, but the log call must not start leaking the day that changes) and to TerminalService.ts.
…action paths (#14989) redactUrlForLogging.test.ts tests the helper directly for the fragment and non-string cases -- neither has a real call site to drive today, unlike the composable/service bugs. useWebSocket.redaction.test.ts and TerminalService.redaction.test.ts gain a case each that forces a real new WebSocket() throw (or its private-method equivalent) and asserts the resulting console.error call is redacted, driving the real catch block rather than asserting against redactErrorForLogging() in isolation.
…4959) api/ws_security.py already had enforce_ws_origin for the CSWSH check but nothing equivalent for authentication, so every WebSocket route had to restate the accept/close boilerplate itself -- or, as with the VNC and terminal sockets, omit it. enforce_ws_authentication wraps the existing auth_middleware.authenticate_websocket dependency and closes with 1008 before accept() on failure, mirroring enforce_ws_origin's convention.
#14959) websocket_proxy forwarded raw RFB frames -- full keyboard, mouse and framebuffer access to the canonical desktop -- to any caller whose handshake cleared the Origin check, which is not authentication and says so in its own docstring: a client that simply omits Origin passed through untouched. Gate the handshake with enforce_ws_authentication before accept(), and name the authenticated caller in the connection observation recorded for MCP.
, #14961) terminal_websocket accepted any handshake that cleared the Origin check and attached it to a live shell -- and _init_terminal_handler resolved an unknown session_id via config.get(session_id, {}), so a missing, expired or fabricated session_id was indistinguishable from a real one and silently got a STANDARD-security terminal built from empty defaults. create_terminal_session now stamps the authenticated creator as the session's owner via security.session_ownership.build_owner_metadata (the one builder every ownership-stamping path uses), never the unauthenticated client-supplied request.user_id field. terminal_websocket authenticates before accept() via enforce_ws_authentication, then resolves the session through the new _lookup_terminal_session, which returns None -- denying the connection -- for both an unknown session_id and an authenticated caller who is not its owner. _init_terminal_handler now takes the already-validated config directly, so it can no longer default a security level for a session that was never created.
#14960) TerminalService.connect() opened the terminal WebSocket with no token at all -- the client half of #14960's handshake authentication. Reuse the existing buildAuthenticatedWsUrl helper (#6700), the one place every WS service is meant to attach its JWT, instead of connecting unauthenticated and deferring the connect when no token is available yet.
#14959, #14960, #14991) These four docs described /api/vnc-proxy/{type}/websockify and /api/terminal/ws/{id} with no mention of a credential, which now reads as an implicit "none required." Note the handshake authentication, the terminal ownership check, and the SSH endpoint's admin requirement where each socket is documented.
Applied Black, isort, and autoflake to match code-quality checks. Triggered by workflow auto-fix-formatting.yml.
…dget (#14961) api/simple_terminal_e2e_test.py's new session-creation check used `return False` -- pytest discards it, so a failed session creation still reported green (#14920's exact failure mode) and pushed autobot-backend's known-offender count from 75 to 76, above the ratchet's ceiling. Converted to `assert session_id, ...`. That alone would have failed every run with no live backend configured -- CI's python-suite never sets AUTOBOT_TEST_BACKEND_URL, so the assert would fire unconditionally. This is a live e2e smoke test, not a unit test; it now skips loudly when no backend is configured, matching the tests_that_ return... ratchet's own "loud, not silent" standard rather than either silently passing (the pre-existing shape) or failing every unconfigured run. tests_that_return_instead_of_asserting_test.py: lowered autobot-backend's recorded budget 75 -> 73, matching the actual count after this fix plus one further pre-existing offender landed independently via the Dev_new_gui merge -- the ratchet requires the number never sit stale above the true count.
… tokens (#14991) Stylelint's color-no-hex flagged 10 pre-existing hex values in this file's <style> block, pulled into changed-file scope by the <script> edit for #14991's token attachment. Four already had exact-match existing tokens (--terminal-chrome-bg-alt, --terminal-chrome-bg, --terminal-border-subtle, --terminal-text-muted); the connecting/connected/error status-bar colors had none, so extended the same literal-hex TERMINAL COMPONENT TOKENS block in design-tokens.css (#11859) that already carries this component family's palette, rather than remapping a live-connection status bar onto the semantic --color-success/warning/error tokens or hardcoding new values.
… URLs (#14989) useWebSocket.ts logged the full connection URL on open and on an invalid-URL error; TerminalService's _validateWsUrl embedded it in a thrown message. Those URLs carried no credential before this PR -- buildAuthenticatedWsUrl (#6700) now puts a JWT in `?token=...`, so every one of those lines wrote a live token into the browser console (and the thrown message reaches a further logger.error via _handleConnectCatchError). redactUrlForLogging masks the VALUE of token/credential-shaped query params, keeping the parameter name visible for debugging, and never throws -- the Invalid URL branch is exactly where the input may not parse, so a failed `new URL()` falls back to a string substitution instead of raising.
…nection URLs (#14989) Same fix as the autobot-frontend composable, for @autobot/terminal's own useWebSocket.ts -- SshTerminal.vue's connection URL now carries a JWT (#14991). createLogger() here is a no-op today, so there is no live console exposure through it yet, but the log calls must not start leaking the token the day that stops being true. Duplicated rather than shared: @autobot/terminal is a standalone package (peerDependencies only -- vue/pinia/xterm) consumed by both autobot-frontend and autobot-slm-frontend, with no shared frontend utility package between them to hold one copy in. Same logic, same reasoning, kept in sync by hand.
Drives the real composable/service, not redactUrlForLogging() in isolation -- the bug was that the caller logged the raw value, so a helper-only test would pass even if connect() stopped calling it. Spies on the real console.info/console.error the createLogger wrapper writes to (matching this suite's existing convention, e.g. useLocalStorage.test.ts), using the established MockWebSocket/websocket-mock fixture. Covers: a successful connection's "Connected to:" log has the token replaced with REDACTED while other query params survive; an empty/invalid URL logs without throwing and without leaking; TerminalService's _validateWsUrl throws a redacted message (a defensive path -- baseUrl is always resolved to a valid ws://wss:// prefix in normal use, so this one is not reachable through connect()'s public happy path today, unlike the composable tests above).
…actor (#14989) redactUrlForLogging's URL-API branch only ever touches searchParams -- new URL('wss://h/ws?x=1#token=SECRET').toString() leaves #token=SECRET completely untouched, so a credential placed in a fragment bypassed redaction entirely. No current call site does that, but the doc comment already claimed general coverage. The regex substitution now always runs as a second pass over the final string (query-redacted or not), covering the fragment case the URL API branch cannot reach on its own. The doc comment also claimed "never throws", which was not literally true: a non-string argument reaching the catch fallback called .replace() on the original uncoerced value. Every call site is TS-typed string so this was unreachable through the compiled app, but made it literally true rather than only true for well-typed callers. Same fix in both copies (autobot-frontend and autobot-plugins/terminal -- #15002 tracks the duplication itself).
…essages (#14989) useWebSocket.ts's connect() has no upstream scheme validation -- buildAuthenticatedWsUrl is plain string concatenation with no scheme check -- so a misconfigured base URL (http:// where ws:// was meant) reaches new WebSocket(), which throws a SyntaxError whose .message embeds the full attempted URL verbatim in every major browser (Chrome: "The URL's scheme must be either 'ws' or 'wss'. '<url>' is not allowed."). That raw error, JWT included, went to a real logger.error. TerminalService._handleConnectCatchError has the same shape: _validateWsUrl closes the wrong-scheme case, but not every WHATWG parse failure (unusual host, percent-encoding), so the same native-throw path applies there too. redactErrorForLogging rewrites only the message text -- name and stack are preserved -- so the error stays useful for debugging rather than being swallowed. Applied to both useWebSocket.ts copies (the plugin's own createLogger() is a no-op today, so this one has no live exposure yet, but the log call must not start leaking the day that changes) and to TerminalService.ts.
…action paths (#14989) redactUrlForLogging.test.ts tests the helper directly for the fragment and non-string cases -- neither has a real call site to drive today, unlike the composable/service bugs. useWebSocket.redaction.test.ts and TerminalService.redaction.test.ts gain a case each that forces a real new WebSocket() throw (or its private-method equivalent) and asserts the resulting console.error call is redacted, driving the real catch block rather than asserting against redactErrorForLogging() in isolation.
…er their ceilings (#14959) The ownership work pushed all three past ceilings a grandfathered file may not cross. Nothing was deleted to hit a number -- each block moved to a sibling and stays wired in, and every ceiling is re-lowered to the new size in both copies. api/terminal.py 1504 -> 1300 (ceiling 1420 -> 1300): the SSH stub handler, its session manager and the three websocket helpers move to api/terminal_ssh.py, the same extraction terminal_handlers (#210) and terminal_tools (#185) already got. The /ws/ssh/{host_id} route stays on the router and imports them back under this module's own names, so api.terminal.ssh_terminal_manager still resolves for its existing patch target. api/agent_terminal.py 1115 -> 858 (ceiling 1112 -> 858): the five host-selection endpoints and the pending-selection store nothing else touches become a sub-router in api/agent_terminal_host_selection.py, included by the parent. All 18 registered paths are byte-identical before and after. services/agent_terminal/service.py 1022 -> 969 (ceiling 1015 -> 969): the two command-audit helpers read no service state beyond the logger handed to them, so they move to the package's utils module as free functions and both call sites follow.
Applied Black, isort, and autoflake to match code-quality checks. Triggered by workflow auto-fix-formatting.yml.
Auto-regenerated (py3.14) to match the backend and/or SLM backend schema so the required verify-generated-types gate(s) pass. Triggered by auto-fix-generated-types.yml.
f0f2871 to
ae98eb8
Compare
|
The file measured 1300 locally and 1299 in CI. The missing line was not drift: CI's own Both copies now say 1299 ( Two things nearly went wrong while fixing it, both caught rather than discovered later. First, Second, rebasing onto the new base conflicted in Verified after the rebase: |
…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.
Thinking Path
Three review passes extended this PR's original scope (#14959/#14960/#14961) with a fourth
finding and several correctness gaps in the ownership gate itself, plus two CI failures caught
by a later review. Each is addressed below; the reasoning that mattered most:
Correction carried over from review, stated plainly so this body does not misstate it:
terminal.py's router carries a router-leveldependencies=[Depends(check_admin_permission)]that does not resolve for WebSocket routes --
check_admin_permissionis typed to take anHTTP
Request, which a WebSocket handshake cannot supply, so FastAPI's dependency solverraises a
TypeErrorbefore either the check or the route body runs. Verified independentlythree ways including a live probe: this currently makes every handshake to
terminal_websocketandssh_terminal_websocketfail with HTTP 500, for every caller,authenticated or not. These two routes are not open doors in production today -- they are
broken in a different way, tracked separately as #14998, whose own text requires that the
routing fix not land before this PR does (fixing #14998 first would turn today's 500 into a
genuinely open route the moment this PR's guards are not already there to catch it).
api/vnc_proxy.py's router is a bareAPIRouter()with no router-level dependency --that route (#14959) is unaffected by any of this and was, and remains, genuinely and currently
reachable and unauthenticated.
terminal.pyis the only WS-bearing router in the backend witha router-level
Depends.#14991:
ssh_terminal_websockethad the identical unauthenticated-accept() shape asterminal_websocket, and is wired to the primary chat UI (SSHTerminal.vuemounted inChatTabContent.vue) -- currently inert per the #14998 500 above, andSSHTerminalWebSocketitself is an inert deprecation stub regardless (#729/#3383: SSH moved to slm-server). Fixed
anyway, because #14998 explicitly depends on this PR landing first. There is no per-host
permission model anywhere in this codebase to scope a
host_idagainst a caller's permittedset, and #14958's own umbrella explicitly rules out inventing one here. The strictest reading
available without inventing a new capability model is the router's own already-declared intent
(
Depends(check_admin_permission)at router level) -- which is exactly the dependency thatdoes not resolve for WebSocket routes, per the correction above. So the fix checks admin role
explicitly, in code, for every
host_id.Credential union:
authenticate_websocketonly reads a JWT from the query string -- the onecredential a browser can present on a WS handshake (no custom headers). A cookie-session,
Authorization-header, or internal-service-key caller had no path through it -- the samelockout #11016 already fixed for the admin workspace shell. Widened
enforce_ws_authenticationto try the query-param JWT first, then
get_user_from_request(header/session/dev-header) andthe internal-service key, denying only when all fail. This is a union of the accepted checks
(more ways to prove identity), not a widening of what any one of them accepts. It also
transparently fixed the case of a session created via the internal-service key (
owner = "service:slm") becoming permanently unconnectable -- the same key now authenticates the WShandshake to the same synthetic principal.
Close-code convention: kept close-before-
accept()with code1008for all three routes,matching
enforce_ws_origin's existing convention in the same file and the original issues'explicit acceptance criteria ("rejected with close code 1008", "before
accept()"). Thiscoexists with a second convention elsewhere (
api/websockets.py,api/live_events.py:accept-then-close-
4001, pinned bytests/test_websockets_auth_reject_12366.py). I did notunify them -- that is a repo-wide sweep (#14965 territory), and the two conventions serve
different call sites already. The practical cost of keeping close-before-accept: the WHATWG
WebSocket spec requires browsers to normalise the close code (and reason) to
1006for anyclosure before the opening handshake completes, so browser JS can never distinguish "denied"
from "offline" from the close event alone. That is the root cause of the retry-storm finding
below, fixed on the client side instead of by switching conventions.
Ownership-gate correctness (blockers from a second review pass): the gate denies by design
when an entry has no
owner, but a second writer of the sharedsession_configsdict --SessionManager._register_pty_with_terminal_manager, the Chat Terminal path reached fromPOST /api/agent-terminal/sessions-- never stamped one, so it denied even its own creator.Fixed by threading the authenticated username through
create_agent_terminal_session->AgentTerminalService.create_session->SessionManager.create_sessiondown to the write site.session_configsis a plain in-process dict, wiped on every restart(
api/terminal_handlers.py), so there is no legacy-session migration concern -- every entryeither has today's shape or does not exist yet, which is exactly why deny-by-default is safe.
A related claim -- that the dormant
TerminalWindow.vuebreaking was "every user, every time"-- did not hold up:
TerminalWindowis exported from a barrel file and used only in Storybook;nothing mounts it. The
TerminalService.tstoken fix from the first round is real (reuses theestablished
buildAuthenticatedWsUrlhelper correctly) but is exercised by no live flow --stated here explicitly rather than implied otherwise.
What Changed
autobot-backend/api/ws_security.py--enforce_ws_authentication(added in the firstround) now tries three credential sources in order via a new
_resolve_ws_userhelper:query-param JWT, the internal-service key, then
get_user_from_request(Authorization header /X-Session-ID/ dev header). Denies (close1008pre-accept()) only when all three miss.autobot-backend/api/terminal.py:ssh_terminal_websocket(SSH terminal WebSocket accepts unauthenticated connections and starts a live shell on an infrastructure host #14991) -- authenticates viaenforce_ws_authentication, thenrequires admin role via
is_admin_role(autobot_shared.auth.permissions), beforeaccept(). Docstring records why admin-only is the correct default absent a per-host model.create_terminal_sessioncontinues stampingownerfrom the authenticated caller(first round, unchanged).
autobot-backend/services/agent_terminal/session_manager.py,autobot-backend/services/agent_terminal/service.py,autobot-backend/api/agent_terminal.py-- thread
owner: str | None(the authenticated creator's username) fromcreate_agent_terminal_sessiondown throughAgentTerminalService.create_session,SessionManager.create_session,_setup_pty_for_session, to_register_pty_with_terminal_manager, which now stamps it into the sharedsession_manager.session_configsentryapi.terminal._lookup_terminal_sessionreads.Frontend (
autobot-frontend,autobot-plugins,autobot-slm-frontend) -- the SSH terminalguard needed its live credential-carrying counterpart or it breaks the chat SSH terminal:
autobot-frontend/src/components/terminal/SSHTerminal.vue(mounted inChatTabContent.vue) -- reusesbuildAuthenticatedWsUrl(refactor(frontend/ws): extract shared buildAuthenticatedWsUrl() helper — token plumbing is duplicated and inconsistent across WS services #6700), same helper as the earlierTerminalService.tsfix. Refuses to connect client-side with no token.autobot-plugins/terminal/src/components/SshTerminal.vue(the shared@autobot/terminalpackage, consumed by both
autobot-frontendandautobot-slm-frontend) has no auth store ofits own -- added an
authToken?prop; host apps resolve and pass their own token.autobot-frontend/src/components/plugins/TerminalPlugin.vuesupplies it fromuseUserStore.autobot-slm-frontend/src/views/tools/admin/TerminalTool.vuesupplies it from its ownstores/auth.ts(a separate JWT domain behind the same SSO backend).autobot-frontend/src/services/TerminalService.ts--_handleWsCloseno longerauto-retries a connection that never reached
CONNECTED. Browsers cannot surface the real closecode/reason for a pre-
accept()rejection (spec-mandated1006normalisation), so the fix uses"did this attempt ever connect" instead of the close code to decide whether a retry makes sense,
removing the 6-attempt storm and the permanently-poisoned
reconnectAttemptscounter.autobot-backend/api/simple_terminal_e2e_test.py-- previously connected with a fabricatedsession_idand no credential, both now rejected. Creates a real session viaPOST /api/terminal/sessionsfirst and presents the SSOT-configured internal-service key (thecanonical credential, reused rather than a parallel path) on both the REST call and the socket.
Docs --
docs/guides/vision-vnc-ui-testing.md,docs/developer/VNC_MCP_ARCHITECTURE.md,docs/architecture/TERMINAL_ARCHITECTURE_DIAGRAM.md,docs/architecture/TERMINAL_CONSOLIDATION_ANALYSIS.md-- each documented one of these socketswith no mention of a credential; added a note describing the authentication (and, for terminal,
ownership) requirement where the socket is documented.
Tests --
api/vnc_proxy_websocket_auth_test.py,api/terminal_websocket_auth_test.py,api/ssh_terminal_websocket_auth_test.py(new),services/agent_terminal/session_manager_owner_test.py(new). See Verification.
CI fixes (two red checks caught by review, both on this PR's own additions):
api/simple_terminal_e2e_test.py-- the session-creation check I added usedreturn False, which pytest discards silently; that is a new return-instead-of-assertoffender and
repo_tests/tests_that_return_instead_of_asserting_test.py'sautobot-backendbudget is down-only (was pinned at 75). Converted toassert session_id, .... That alone would fail this test on every run with no livebackend -- CI's
python-suitenever setsAUTOBOT_TEST_BACKEND_URL-- so it now skipsloudly first when unconfigured, rather than either silently passing (this file's other,
pre-existing, untouched checks still do that -- not newly counted, not touched here) or
failing every unconfigured run.
tests_that_return_instead_of_asserting_test.py'srecorded budget for
autobot-backendis lowered75 -> 73, matching the actual countafter this fix plus one further pre-existing offender that landed independently via the
Dev_new_guimerge.autobot-frontend/src/components/terminal/SSHTerminal.vue-- editing its<script>forthe token fix pulled its untouched
<style>block into Stylelint's changed-file scope,where 10 pre-existing hardcoded hex colors failed
color-no-hex. Four already had exactmatching tokens (
--terminal-chrome-bg-alt,--terminal-chrome-bg,--terminal-border-subtle,--terminal-text-muted); the other six (connecting/connected/error status colors) had none, so extended the existing, deliberately-literal
TERMINAL COMPONENT TOKENSblock indesign-tokens.css(tokenise(user): TerminalWindow — 65 hardcoded hexes + 49 px → design tokens (#11515 Task 4.3/4.4) #11859) that already carriesthis exact component family's palette, rather than remapping a connection-status bar onto
the semantic
--color-success/warning/errortokens or hardcoding new values.Not changed / explicitly decided against:
TerminalWindow.vue) -- not fixed. Thatcomponent is unreachable from any live template; wiring it to call
createSession()first isreal work for when it is actually mounted, not a live regression today.
credential-union fix above, not by a separate change.
@router.websocketroutes found in a full sweep -- listedbelow, not touched (per instruction, scoped to Per-route WebSocket auth sweep with a route-level (not file-level) guard #14965). One correction to the sweep the
coordinator shared:
api/advanced_control.py's/ws/desktop/{session_id}is guarded(
enforce_ws_admin), not unauthenticated -- verified by reading the route directly.Credential logging fix (flagged by a background security review of this PR's own diff):
before this PR, WebSocket connection URLs carried no credential, so logging them was harmless.
buildAuthenticatedWsUrl(#6700) now puts a JWT in?token=..., and bothuseWebSocket.tscomposables (
autobot-frontend,autobot-plugins/terminal) andTerminalService.tslog thatfull URL on connect and on an invalid-URL error -- turning those into live token leaks into the
browser console.
redactUrlForLoggingmasks the VALUE oftoken/credential-shaped query params, keepingthe parameter name visible (
?token=REDACTED) so the log line stays useful, and never throws-- the invalid-URL branch is exactly where the string may not parse, so a failed
new URL()falls back to a regex substitution instead of raising. Wired into every log site the review
flagged, in
autobot-frontend/src/composables/useWebSocket.ts,autobot-plugins/terminal/src/composables/useWebSocket.ts, andTerminalService.ts.TerminalService._validateWsUrlthrows`Invalid WebSocket URL: ${wsUrl}`with the same token-bearing value, and that messagereaches a further
logger.errorvia_handleConnectCatchError-- redacted the same way.baseUrlis always resolved to a validws:///wss://prefix in normal use, so this branchis a defensive fix rather than one reachable through
connect()'s public happy path today.autobot-frontend/src/utils/redactUrlForLogging.tsandautobot-plugins/terminal/src/utils.ts:@autobot/terminalis a standalone package(peerDependencies only) consumed by both
autobot-frontendandautobot-slm-frontend, withno shared frontend utility package between them to hold one copy in. The plugin's own
createLogger()is a no-op today (per its own doc comment), so this specific composable hasno live console exposure yet -- fixed anyway so it cannot start leaking the day that changes.
Follow-up (flagged by a second, focused security review of the redaction delta itself --
the redactor's own logic was independently transcribed and run against 11 adversarial inputs and
confirmed sound): the "not changed" call I made above -- the two composables'
catch (err) { logger.error(..., err) }blocks -- was wrong to defer. It is a live leak, not a theoretical one:buildAuthenticatedWsUrldoes plain string concatenation with no scheme check, so amisconfigured base URL (
http://wherews://was meant) reachesnew WebSocket(), whichthrows a
SyntaxErrorembedding the full attempted URL verbatim in every major browser (Chrome:"The URL's scheme must be either 'ws' or 'wss'. '' is not allowed."). That raw error, JWT
included, went to a real
console.error.redactErrorForLogging(err)(same two files asredactUrlForLogging) rewrites only anError's.messagethrough the same redactor, preserving.nameand.stackso the errorstays useful for debugging. Wired into
autobot-frontend/src/composables/useWebSocket.ts'sconnect() catch (MEDIUM, no upstream scheme validation makes this reachable) and
TerminalService._handleConnectCatchError(LOW, defensive --_validateWsUrlalready closesthe wrong-scheme case, but not every WHATWG parse failure). Also applied to the plugin's own
useWebSocket.tscatch for symmetry, though itscreateLogger()no-op means it has no liveexposure yet either, same as its other two call sites.
redactUrlForLoggingitself, confirmed reachable-in-principle by executionduring review: (1) a credential in the URL fragment bypassed both branches --
URLSearchParamsnever looks at.hash, and the regex fallback required a[?&]prefix, so#token=...matched neither. The regex substitution now always runs as a second pass over thefinal string (covering the fragment) rather than only as the
new URL()failure fallback. (2)"Never throws" was not literally true -- a non-string argument bypassing the TS type system hit
.replace()on the original uncoerced value in the catch fallback and threwTypeError. Addedan explicit
typeofguard. Both fixes applied to both copies, kept behaviourally identical.Verification
Local interpreter: Python 3.10.12 (this dev sandbox's
/usr/bin/python3; CI is authoritative).pytest api/vnc_proxy_websocket_auth_test.py api/terminal_websocket_auth_test.py api/ssh_terminal_websocket_auth_test.py services/agent_terminal/session_manager_owner_test.py-- 11 passed.
suites): 91 passed, 0 failed.
pytest api/ services/agent_terminal/ -k "terminal or vnc or ws_security or agent_terminal" --collect-only-- 1084 tests collected, no import errors.confirmed identical to the pre-mutation file via
diff, nothing pushed):ssh_terminal_websocket: removing the auth call and disabling the admin-role checkindependently each fail their respective dedicated tests.
_lookup_terminal_session: the existing-session-vs-owner-mismatch discrimination gap areviewer found (both branches exited through the same
close(1008), so reverting theexistence check to
.get(id, {})still passed the old test) is now closed -- the unknown-session test asserts the specific "unknown session_id" log line and asserts the "not the
owner" line is absent; reverting to
.get(id, {})now fails it correctly.SessionManager._register_pty_with_terminal_manager: reverting to the pre-fix shape (noownerkey written) fails both new owner-stamping tests.python3 -m py_compileclean on every edited backend.pyfile.repo_tests/tests_that_return_instead_of_asserting_test.py-- 5 passed (not run before thisround; adding it to Verification per review feedback -- these tree-wide guards move on any
PR, not just ones that touch their own file, and must be checked explicitly).
repo_tests/in full -- 1217 passed, 4 skipped, 2 xfailed, 6 failed. All 6 failures verifiedpre-existing and unrelated: a
tomllibModuleNotFoundError(this sandbox runs Python 3.10;the check needs 3.11+) affecting 4 tests in
pip_ignore_scope_test.py; one failure intest_ci_import_smoke_paths_14252.pyover an unrelatedphase_validation.ymlinline-Pythonparse; and
with_error_handling_single_definition_test.py's tree-wide scan finding zerodefinitions because its exclusion filter (
"/.worktrees/" in path) matches this session's owncheckout path when run from inside a worktree -- confirmed by running the file's other two
tests, which parse the canonical file directly and pass. None reproduce from a normal branch
checkout, and none relate to anything in this diff.
tsc/node_modules/vitestin this sandbox (nothing installed, per policy);the pre-push hook attempted
vue-tscand skipped for the same reason. Every.vue/.tschange in this PR, including all four new/updated redaction test files, was therefore
unverified locally -- CI's
Unit & Integration Testsjob (self-hosted runner,frontend-test.yml) is the only thing that has actually executed them. Confirmed green onthis PR's current head (
02deb71a65476e2ec7bdf00cc1292f9aab6bb15b):Detect changed paths,Security Scan,Unit & Integration Tests,Build TestandTest Summaryall completed withconclusion
success(checked via the GitHub Actions API against the actual job run, not theseparate
frontend-required-context.ymlshim, which is a distinct workflow that correctlyskips itself whenever this one applies). Manually traced
redactUrlForLogging's logic againstNode's own
URL/URLSearchParams(available in this sandbox) to confirm theredact-then-fallback behaviour before writing the composable
tests against it; that is not a substitute for CI actually running the
.tstest files.Unit & Integration Testsrun confirms all seven frontend redaction tests (theoriginal three plus this round's four -- two new cases in the existing composable/service
spec files, plus the new
redactUrlForLogging.test.ts) execute and pass as authored, whichthis sandbox could not confirm on its own. Mutation itself was not pushed (per instruction),
so the following is reasoning traced through the code, not a CI-executed mutation run: reverting
any
redactUrlForLogging(wsUrl)call back to barewsUrlputs the raw token back into theconsole.info/console.errorspy's captured args, failing the correspondingnot.toContain(TOKEN)assertion; reverting aredactErrorForLogging(err)call back to bareerrdoes the same for the connection-error tests; removing the fragment/typeofguards fromredactUrlForLoggingfailsredactUrlForLogging.test.ts's fragment and non-string casesdirectly.
Known gap in this PR's own history: one commit subject
(
fix(terminal): update the e2e smoke script...) cites#14989-- this PR's own number --instead of an issue. Left as-is rather than rewrite already-reasoned-about history; flagging
for whoever gates the merge.
Remaining unauthenticated
@router.websocketroutes (report only, not fixed)Full sweep of all 24
@router.websocket(declarations inautobot-backend; 3 are the routesthis PR fixes (
api/vnc_proxy.py:374,api/terminal.py:937,1071), 7 were already guarded(
api/transcripts.py:154,api/websockets.py:787,858,api/live_events.py:384,api/task_workspace_ws.py:245,api/advanced_control.py:530,572). The remaining 14, verifiedunauthenticated by reading each route (not just grepping a marker string):
/streamapi/intelligent_agent.py:254/streamapi/voice_stream.py:316/wsapi/analytics_quality.py:1668/{operation_id}/progressapi/long_running_operations.py:494/tail/{filename}api/logs.py:848/ws/sessions/{session_id}/presenceapi/presence_ws.py:24(identity from an unverified?user_id=)/ws/knowledge/researchapi/knowledge_research_ws.py:119/processes/{process_id}/streamapi/process_management.py:172/ws/{session_id}api/overseer_handlers.py:435(runs commands)/wsapi/startup.py:127/ws/realtimeapi/analytics.py:675/ws/analytics/liveapi/analytics.py:1098/realtimeapi/monitoring.py:1148/workflow_ws/{session_id}services/workflow_automation/routes.py:596Model Used
Claude Sonnet 5 (claude-sonnet-5)
Closes #14959, #14960, #14961, #14991