From c5973cd540f1e43e021a339e546cd72a57347e30 Mon Sep 17 00:00:00 2001 From: Erosika Date: Fri, 4 Sep 2026 15:31:18 -0400 Subject: [PATCH] feat(tui-gateway): build the agent with the authenticated dashboard user as user_id the dashboard login already stamps {user_id, provider} on the websocket at upgrade time and tui_gateway keeps it as WSTransport.auth_identity, but _make_agent never read it. every dashboard and desktop session was built with user_id=None, so memory providers saw no runtime user and fell back to the configured peer, mixing all logins together (#89794). _make_agent now reads the session transport's auth_identity and passes its user_id to AIAgent, the kwarg gateway platforms already use. the legacy ?token= path, stdio, and the server-internal credential the PTY child connects with carry no human and pass None, so they keep resolving to the configured peer. the change is provider-neutral: honcho and any other memory provider receive the id through the existing initialize() kwargs. --- tests/test_tui_gateway_server.py | 52 ++++++++++++++++++++++++++++++++ tui_gateway/server.py | 16 ++++++++-- tui_gateway/ws.py | 3 +- 3 files changed, 68 insertions(+), 3 deletions(-) diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index a287b70416..e47d9ec737 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -4549,6 +4549,58 @@ def test_make_agent_passes_configured_fallback_chain(monkeypatch): assert captured["platform"] == "tui" +def _capture_make_agent_kwargs(monkeypatch) -> dict: + """Stub AIAgent so ``server._make_agent`` records the kwargs it was built with.""" + captured = {} + + def fake_agent(**kwargs): + captured.update(kwargs) + return types.SimpleNamespace(model=kwargs.get("model")) + + _setup_make_agent_mocks(monkeypatch, {}) + monkeypatch.setattr("run_agent.AIAgent", fake_agent) + return captured + + +def test_make_agent_passes_the_authenticated_dashboard_user_as_user_id(monkeypatch): + """The identity stamped at WS-upgrade auth (WSTransport.auth_identity) reaches the agent as + ``user_id``, the same kwarg gateways pass, so memory providers scope memory to the login (#89794).""" + captured = _capture_make_agent_kwargs(monkeypatch) + transport = types.SimpleNamespace(auth_identity={"user_id": "oidc|abc123", "provider": "oidc"}) + monkeypatch.setitem(server._sessions, "sid-auth", {"session_key": "k", "transport": transport}) + + server._make_agent("sid-auth", "k") + + assert captured["user_id"] == "oidc|abc123" + + +@pytest.mark.parametrize("identity", [ + None, + {"user_id": "server-internal", "provider": "server-internal"}, + {"user_id": "", "provider": "oidc"}, + {"user_id": "abc", "provider": ""}, +]) +def test_make_agent_passes_no_user_id_without_an_authenticated_human(monkeypatch, identity): + """Legacy token, stdio and the PTY child's server-internal credential name no human; the + agent must not receive a user id memory would treat as a person.""" + captured = _capture_make_agent_kwargs(monkeypatch) + transport = types.SimpleNamespace(auth_identity=identity) + monkeypatch.setitem(server._sessions, "sid-anon", {"session_key": "k", "transport": transport}) + + server._make_agent("sid-anon", "k") + + assert captured["user_id"] is None + + +def test_make_agent_passes_no_user_id_for_an_unknown_session(monkeypatch): + captured = _capture_make_agent_kwargs(monkeypatch) + server._sessions.pop("sid-missing", None) + + server._make_agent("sid-missing", "k") + + assert captured["user_id"] is None + + def test_background_agent_kwargs_preserves_full_fallback_chain(monkeypatch): chain = [ {"provider": "openrouter", "model": "openai/gpt-5.5"}, diff --git a/tui_gateway/server.py b/tui_gateway/server.py index e0c848d454..b65198277e 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -2255,6 +2255,15 @@ def _startup_system_prompt(cfg: dict, task_id: str) -> str: return system_prompt +def _session_auth_user_id(session: dict | None) -> str | None: + """User id the WS-upgrade credential authenticated for ``session``'s transport, or None for the legacy + token, stdio, and the server-internal credential the PTY child connects with.""" + identity = getattr((session or {}).get("transport"), "auth_identity", None) + if _methods_browser_control._is_authenticated_identity(identity): + return str(identity["user_id"]).strip() + return None + + def _make_agent( sid: str, key: str, session_id: str | None = None, session_db=None, model_override: dict | str | None = None, provider_override: str | None = None, @@ -2280,6 +2289,8 @@ def _make_agent( _pr = _load_provider_routing() platform = _resolve_agent_platform(platform_override) ignore_rules = is_truthy_value(os.environ.get("HERMES_IGNORE_RULES")) + with _sessions_lock: + session = _sessions.get(sid) agent = AIAgent( model=model, max_iterations=_cfg_max_turns(cfg, 500), provider=runtime.get("provider"), base_url=runtime.get("base_url"), api_key=runtime.get("api_key"), api_mode=runtime.get("api_mode"), @@ -2294,14 +2305,15 @@ def _make_agent( providers_allowed=_pr.get("only"), providers_ignored=_pr.get("ignore"), providers_order=_pr.get("order"), provider_sort=_pr.get("sort"), provider_require_parameters=_pr.get("require_parameters", False), provider_data_collection=_pr.get("data_collection"), platform=platform, session_id=session_id or key, + # The dashboard login identity reaches memory providers as the runtime user, like a gateway user id. + user_id=_session_auth_user_id(session), session_db=session_db if session_db is not None else _get_db(), ephemeral_system_prompt=system_prompt or None, checkpoints_enabled=is_truthy_value(os.environ.get("HERMES_TUI_CHECKPOINTS")), pass_session_id=is_truthy_value(os.environ.get("HERMES_TUI_PASS_SESSION_ID")), skip_context_files=ignore_rules, skip_memory=ignore_rules, fallback_model=_load_fallback_model(), **_agent_cbs(sid)) if context_cwd_is_launch_artifact is None: - with _sessions_lock: - context_cwd_is_launch_artifact = _context_cwd_is_launch_artifact(_sessions.get(sid)) + context_cwd_is_launch_artifact = _context_cwd_is_launch_artifact(session) agent._context_cwd_is_launch_artifact = bool(context_cwd_is_launch_artifact) return agent diff --git a/tui_gateway/ws.py b/tui_gateway/ws.py index 480ed36636..0ab7ede75f 100644 --- a/tui_gateway/ws.py +++ b/tui_gateway/ws.py @@ -93,7 +93,8 @@ class WSTransport: self._loop = loop self._peer = peer #: Server-verified identity from the WS-upgrade credential, stamped by ``web_server._ws_auth_reason``; None - #: for legacy-token/stdio. RPC params can never populate it: sole identity authority for browser controllers. + #: for legacy-token/stdio. RPC params can never populate it: sole identity authority for browser controllers + #: and for the ``user_id`` the agent is built with (``server._session_auth_user_id``). self.auth_identity = auth_identity self._closed = False # Token-coalescing buffer. The lock guards the buffer + "armed" flag against worker threads