Skip to content

feat(agents): capture a terminal session's tenant at creation (#16975) - #16978

Merged
mrveiss merged 6 commits into
mainfrom
issue-16975-session-tenant
Sep 19, 2026
Merged

mrveiss merged 6 commits into
mainfrom
issue-16975-session-tenant

Conversation

@mrveiss

@mrveiss mrveiss commented Sep 18, 2026

Copy link
Copy Markdown
Owner

Stacked on #16965 (#16947) -- targets that branch, not main, since this needs AgentPresenceRegistry/sync_session_presence to exist. Rebase onto main once #16965 merges.

Problem

A terminal session had no tenant, and none could be recovered after the fact -- presence (#16947) had to report every session as UNKNOWN_TENANT, so no session could be discovered or addressed within its own tenant.

The route that looked like it might work does not exist (investigated and reported back during #16947's review rather than picking something else):

  • conversation_id is a log-linkage id only, everywhere it's used.
  • chat_history's session/conversation stores (base.py, session.py, security.py) carry no user/tenant field anywhere.
  • The authenticated creator's owner (username) is threaded through SessionManager.create_session, but only for PTY registration -- never persisted on the session for a later lookup to recover.

Fix

Capture the tenant at creation, while the authenticated principal is still in hand:

  1. AgentTerminalSession gains an explicit tenant_id field.
  2. Threaded through SessionManager.create_session -> AgentTerminalService.create_session -> the one real call site with authenticated context: POST /api/agent-terminal/sessions, which already resolves owner from current_user.get("username") for the WebSocket ownership gate (fix(remote-control): authenticate the VNC and terminal websockets, deny unknown terminal sessions #14989/Terminal WebSocket accepts unauthenticated connections #14960). tenant_id is current_user.get("org_id") alongside it -- the JWT claim only. TerminalCreateSessionRequest has no org_id/tenant_id field, so a caller cannot supply one through this route's request body.
  3. sync_session_presence now reads session.tenant_id, falling back to UNKNOWN_TENANT only when it's unset.
  4. Sessions created via a path with no authenticated context (the chat-tool's internal session creation in tools/terminal_tool.py, which passes neither owner nor tenant_id) are untouched by this PR and correctly keep reporting UNKNOWN_TENANT -- not guessed.

Acceptance criteria (from #16975)

  • A newly created session carries the tenant of the principal who created it -- session_manager_tenant_16975_test.py
  • The tenant is resolved from the authenticated principal, never a caller-supplied value -- current_user.get("org_id") (JWT claim), and the request schema has no field a caller could use to override it
  • Presence reports a session under its real tenant, and a session whose tenant cannot be determined stays UNKNOWN_TENANT
  • A test showing a session is discoverable within its own tenant and invisible to another -- test_a_session_with_a_known_tenant_is_discoverable_within_it_only
  • Sessions that already exist are handled explicitly -- left UNKNOWN_TENANT, not guessed (the session.tenant_id or UNKNOWN_TENANT fallback)

Test plan

  • 6 new tests (2 in session_manager_tenant_16975_test.py, 4 in agent_presence_feeds_test.py) -- all passing
  • services/agent_terminal/ full suite -- 68/68 passing, no regressions
  • black, isort, flake8, bandit -- clean (pre-existing, untouched-by-this-diff E402s left alone, same convention as feat(agents): live presence — one registry of named agents with busy/idle state, across all three kinds #16947)
  • scripts/check_python_file_size.py --audit-ceilings -- clean; service.py's ratchet lowered 962 -> 959 after landing below ceiling
  • pipeline-scripts/detect-hardcoded-values.sh -- zero new findings
  • .secrets.baseline -- scoped scan confirms zero new findings; baseline untouched

Refs #16975

A terminal session had no tenant, and none could be recovered afterwards --
presence (#16947) had to report every session as UNKNOWN_TENANT. The route
that looked like it might work does not exist: conversation_id is a log-
linkage id only, chat_history's session/conversation stores carry no
user/tenant field, and the owner username threaded through
SessionManager.create_session was never persisted for a later lookup to
recover.

- AgentTerminalSession gains an explicit tenant_id field.
- Threaded through SessionManager.create_session ->
  AgentTerminalService.create_session -> the one real call site with
  authenticated context: POST /api/agent-terminal/sessions, which already
  resolves `owner` from current_user.get("username") for the WebSocket
  ownership gate (#14989/#14960) -- tenant_id is current_user.get("org_id")
  alongside it, the JWT claim only. TerminalCreateSessionRequest has no
  org_id/tenant_id field, so a caller cannot supply one through this route.
- sync_session_presence now reads session.tenant_id, falling back to
  UNKNOWN_TENANT only when it's unset -- a pre-#16975 session, or one
  created via a path with no authenticated context (e.g. the chat-tool
  internal session creation in tools/terminal_tool.py, which passes neither
  owner nor tenant_id today and is left alone: no code touches those paths
  in this PR, so they correctly keep reporting UNKNOWN_TENANT rather than
  a guess).
- 6 new tests: SessionManager.create_session threads tenant_id onto the
  session (and leaves it unset without one); presence reports a session
  under its real tenant and it is invisible to a different tenant's query
  (the AC's own discoverability negative control); a session with no
  tenant stays UNKNOWN_TENANT and is never listed.
- service.py's ratchet lowered 962 -> 959 (landed below ceiling after
  trimming its own docstring to make room for the new parameter).

Refs #16975
@coderabbitai

coderabbitai Bot commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

🗂️ Base branches to auto review (2)
  • main
  • release

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: 23c91402-a13f-499e-b19e-5c9241db807e

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown
Contributor

Notice: 29 open PRs — past the runaway threshold (25)

There is no PR queue limit, and this is not a request to defer this PR. Work proceeds one issue at a time without a cap on open PRs; review capacity is the constraint.

This notice only means the count is high enough to be worth a glance for a runaway — something opening PRs in a loop, or a merge pipeline that has stalled so nothing is draining.

Currently open:

If the queue is draining normally, ignore this. Otherwise:

  1. Check whether CI is dispatching at all — see the ci-dispatch-watchdog status on these PRs
  2. Merge the ones whose CI has finished and review has passed: gh pr merge <number> --squash --delete-branch
  3. Look for a loop opening near-identical PRs

Warn-only runaway detector — .github/workflows/pr-queue-gate.yml. It never blocks a merge.

@mrveiss mrveiss added this to the v0.9.0 milestone Sep 18, 2026
Review found to_persist_dict() left tenant_id out, and get_session()'s
Redis-reload branch rebuilt AgentTerminalSession without it -- a session
stamped with its tenant at creation lost the stamp on any reload (a
restart, an eviction, another worker) and reappeared as UNKNOWN_TENANT.
Fails closed, so nothing leaked, but it broke AC1 across a reload.

- to_persist_dict() now includes tenant_id.
- get_session()'s Redis-reload branch restores it via
  session_data.get("tenant_id").
- The pending-approval rebuild path is left as UNKNOWN_TENANT deliberately
  -- it predates this PR and never guessing is correct there.
- New test drives the real persist -> drop from memory -> get_session
  round trip (not the two methods in isolation), then confirms the
  reloaded session is discoverable through the real presence registry in
  its own tenant only.

Refs #16975, #16978 review
…esult

Merging issue-16947-agent-presence into this branch landed
services/agent_terminal/service.py at 956 lines, under both branches' own
959 ceiling -- the ratchet only turns down, so lowering it here rather
than leaving the merge with unclaimed slack.

Refs #16947, #16975
mrveiss added a commit that referenced this pull request Sep 18, 2026
…17053)

session_manager.py reached 596 lines, and #16978 (in the agent-coordination vehicle) adds 5 to it, which would have crossed the 600-line limit on rebase. The file-then-grant owner lookup moves to services/agent_terminal/conversation_owner.py unchanged; SessionManager._conversation_owner delegates to it.
@mrveiss mrveiss added backend enhancement New feature or request labels Sep 19, 2026
Base automatically changed from issue-16947-agent-presence to main September 19, 2026 22:10
@mrveiss
mrveiss merged commit af744a8 into main Sep 19, 2026
6 of 18 checks passed
@mrveiss
mrveiss deleted the issue-16975-session-tenant branch September 19, 2026 22:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backend enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant