Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions autobot-backend/api/agent_terminal.py
Original file line number Diff line number Diff line change
Expand Up @@ -340,16 +340,16 @@ async def create_agent_terminal_session(
f"Must be one of: {[role.name.lower() for role in AgentRole]}",
)

# Create session
# Issue #14989: thread the authenticated creator through as owner, so the
# terminal WebSocket ownership gate (#14960) recognises them.
# owner (#14989/#14960) and tenant_id (#16975) both come from the JWT only --
# owner names the WebSocket ownership gate, tenant_id its org_id claim.
session = await service.create_session(
agent_id=request.agent_id,
agent_role=agent_role,
conversation_id=request.conversation_id,
host=request.host,
metadata=request.metadata,
owner=current_user.get("username"),
tenant_id=current_user.get("org_id"),
)

return {
Expand Down
17 changes: 6 additions & 11 deletions autobot-backend/protocols/agent_presence_feeds.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,21 +79,16 @@ async def sync_ai_stack_presence(
async def sync_session_presence(registry: AgentPresenceRegistry, session_manager: "SessionManager") -> None:
"""Report every live agent-terminal session; busy iff it has a running command.

tenant_id is UNKNOWN_TENANT for every session, not derived (#16947 review
asked for `conversation_id` -> owner -> org). Checked and reported back:
`AgentTerminalSession.conversation_id` is a chat_history correlation id
only -- `chat_history`'s own session/conversation mixins (base.py,
session.py, security.py) carry no user_id/org_id anywhere, and the
`owner` username threaded through `SessionManager.create_session` is not
persisted onto `AgentTerminalSession` for a later lookup to recover. The
real fix is threading tenant through at session-creation time, not
deriving it after the fact from a field that cannot carry it -- left as
a follow-up; UNKNOWN_TENANT is the correct value until that lands.
tenant_id (#16975) is `AgentTerminalSession.tenant_id`, captured at
creation from the creating principal's JWT `org_id` claim -- it cannot be
recovered afterwards (see that field's own docstring), so a session
created before #16975 or via a path with no authenticated context falls
back to UNKNOWN_TENANT here rather than guessing.
"""
for session_id, session in list(session_manager.sessions.items()):
registry.report(
kind=AgentKind.SESSION,
tenant_id=UNKNOWN_TENANT,
tenant_id=session.tenant_id or UNKNOWN_TENANT,
name=session_id,
instance_id=session_id,
busy=session.has_running_task(),
Expand Down
32 changes: 21 additions & 11 deletions autobot-backend/protocols/agent_presence_feeds_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,30 +139,40 @@ async def test_omitting_the_manager_reports_idle_not_a_guess(self):
assert registry.list_live()[0].busy is False


def _fake_session(*, agent_id: str = "claude", busy: bool = False, tenant_id: str | None = None):
return SimpleNamespace(agent_id=agent_id, has_running_task=lambda: busy, tenant_id=tenant_id)


class TestSyncSessionPresence:
@pytest.mark.asyncio
async def test_a_session_with_a_running_command_is_busy(self):
session_manager = SimpleNamespace(
sessions={
"sess-1": SimpleNamespace(agent_id="claude", has_running_task=lambda: True),
"sess-2": SimpleNamespace(agent_id="claude", has_running_task=lambda: False),
"sess-1": _fake_session(busy=True, tenant_id="tenant-x"),
"sess-2": _fake_session(busy=False, tenant_id="tenant-x"),
}
)
registry = AgentPresenceRegistry(ttl_seconds=60)

await sync_session_presence(registry, session_manager)

# Sessions carry UNKNOWN_TENANT (#16947 review): never listable via
# list_live(), by design -- inspect the stored record directly.
stored = {name: rec for (_, _, name), rec in registry._entries.items()}
assert stored["sess-1"].busy is True
assert stored["sess-2"].busy is False
entries = {e.name: e for e in registry.list_live("tenant-x")}
assert entries["sess-1"].busy is True
assert entries["sess-2"].busy is False

@pytest.mark.asyncio
async def test_sessions_are_reported_with_unknown_tenant_and_never_listed(self):
session_manager = SimpleNamespace(
sessions={"sess-1": SimpleNamespace(agent_id="claude", has_running_task=lambda: False)}
)
async def test_a_session_with_a_known_tenant_is_discoverable_within_it_only(self):
session_manager = SimpleNamespace(sessions={"sess-1": _fake_session(tenant_id="tenant-x")})
registry = AgentPresenceRegistry(ttl_seconds=60)

await sync_session_presence(registry, session_manager)

assert {e.name for e in registry.list_live("tenant-x")} == {"sess-1"}
assert registry.list_live("tenant-y") == []

@pytest.mark.asyncio
async def test_a_session_with_no_tenant_is_unknown_and_never_listed(self):
session_manager = SimpleNamespace(sessions={"sess-1": _fake_session(tenant_id=None)})
registry = AgentPresenceRegistry(ttl_seconds=60)

await sync_session_presence(registry, session_manager)
Expand Down
5 changes: 5 additions & 0 deletions autobot-backend/services/agent_terminal/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ class AgentTerminalSession:
agent_id: str
agent_role: AgentRole
conversation_id: str | None = None # Linked chat conversation
# #16975: the creating principal's org_id (JWT claim only, never a
# caller-supplied value), captured at creation -- it cannot be recovered
# afterwards. None when undetermined; presence then reports UNKNOWN_TENANT.
tenant_id: str | None = None
host: str = "main" # Target host (main, frontend, npu-worker, etc.)
state: AgentSessionState = AgentSessionState.AGENT_CONTROL
created_at: float = field(default_factory=time.time)
Expand Down Expand Up @@ -281,6 +285,7 @@ def to_persist_dict(self) -> Metadata:
"agent_id": self.agent_id,
"agent_role": self.agent_role.value,
"conversation_id": self.conversation_id,
"tenant_id": self.tenant_id, # #16975/#16978: lost on reload otherwise
"host": self.host,
"state": self.state.value,
"created_at": self.created_at,
Expand Down
9 changes: 3 additions & 6 deletions autobot-backend/services/agent_terminal/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,20 +95,17 @@ async def create_session(
host: str = "main",
metadata: Metadata | None = None,
owner: str | None = None,
tenant_id: str | None = None,
) -> AgentTerminalSession:
"""Create a new agent terminal session with PTY integration.

Issue #14989: `owner` is the authenticated creator's username, stamped
onto the shared terminal session_configs entry so the WebSocket
ownership gate in api.terminal can recognise them (#14960).
"""
"""Create a session; `owner` (#14989) stamps the WebSocket ownership gate, `tenant_id` (#16975) is JWT-only."""
return await self.session_manager.create_session(
agent_id=agent_id,
agent_role=agent_role,
conversation_id=conversation_id,
host=host,
metadata=metadata,
owner=owner,
tenant_id=tenant_id,
)

async def get_session(self, session_id: str) -> AgentTerminalSession | None:
Expand Down
5 changes: 5 additions & 0 deletions autobot-backend/services/agent_terminal/session_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,7 @@ async def create_session(
host: str = "main",
metadata: Metadata | None = None,
owner: str | None = None,
tenant_id: str | None = None,
) -> AgentTerminalSession:
"""
Create a new agent terminal session with PTY integration.
Expand All @@ -245,6 +246,8 @@ async def create_session(
host: Target host for command execution
metadata: Additional session metadata
owner: Authenticated creator's username (#14989/#14960)
tenant_id: The creator's org_id, from the JWT claim only (#16975).
None if it could not be determined -- never guessed here.

Returns:
Created session
Expand All @@ -257,6 +260,7 @@ async def create_session(
agent_id=agent_id,
agent_role=agent_role,
conversation_id=conversation_id,
tenant_id=tenant_id,
host=host,
metadata=metadata or {},
pty_session_id=pty_session_id,
Expand Down Expand Up @@ -314,6 +318,7 @@ async def get_session(self, session_id: str) -> AgentTerminalSession | None:
agent_id=session_data["agent_id"],
agent_role=AgentRole(session_data["agent_role"]),
conversation_id=session_data.get("conversation_id"),
tenant_id=session_data.get("tenant_id"),
host=session_data.get("host"),
metadata=session_data.get("metadata", {}),
pty_session_id=session_data.get("pty_session_id"),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# Copyright 2025-2026 mrveiss
# SPDX-License-Identifier: Apache-2.0
# AutoBot - AI-Powered Automation Platform
# Author: mrveiss
"""SessionManager.create_session threads tenant_id onto the session (#16975).

A terminal session had no tenant, and none was recoverable after the fact
(see the module docstring in protocols/agent_presence_feeds.py's
sync_session_presence). The fix is capturing it at creation, while the
authenticated principal is still in hand -- this drives create_session
itself, not a helper in isolation, so a future refactor that stops passing
tenant_id through would fail here.
"""

import fakeredis
import fakeredis.aioredis
import pytest

from services.agent_terminal.session_manager import SessionManager
from services.terminal_session_store import SessionConfigStore


@pytest.fixture(autouse=True)
def _fake_session_store(monkeypatch):
"""Same fixture as session_manager_owner_test.py -- see its docstring."""
from api.terminal import session_manager as terminal_session_manager

fake_client = fakeredis.FakeRedis(server=fakeredis.FakeServer())
monkeypatch.setattr(terminal_session_manager, "session_configs", SessionConfigStore(redis_client=fake_client))


class TestCreateSessionTenantId:
@pytest.mark.asyncio
async def test_a_known_tenant_is_stamped_onto_the_session(self):
from services.command_approval_manager import AgentRole

mgr = SessionManager()
session = await mgr.create_session(
agent_id="chat_agent_1",
agent_role=AgentRole.CHAT_AGENT,
tenant_id="tenant-x",
)

assert session.tenant_id == "tenant-x"

@pytest.mark.asyncio
async def test_no_tenant_argument_leaves_it_unset(self):
from services.command_approval_manager import AgentRole

mgr = SessionManager()
session = await mgr.create_session(agent_id="chat_agent_2", agent_role=AgentRole.CHAT_AGENT)

assert session.tenant_id is None


class TestTenantSurvivesRedisReload:
"""#16978 review: to_persist_dict() dropped tenant_id, so a session
reloaded from Redis (a restart, an eviction, another worker) reappeared
as UNKNOWN_TENANT even though it was stamped with a real tenant at
creation. Drives the real persist -> drop from memory -> get_session
round trip, not to_persist_dict()/get_session in isolation.
"""

@pytest.mark.asyncio
async def test_a_reloaded_session_keeps_its_tenant_and_is_discoverable_only_there(self):
from services.command_approval_manager import AgentRole

fake_redis = fakeredis.aioredis.FakeRedis(server=fakeredis.aioredis.FakeServer())
mgr = SessionManager(redis_client=fake_redis)
session = await mgr.create_session(
agent_id="chat_agent_3", agent_role=AgentRole.CHAT_AGENT, tenant_id="tenant-x"
)
await mgr._persist_session(session)

# Drop from the in-memory cache -- the only way get_session's Redis
# reload branch is reachable at all.
async with mgr._sessions_lock:
del mgr.sessions[session.session_id]

reloaded = await mgr.get_session(session.session_id)

assert reloaded is not None
assert reloaded.tenant_id == "tenant-x"

from protocols.agent_kind import AgentKind
from protocols.agent_presence import AgentPresenceRegistry
from protocols.agent_presence_feeds import sync_session_presence

registry = AgentPresenceRegistry(ttl_seconds=60)
await sync_session_presence(registry, mgr)

names_in_tenant_x = {e.name for e in registry.list_live("tenant-x") if e.kind == AgentKind.SESSION}
assert session.session_id in names_in_tenant_x
assert registry.list_live("tenant-y") == []
7 changes: 7 additions & 0 deletions changelog/unreleased/16975-session-tenant-capture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
type: fix
scope: backend
issue: 16975
pr: 0
---
`AgentTerminalSession` gains a `tenant_id` field, captured at creation from the creating principal's JWT `org_id` claim only -- never a caller-supplied value, and never recoverable afterwards, since nothing in `chat_history`'s session/conversation model or `conversation_id` itself carries tenancy. Presence (`#16947`) now reports a session under its real tenant, falling back to `UNKNOWN_TENANT` only when it genuinely can't be determined (a pre-#16975 session, or one created via a path with no authenticated context).
2 changes: 1 addition & 1 deletion repo_tests/python_file_size_ratchet_baseline.py
Original file line number Diff line number Diff line change
Expand Up @@ -328,7 +328,7 @@
"autobot-backend/security/threat_intelligence.py": 763,
"autobot-backend/security_layer.py": 767,
"autobot-backend/services/agent_analytics.py": 686,
"autobot-backend/services/agent_terminal/service.py": 959,
"autobot-backend/services/agent_terminal/service.py": 956,
"autobot-backend/services/ai_stack_client.py": 790,
"autobot-backend/services/analytics_service.py": 838,
"autobot-backend/services/audit_logger.py": 1022,
Expand Down
2 changes: 1 addition & 1 deletion scripts/python_file_size_known_large.py
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,7 @@
"autobot-backend/security/threat_intelligence.py": 763,
"autobot-backend/security_layer.py": 767,
"autobot-backend/services/agent_analytics.py": 686,
"autobot-backend/services/agent_terminal/service.py": 959,
"autobot-backend/services/agent_terminal/service.py": 956,
"autobot-backend/services/ai_stack_client.py": 790,
"autobot-backend/services/analytics_service.py": 838,
"autobot-backend/services/audit_logger.py": 1022,
Expand Down
Loading