diff --git a/backend/package-lock.json b/backend/package-lock.json index 91beed8..de3dae9 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -4,14 +4,15 @@ "requires": true, "packages": { "": { + "name": "backend", "dependencies": { "ws": "^8.19.0" } }, "node_modules/ws": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", - "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/backend/package.json b/backend/package.json index 4414d1d..40058be 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,5 +1,5 @@ { "dependencies": { - "ws": "^8.19.0" + "ws": "^8.21.0" } } diff --git a/backend/routers/agent_shell.py b/backend/routers/agent_shell.py index bc95cc4..64aa5ab 100644 --- a/backend/routers/agent_shell.py +++ b/backend/routers/agent_shell.py @@ -29,6 +29,7 @@ from services.agent_shell_settings import ( get_agent_shell_settings, set_agent_shell_working_directory, ) +from services.agent_shell_ws_token import consume_agent_shell_ws_token, mint_agent_shell_ws_token logger = logging.getLogger(__name__) router = APIRouter(tags=["agent-shell"]) @@ -43,32 +44,15 @@ def _set_winsize(fd: int, rows: int, cols: int) -> None: fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize) -def _published_local_dashboard_ws(ws: WebSocket) -> bool: - """Browser → published Docker port appears as a bridge IP, not loopback. - - For the operator shell only, also accept when the upgrade request clearly - targets the local dashboard (Host/Origin on localhost). - """ - host_header = str(ws.headers.get("host") or "").strip().lower() - host_name = host_header.split(":", 1)[0] - if host_name in {"127.0.0.1", "localhost", "::1"}: - return True - - origin = str(ws.headers.get("origin") or "").strip().lower() - if origin.startswith("http://127.0.0.1:") or origin.startswith("http://localhost:"): - return True - if origin.startswith("https://127.0.0.1:") or origin.startswith("https://localhost:"): - return True - return False - - -async def _authorize_agent_shell_ws(ws: WebSocket, admin_key_query: str = "") -> None: +async def _authorize_agent_shell_ws( + ws: WebSocket, + admin_key_query: str = "", + ws_token_query: str = "", +) -> None: host = (ws.client.host or "").lower() if ws.client else "" - if ( - _is_trusted_local_runtime_host(host) - or _published_local_dashboard_ws(ws) - or (_debug_mode_enabled() and host == "test") - ): + if _is_trusted_local_runtime_host(host) or (_debug_mode_enabled() and host == "test"): + return + if consume_agent_shell_ws_token(ws_token_query): return admin_key = _current_admin_key() presented = str(admin_key_query or ws.headers.get("x-admin-key", "") or "").strip() @@ -142,6 +126,12 @@ async def read_agent_shell_settings() -> dict[str, Any]: return get_agent_shell_settings() +@router.post("/api/agent-shell/ws-token", dependencies=[Depends(require_local_operator)]) +async def mint_agent_shell_ws_token_route() -> dict[str, Any]: + token, expires_in = mint_agent_shell_ws_token() + return {"token": token, "expires_in": expires_in} + + @router.put("/api/agent-shell/settings", dependencies=[Depends(require_local_operator)]) async def write_agent_shell_settings(body: AgentShellSettingsUpdate) -> dict[str, Any]: try: @@ -160,10 +150,11 @@ async def agent_shell_websocket( cols: int = Query(default=80), rows: int = Query(default=24), admin_key: str = Query(default=""), + ws_token: str = Query(default=""), ) -> None: await ws.accept() try: - await _authorize_agent_shell_ws(ws, admin_key) + await _authorize_agent_shell_ws(ws, admin_key, ws_token) except WebSocketDisconnect: return diff --git a/backend/services/agent_shell_ws_token.py b/backend/services/agent_shell_ws_token.py new file mode 100644 index 0000000..9653b98 --- /dev/null +++ b/backend/services/agent_shell_ws_token.py @@ -0,0 +1,52 @@ +"""Short-lived, single-use WebSocket bootstrap tokens for the agent shell.""" + +from __future__ import annotations + +import secrets +import time +from threading import Lock + +_TOKEN_TTL_SECONDS = 60.0 +_MAX_ACTIVE_TOKENS = 256 + +_store: dict[str, float] = {} +_lock = Lock() + + +def _purge_expired(*, force: bool = False) -> None: + now = time.time() + with _lock: + expired = [token for token, expires in _store.items() if expires <= now] + for token in expired: + _store.pop(token, None) + if force and len(_store) > _MAX_ACTIVE_TOKENS: + for token in list(_store.keys())[: len(_store) - _MAX_ACTIVE_TOKENS]: + _store.pop(token, None) + + +def mint_agent_shell_ws_token() -> tuple[str, int]: + """Return (token, expires_in_seconds).""" + _purge_expired() + token = secrets.token_urlsafe(32) + expires_at = time.time() + _TOKEN_TTL_SECONDS + with _lock: + if len(_store) >= _MAX_ACTIVE_TOKENS: + _purge_expired(force=True) + _store[token] = expires_at + return token, int(_TOKEN_TTL_SECONDS) + + +def consume_agent_shell_ws_token(token: str) -> bool: + """Validate and burn a one-time token. Returns True when accepted.""" + cleaned = str(token or "").strip() + if not cleaned: + return False + now = time.time() + with _lock: + expires_at = _store.pop(cleaned, None) + return expires_at is not None and expires_at > now + + +def reset_agent_shell_ws_tokens_for_tests() -> None: + with _lock: + _store.clear() diff --git a/backend/tests/test_agent_shell_ws_auth.py b/backend/tests/test_agent_shell_ws_auth.py new file mode 100644 index 0000000..3bd08b5 --- /dev/null +++ b/backend/tests/test_agent_shell_ws_auth.py @@ -0,0 +1,129 @@ +"""Agent shell WebSocket auth regression tests (issue #407).""" + +from __future__ import annotations + +import sys +import types +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from starlette.websockets import WebSocketDisconnect + +if sys.platform == "win32": + fcntl_stub = types.ModuleType("fcntl") + fcntl_stub.ioctl = lambda *args, **kwargs: None + sys.modules.setdefault("fcntl", fcntl_stub) + termios_stub = types.ModuleType("termios") + termios_stub.TIOCSWINSZ = 0 + termios_stub.TCSAFLUSH = 0 + sys.modules.setdefault("termios", termios_stub) + pty_stub = types.ModuleType("pty") + pty_stub.openpty = lambda: (0, 0) + sys.modules["pty"] = pty_stub + +from routers import agent_shell # noqa: E402 +from services.agent_shell_ws_token import ( # noqa: E402 + consume_agent_shell_ws_token, + mint_agent_shell_ws_token, + reset_agent_shell_ws_tokens_for_tests, +) + + +@pytest.fixture() +def shell_client(): + app = FastAPI() + app.include_router(agent_shell.router) + with TestClient(app) as client: + yield client + + +@pytest.fixture(autouse=True) +def _reset_ws_tokens(): + reset_agent_shell_ws_tokens_for_tests() + yield + reset_agent_shell_ws_tokens_for_tests() + + +class TestAgentShellWsTokenStore: + def test_mint_and_consume_once(self): + token, expires_in = mint_agent_shell_ws_token() + assert expires_in > 0 + assert consume_agent_shell_ws_token(token) is True + assert consume_agent_shell_ws_token(token) is False + + +class TestAgentShellWsTokenRoute: + def test_loopback_can_mint_token(self, shell_client): + transport = shell_client._transport + transport.client = ("127.0.0.1", 12345) + response = shell_client.post("/api/agent-shell/ws-token") + assert response.status_code == 200 + body = response.json() + assert body["token"] + assert body["expires_in"] > 0 + + def test_remote_caller_cannot_mint_token(self, shell_client): + shell_client._transport.client = ("1.2.3.4", 12345) + with patch("auth._current_admin_key", return_value="test-admin-key-32chars-xxxxxxxxxx"): + response = shell_client.post("/api/agent-shell/ws-token") + assert response.status_code == 403 + + +class TestAgentShellWsAuthorization: + def test_remote_peer_with_spoofed_host_is_denied(self, shell_client): + shell_client._transport.client = ("1.2.3.4", 12345) + with pytest.raises((WebSocketDisconnect, Exception)): + with shell_client.websocket_connect( + "/api/agent-shell/ws", + headers={"host": "localhost:8000"}, + ) as ws: + ws.receive_text() + + def test_remote_peer_with_spoofed_origin_is_denied(self, shell_client): + shell_client._transport.client = ("1.2.3.4", 12345) + with pytest.raises((WebSocketDisconnect, Exception)): + with shell_client.websocket_connect( + "/api/agent-shell/ws", + headers={"origin": "http://localhost:3000"}, + ) as ws: + ws.receive_text() + + def test_remote_peer_with_valid_ws_token_is_accepted(self, shell_client): + shell_client._transport.client = ("127.0.0.1", 12345) + token = shell_client.post("/api/agent-shell/ws-token").json()["token"] + shell_client._transport.client = ("1.2.3.4", 12345) + with patch("sys.platform", "win32"): + with shell_client.websocket_connect(f"/api/agent-shell/ws?ws_token={token}") as ws: + payload = ws.receive_json() + assert payload["type"] == "error" + assert "Windows" in payload["message"] + + def test_ws_token_is_single_use(self, shell_client): + shell_client._transport.client = ("127.0.0.1", 12345) + token = shell_client.post("/api/agent-shell/ws-token").json()["token"] + shell_client._transport.client = ("1.2.3.4", 12345) + with patch("sys.platform", "win32"): + with shell_client.websocket_connect(f"/api/agent-shell/ws?ws_token={token}") as ws: + ws.receive_json() + with pytest.raises((WebSocketDisconnect, Exception)): + with shell_client.websocket_connect(f"/api/agent-shell/ws?ws_token={token}") as ws: + ws.receive_text() + + def test_loopback_peer_does_not_need_ws_token(self, shell_client): + shell_client._transport.client = ("127.0.0.1", 12345) + with patch("sys.platform", "win32"): + with shell_client.websocket_connect("/api/agent-shell/ws") as ws: + payload = ws.receive_json() + assert payload["type"] == "error" + assert "Windows" in payload["message"] + + @pytest.mark.asyncio + async def test_authorize_rejects_spoofed_headers_without_token(self): + ws = MagicMock() + ws.client = MagicMock(host="1.2.3.4") + ws.headers = {"host": "localhost:8000", "origin": "http://localhost:3000"} + ws.close = AsyncMock() + with pytest.raises(WebSocketDisconnect): + await agent_shell._authorize_agent_shell_ws(ws) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8b7f132..0468763 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -130,13 +130,13 @@ "license": "MIT" }, "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -145,9 +145,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", - "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "dev": true, "license": "MIT", "engines": { @@ -155,21 +155,21 @@ } }, "node_modules/@babel/core": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", - "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -186,14 +186,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -203,14 +203,14 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -220,9 +220,9 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "dev": true, "license": "MIT", "engines": { @@ -230,29 +230,29 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -262,9 +262,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { @@ -272,9 +272,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -282,9 +282,9 @@ } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, "license": "MIT", "engines": { @@ -292,27 +292,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", - "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", - "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -332,33 +332,33 @@ } }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", "debug": "^4.3.1" }, "engines": { @@ -366,14 +366,14 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -3627,9 +3627,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", - "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", + "version": "2.10.38", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz", + "integrity": "sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -3673,9 +3673,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", "dev": true, "funding": [ { @@ -3693,11 +3693,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" @@ -3795,9 +3795,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001774", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001774.tgz", - "integrity": "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA==", + "version": "1.0.30001799", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", + "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", "funding": [ { "type": "opencollective", @@ -3888,15 +3888,15 @@ "license": "MIT" }, "node_modules/concurrently": { - "version": "9.2.1", - "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", - "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==", + "version": "9.2.3", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.3.tgz", + "integrity": "sha512-ihjs0E2SxvDgq/MK418hX6YycQgKhsqxpbZuZbHo0yKfqDWdymWMjWYIpCIzqDDLLKClHlXev8whW/8WXmJ0BA==", "dev": true, "license": "MIT", "dependencies": { "chalk": "4.1.2", "rxjs": "7.8.2", - "shell-quote": "1.8.3", + "shell-quote": "1.8.4", "supports-color": "8.1.1", "tree-kill": "1.2.2", "yargs": "17.7.2" @@ -4226,9 +4226,9 @@ "license": "ISC" }, "node_modules/electron-to-chromium": { - "version": "1.5.302", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz", - "integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==", + "version": "1.5.375", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.375.tgz", + "integrity": "sha512-ZWP5eB4BVPW/ZYo9252hQZHZ5XavtsTgpbhcmMmRwymavC5AsLWQWBPaKMeNd2LW0KGby5HPXvj7+sr4ta5j/Q==", "dev": true, "license": "ISC" }, @@ -6198,10 +6198,20 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -7073,11 +7083,14 @@ } }, "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "version": "2.0.48", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.48.tgz", + "integrity": "sha512-1uz8041X6LoI6ZSdZacM9lVY28vuzDlSKitnpbSNK0RfKoIJkX29NBPVEFXhnuSuEOA9Ww0xnPJ+ILWbGAv8DA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/object-assign": { "version": "4.1.1", @@ -8187,9 +8200,9 @@ } }, "node_modules/shell-quote": { - "version": "1.8.3", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", - "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "version": "1.8.4", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz", + "integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==", "dev": true, "license": "MIT", "engines": { @@ -9024,9 +9037,9 @@ } }, "node_modules/undici": { - "version": "7.24.2", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.2.tgz", - "integrity": "sha512-P9J1HWYV/ajFr8uCqk5QixwiRKmB1wOamgS0e+o2Z4A44Ej2+thFVRLG/eA7qprx88XXhnV5Bl8LHXTURpzB3Q==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", "dev": true, "license": "MIT", "engines": { diff --git a/frontend/src/components/MeshChat/AgentShellPanel.tsx b/frontend/src/components/MeshChat/AgentShellPanel.tsx index 3dc0e57..482b97c 100644 --- a/frontend/src/components/MeshChat/AgentShellPanel.tsx +++ b/frontend/src/components/MeshChat/AgentShellPanel.tsx @@ -12,7 +12,7 @@ import { FitAddon } from '@xterm/addon-fit'; import '@xterm/xterm/css/xterm.css'; -import { resolveAgentShellWsUrl } from '@/lib/agentShellWs'; +import { mintAgentShellWsToken, resolveAgentShellWsUrl } from '@/lib/agentShellWs'; @@ -302,11 +302,12 @@ export default function AgentShellPanel({ active, expanded, onExpandedChange }: - const ws = new WebSocket(resolveAgentShellWsUrl(storedCwd)); + void (async () => { + const wsToken = await mintAgentShellWsToken(); + const ws = new WebSocket(resolveAgentShellWsUrl(storedCwd, wsToken ?? undefined)); + ws.binaryType = 'arraybuffer'; - ws.binaryType = 'arraybuffer'; - - wsRef.current = ws; + wsRef.current = ws; @@ -423,7 +424,7 @@ export default function AgentShellPanel({ active, expanded, onExpandedChange }: } }); - + })(); }, []); diff --git a/frontend/src/lib/agentShellWs.ts b/frontend/src/lib/agentShellWs.ts index 38c4d00..8c4025a 100644 --- a/frontend/src/lib/agentShellWs.ts +++ b/frontend/src/lib/agentShellWs.ts @@ -1,4 +1,21 @@ -export function resolveAgentShellWsUrl(cwd?: string): string { +export async function mintAgentShellWsToken(): Promise { + if (typeof window === 'undefined') return null; + try { + const res = await fetch('/api/agent-shell/ws-token', { + method: 'POST', + credentials: 'same-origin', + cache: 'no-store', + }); + if (!res.ok) return null; + const body = (await res.json()) as { token?: string }; + const token = String(body?.token || '').trim(); + return token || null; + } catch { + return null; + } +} + +export function resolveAgentShellWsUrl(cwd?: string, wsToken?: string): string { if (typeof window === 'undefined') return ''; const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'; const host = window.location.hostname || '127.0.0.1'; @@ -11,6 +28,8 @@ export function resolveAgentShellWsUrl(cwd?: string): string { const params = new URLSearchParams(); const trimmed = String(cwd || '').trim(); if (trimmed) params.set('cwd', trimmed); + const token = String(wsToken || '').trim(); + if (token) params.set('ws_token', token); const query = params.toString(); return `${protocol}://${host}:${port}/api/agent-shell/ws${query ? `?${query}` : ''}`; }