Files
2026-07-13 13:12:00 +08:00

3002 lines
108 KiB
Python

"""Integration tests for session permission endpoints.
Exercises the full middleware -> route -> store pipeline for the
permission management endpoints on ``/v1/sessions/{id}/permissions``
and the access-control enforcement on session CRUD endpoints when
a :class:`PermissionStore` is active.
Uses a custom ``auth_app`` / ``auth_client`` fixture pair that wires
a :class:`SqlAlchemyPermissionStore` into the FastAPI app so the
``UnifiedAuthProvider`` and permission checks are active. Requests
include ``X-Forwarded-Email`` headers to impersonate different users.
"""
from __future__ import annotations
import asyncio
import json
from collections.abc import AsyncIterator
from pathlib import Path
from typing import Any
import httpx
import pytest
import pytest_asyncio
from fastapi import FastAPI
from omnigent.host.frames import HostHelloFrame
from omnigent.runtime import session_stream
from omnigent.runtime.agent_cache import AgentCache
from omnigent.server import presence
from omnigent.server.app import create_app
from omnigent.server.auth import LEVEL_EDIT, LEVEL_MANAGE, LEVEL_OWNER, LEVEL_READ
from omnigent.stores.agent_store.sqlalchemy_store import SqlAlchemyAgentStore
from omnigent.stores.artifact_store.local import LocalArtifactStore
from omnigent.stores.comment_store.sqlalchemy_store import SqlAlchemyCommentStore
from omnigent.stores.conversation_store.sqlalchemy_store import (
SqlAlchemyConversationStore,
)
from omnigent.stores.file_store.sqlalchemy_store import SqlAlchemyFileStore
from omnigent.stores.host_store import HostStore
from omnigent.stores.permission_store.sqlalchemy_store import (
SqlAlchemyPermissionStore,
)
from tests.server.conftest import ControllableMockClient
from tests.server.helpers import (
build_agent_bundle,
create_test_agent,
start_session_stream_collector,
)
pytestmark = pytest.mark.asyncio
# ── Fixtures ─────────────────────────────────────────────────
@pytest.fixture()
def auth_app(
runtime_init: None,
db_uri: str,
tmp_path: Path,
) -> FastAPI:
"""App fixture with permission store enabled.
Mirrors the shared ``app`` fixture from ``conftest.py`` but adds
a :class:`SqlAlchemyPermissionStore` so
:class:`UnifiedAuthProvider` and permission checks are active on
all session routes.
:param runtime_init: Fixture that initializes the runtime with a mock LLM.
:param db_uri: Test database URI.
:param tmp_path: Pytest temporary directory fixture.
"""
from omnigent.server.auth import UnifiedAuthProvider
artifact_store = LocalArtifactStore(str(tmp_path / "artifacts"))
return create_app(
agent_store=SqlAlchemyAgentStore(db_uri),
file_store=SqlAlchemyFileStore(db_uri),
conversation_store=SqlAlchemyConversationStore(db_uri),
artifact_store=artifact_store,
agent_cache=AgentCache(
artifact_store=artifact_store,
cache_dir=tmp_path / "cache",
),
comment_store=SqlAlchemyCommentStore(db_uri),
permission_store=SqlAlchemyPermissionStore(db_uri),
# Explicit strict header mode (the deployed multi-user
# posture): requests without X-Forwarded-Email are rejected
# with 401. Constructed directly rather than via
# create_auth_provider() so ambient OMNIGENT_* env vars in
# the test runner can't flip the mode.
auth_provider=UnifiedAuthProvider(source="header", local_single_user=False),
)
@pytest_asyncio.fixture()
async def auth_client(
auth_app: FastAPI,
mock_llm: ControllableMockClient,
tmp_path: Path,
) -> AsyncIterator[httpx.AsyncClient]:
"""HTTP client wired to the auth-enabled FastAPI app.
Same lifecycle pattern as the shared ``client`` fixture from
``conftest.py``: starts the harness process manager, yields the
client, then tears down DBOS on exit.
"""
from omnigent.runtime import set_harness_process_manager
from omnigent.runtime.harnesses.process_manager import HarnessProcessManager
pm = HarnessProcessManager(tmp_parent=tmp_path / "harness_pm")
await pm.start()
set_harness_process_manager(pm)
transport = httpx.ASGITransport(app=auth_app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
yield c
mock_llm.release_all()
set_harness_process_manager(None)
await pm.shutdown()
@pytest.fixture()
def local_auth_app(
runtime_init: None,
db_uri: str,
tmp_path: Path,
) -> FastAPI:
"""App fixture modeling the explicit single-user local runtime.
Same wiring as :func:`auth_app` but with
``local_single_user=True`` (the posture of a server spawned with
``OMNIGENT_LOCAL_SINGLE_USER=1``): requests without
``X-Forwarded-Email`` resolve to the reserved ``"local"``
identity instead of being rejected.
:param runtime_init: Fixture that initializes the runtime with a mock LLM.
:param db_uri: Test database URI.
:param tmp_path: Pytest temporary directory fixture.
"""
from omnigent.server.auth import UnifiedAuthProvider
artifact_store = LocalArtifactStore(str(tmp_path / "artifacts"))
return create_app(
agent_store=SqlAlchemyAgentStore(db_uri),
file_store=SqlAlchemyFileStore(db_uri),
conversation_store=SqlAlchemyConversationStore(db_uri),
artifact_store=artifact_store,
agent_cache=AgentCache(
artifact_store=artifact_store,
cache_dir=tmp_path / "cache",
),
comment_store=SqlAlchemyCommentStore(db_uri),
permission_store=SqlAlchemyPermissionStore(db_uri),
auth_provider=UnifiedAuthProvider(source="header", local_single_user=True),
)
@pytest_asyncio.fixture()
async def local_auth_client(
local_auth_app: FastAPI,
mock_llm: ControllableMockClient,
tmp_path: Path,
) -> AsyncIterator[httpx.AsyncClient]:
"""HTTP client wired to the single-user-mode FastAPI app.
Same lifecycle pattern as :func:`auth_client`.
"""
from omnigent.runtime import set_harness_process_manager
from omnigent.runtime.harnesses.process_manager import HarnessProcessManager
pm = HarnessProcessManager(tmp_parent=tmp_path / "harness_pm")
await pm.start()
set_harness_process_manager(pm)
transport = httpx.ASGITransport(app=local_auth_app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
yield c
mock_llm.release_all()
set_harness_process_manager(None)
await pm.shutdown()
@pytest.fixture()
def host_perm_app(
db_uri: str,
tmp_path: Path,
) -> FastAPI:
"""Auth-enabled app that also wires a host store.
Same shape as :func:`auth_app` but passes ``host_store`` so the
host-launch authorization path in ``POST /v1/sessions`` is live.
"""
from omnigent.server.auth import create_auth_provider
artifact_store = LocalArtifactStore(str(tmp_path / "artifacts"))
return create_app(
agent_store=SqlAlchemyAgentStore(db_uri),
file_store=SqlAlchemyFileStore(db_uri),
conversation_store=SqlAlchemyConversationStore(db_uri),
artifact_store=artifact_store,
agent_cache=AgentCache(
artifact_store=artifact_store,
cache_dir=tmp_path / "cache",
),
comment_store=SqlAlchemyCommentStore(db_uri),
permission_store=SqlAlchemyPermissionStore(db_uri),
auth_provider=create_auth_provider(),
host_store=HostStore(db_uri),
)
@pytest_asyncio.fixture()
async def host_perm_client(
host_perm_app: FastAPI,
mock_llm: ControllableMockClient,
tmp_path: Path,
) -> AsyncIterator[httpx.AsyncClient]:
"""HTTP client for the host-enabled auth app (mirrors ``auth_client``)."""
from omnigent.runtime import set_harness_process_manager
from omnigent.runtime.harnesses.process_manager import HarnessProcessManager
pm = HarnessProcessManager(tmp_parent=tmp_path / "harness_pm")
await pm.start()
set_harness_process_manager(pm)
transport = httpx.ASGITransport(app=host_perm_app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
yield c
mock_llm.release_all()
set_harness_process_manager(None)
await pm.shutdown()
# ── Helpers ──────────────────────────────────────────────────
def _register_online_host(app: FastAPI, host_id: str, owner: str) -> None:
"""Register an online host owned by ``owner`` on the app.
Persists the host (so the owner check can resolve it) and registers
a no-op WebSocket in the app's live registry (so the session-create
flow sees the host as online and would send it a ``host.stat`` if
the ownership check were missing). The registry only needs an object
exposing ``send_text``/``receive_text``.
:param app: The app whose ``host_store``/``host_registry`` to use.
:param host_id: Host id to register, e.g. ``"host_alice"``.
:param owner: Owning user, e.g. ``"alice@example.com"``.
"""
app.state.host_store.upsert_on_connect(host_id, f"{owner}-laptop", owner)
app.state.host_registry.register(
host_id,
type(
"FakeWS",
(),
{"send_text": lambda self, d: None, "receive_text": lambda self: ""},
)(),
HostHelloFrame(version="0.1.0", frame_protocol_version=1, name=host_id),
owner=owner,
)
async def _create_session_as(
client: httpx.AsyncClient,
agent_id: str,
user: str | None,
*,
title: str | None = None,
) -> dict[str, Any]:
"""Create a session as a specific user.
Uses multipart bundled create so each session gets its own
session-scoped agent. The ``agent_id`` parameter is accepted
for backward compatibility but ignored — sessions always
create a fresh agent from the test bundle.
:param client: The test HTTP client.
:param agent_id: Ignored — kept for call-site compatibility.
:param user: User identity for ``X-Forwarded-Email``, or
``None`` to omit the header (falls back to ``"local"``
in header mode).
:param title: Optional session title.
:returns: A dict with ``id`` (session_id), ``agent_id``, and
other session fields.
"""
import json as _json
bundle = build_agent_bundle(name="test-agent")
metadata: dict[str, Any] = {}
if title is not None:
metadata["title"] = title
headers = {"X-Forwarded-Email": user} if user is not None else {}
resp = await client.post(
"/v1/sessions",
data={"metadata": _json.dumps(metadata)},
files={"bundle": ("agent.tar.gz", bundle, "application/gzip")},
headers=headers,
)
assert resp.status_code == 201, f"session create failed: {resp.status_code} {resp.text}"
# Bundled create returns {session_id: "..."} — fetch the full
# snapshot so callers have the same shape as before.
session_id = resp.json()["session_id"]
snap = await client.get(
f"/v1/sessions/{session_id}",
headers=headers,
)
assert snap.status_code == 200, f"session snapshot failed: {snap.text}"
return snap.json()
async def _grant_permission(
client: httpx.AsyncClient,
session_id: str,
*,
granter: str,
target_user: str,
level: int,
) -> httpx.Response:
"""Grant a permission on a session.
:param client: The test HTTP client.
:param session_id: Session to grant access to.
:param granter: User identity of the granter.
:param target_user: User to receive the grant.
:param level: Numeric permission level (1/2/3).
:returns: The raw httpx response.
"""
return await client.put(
f"/v1/sessions/{session_id}/permissions",
json={"user_id": target_user, "level": level},
headers={"X-Forwarded-Email": granter},
)
async def _revoke_permission(
client: httpx.AsyncClient,
session_id: str,
*,
revoker: str,
target_user: str,
) -> httpx.Response:
"""Revoke a permission on a session.
:param client: The test HTTP client.
:param session_id: Session to revoke access from.
:param revoker: User identity of the revoker.
:param target_user: User whose grant to revoke.
:returns: The raw httpx response.
"""
return await client.delete(
f"/v1/sessions/{session_id}/permissions/{target_user}",
headers={"X-Forwarded-Email": revoker},
)
async def _list_sessions_as(
client: httpx.AsyncClient,
user: str,
) -> list[dict[str, Any]]:
"""List sessions visible to a specific user.
:param client: The test HTTP client.
:param user: User identity for ``X-Forwarded-Email``.
:returns: List of session dicts from the ``data`` field.
"""
resp = await client.get(
"/v1/sessions",
headers={"X-Forwarded-Email": user},
)
assert resp.status_code == 200, f"list sessions failed: {resp.status_code} {resp.text}"
return resp.json()["data"]
async def _list_permissions(
client: httpx.AsyncClient,
session_id: str,
*,
user: str | None,
) -> httpx.Response:
"""List permissions on a session.
:param client: The test HTTP client.
:param session_id: Session to list grants for.
:param user: User identity for ``X-Forwarded-Email``, or
``None`` to omit the header (falls back to ``"local"``).
:returns: The raw httpx response.
"""
headers = {"X-Forwarded-Email": user} if user is not None else {}
return await client.get(
f"/v1/sessions/{session_id}/permissions",
headers=headers,
)
# ── Critical CUJ: full grant/revoke/list lifecycle ──────────
async def test_full_permission_lifecycle(
auth_client: httpx.AsyncClient,
) -> None:
"""Full permission lifecycle: grant, downgrade, revoke, self-revoke
block, and visibility in session list.
Steps:
1. bryan creates session S1 -> gets manage grant automatically
2. bryan grants corey edit (level 2) on S1
3. bryan reassigns corey to read (level 1) via upsert
4. bryan grants rice edit (level 2) on S1
5. bryan revokes corey entirely
6. bryan tries to revoke own manage -> 403 (self-revoke blocked)
7. Verify DB state: only bryan (manage) and rice (edit)
8. rice lists sessions -> sees S1
9. corey lists sessions -> sees nothing
10. bryan lists sessions -> sees S1
"""
agent = await create_test_agent(auth_client, user="bryan")
# Step 1: bryan creates session -> auto-gets manage
s1 = await _create_session_as(
auth_client,
agent["id"],
"bryan",
title="lifecycle-test",
)
session_id = s1["id"]
# Step 2: bryan grants corey edit (level 2)
resp = await _grant_permission(
auth_client,
session_id,
granter="bryan",
target_user="corey",
level=LEVEL_EDIT,
)
# Grant endpoint returns 200 with the permission object.
assert resp.status_code == 200, f"grant failed: {resp.status_code} {resp.text}"
grant_body = resp.json()
assert grant_body["user_id"] == "corey", "Grant response should echo the target user_id."
assert grant_body["level"] == LEVEL_EDIT, "Grant response should echo the requested level."
# Step 3: bryan downgrades corey to read (level 1) via upsert
resp = await _grant_permission(
auth_client,
session_id,
granter="bryan",
target_user="corey",
level=LEVEL_READ,
)
assert resp.status_code == 200
# Upsert should return the new level, not the old one.
assert resp.json()["level"] == LEVEL_READ, (
"Upsert downgrade should reflect the new (lower) level. "
"If still 2, the upsert did not overwrite."
)
# Step 4: bryan grants rice edit (level 2)
resp = await _grant_permission(
auth_client,
session_id,
granter="bryan",
target_user="rice",
level=LEVEL_EDIT,
)
assert resp.status_code == 200
assert resp.json()["user_id"] == "rice"
assert resp.json()["level"] == LEVEL_EDIT
# Step 5: bryan revokes corey entirely
resp = await _revoke_permission(
auth_client,
session_id,
revoker="bryan",
target_user="corey",
)
# Revoke returns 204 No Content.
assert resp.status_code == 204, f"revoke failed: {resp.status_code} {resp.text}"
# Step 6: bryan tries to revoke own manage -> 403
resp = await _revoke_permission(
auth_client,
session_id,
revoker="bryan",
target_user="bryan",
)
# Self-revoke of a manage grant is blocked to prevent orphaned
# sessions. The route returns 403 (FORBIDDEN).
assert resp.status_code == 403, (
f"Expected 403 for self-revoke of manage grant, "
f"got {resp.status_code}. If 204, the self-revoke guard "
f"is broken and the session could be orphaned."
)
# Step 7: verify DB state via list_permissions
resp = await _list_permissions(
auth_client,
session_id,
user="bryan",
)
assert resp.status_code == 200
grants = resp.json()
grant_map = {g["user_id"]: g["level"] for g in grants}
# Only bryan (owner) and rice (edit) should remain.
# corey was revoked in step 5.
assert grant_map == {"bryan": LEVEL_OWNER, "rice": LEVEL_EDIT}, (
f"Expected exactly bryan=owner(4) and rice=edit(2), "
f"got {grant_map}. If corey is present, the revoke in "
f"step 5 failed silently."
)
# Step 8: rice lists sessions -> sees S1
rice_sessions = await _list_sessions_as(auth_client, "rice")
rice_ids = {s["id"] for s in rice_sessions}
assert session_id in rice_ids, (
"rice has an edit grant and should see S1 in the session list. "
"If absent, the list_conversations accessible_by filter is "
"not including rice's grant."
)
# Step 9: corey lists sessions -> sees nothing
corey_sessions = await _list_sessions_as(auth_client, "corey")
corey_ids = {s["id"] for s in corey_sessions}
assert session_id not in corey_ids, (
"corey's grant was revoked and should NOT see S1. If present, "
"the revoke did not propagate to the list filter."
)
# Step 10: bryan lists sessions -> sees S1
bryan_sessions = await _list_sessions_as(auth_client, "bryan")
bryan_ids = {s["id"] for s in bryan_sessions}
assert session_id in bryan_ids, (
"bryan has a manage grant and should see S1 in the session list."
)
# ── Visibility: no grants -> no sessions ─────────────────────
async def test_user_without_grants_sees_no_sessions(
auth_client: httpx.AsyncClient,
) -> None:
"""A user with no grants sees an empty session list."""
agent = await create_test_agent(auth_client, user="bryan")
await _create_session_as(auth_client, agent["id"], "user-a", title="private")
# user-b has never been granted anything.
sessions = await _list_sessions_as(auth_client, "user-b")
assert sessions == [], (
"user-b has no grants and should see an empty session list. "
"If non-empty, the accessible_by filter is not enforced or "
"sessions without grants are visible by default."
)
# ── Grant read: can GET but not POST events ──────────────────
async def test_read_grant_allows_get_but_blocks_post_events(
auth_client: httpx.AsyncClient,
) -> None:
"""A user with read-only access can GET a session but cannot POST events."""
agent = await create_test_agent(auth_client, user="bryan")
s1 = await _create_session_as(auth_client, agent["id"], "user-a")
session_id = s1["id"]
# user-a grants user-b read access.
resp = await _grant_permission(
auth_client,
session_id,
granter="user-a",
target_user="user-b",
level=LEVEL_READ,
)
assert resp.status_code == 200
# user-b can GET the session snapshot.
resp = await auth_client.get(
f"/v1/sessions/{session_id}",
headers={"X-Forwarded-Email": "user-b"},
)
assert resp.status_code == 200, (
f"user-b with read grant should be able to GET the session, got {resp.status_code}."
)
# Verify the response contains the session id to confirm the
# full pipeline returned real data (not just a status code).
assert resp.json()["id"] == session_id
# user-b cannot POST events (requires edit level).
resp = await auth_client.post(
f"/v1/sessions/{session_id}/events",
json={
"type": "message",
"data": {
"role": "user",
"content": [{"type": "input_text", "text": "blocked"}],
},
},
headers={"X-Forwarded-Email": "user-b"},
)
assert resp.status_code == 403, (
f"user-b with read-only grant should be blocked from POST events "
f"(requires edit). Got {resp.status_code}. If 202, the "
f"permission check on POST /events is missing or not enforcing "
f"LEVEL_EDIT."
)
async def test_edit_grant_blocked_from_stop_session_requires_owner(
auth_client: httpx.AsyncClient,
) -> None:
"""An editor can post ordinary events but cannot stop the session.
``stop_session`` terminates the whole running session for every
participant — a lifecycle action on par with delete — so the
route requires owner level on top of the LEVEL_EDIT gate that
covers ordinary events. This pins that a shared collaborator with
edit access (who CAN post messages / interrupt) is still blocked
from killing the owner's session.
"""
agent = await create_test_agent(auth_client, user="bryan")
s1 = await _create_session_as(auth_client, agent["id"], "user-a")
session_id = s1["id"]
# user-a grants user-b edit access.
resp = await _grant_permission(
auth_client,
session_id,
granter="user-a",
target_user="user-b",
level=LEVEL_EDIT,
)
assert resp.status_code == 200
# Sanity: the edit grant DOES let user-b post an ordinary event
# (interrupt requires only LEVEL_EDIT). If this 403s, the setup is
# wrong and the stop_session 403 below wouldn't prove the extra
# owner gate — it'd just be the edit gate failing.
resp = await auth_client.post(
f"/v1/sessions/{session_id}/events",
json={"type": "interrupt", "data": {}},
headers={"X-Forwarded-Email": "user-b"},
)
assert resp.status_code == 202, (
f"user-b with edit grant should be able to post interrupt "
f"(LEVEL_EDIT), got {resp.status_code}: {resp.text}"
)
# The actual claim: user-b is blocked from stop_session (owner-only).
resp = await auth_client.post(
f"/v1/sessions/{session_id}/events",
json={"type": "stop_session", "data": {}},
headers={"X-Forwarded-Email": "user-b"},
)
assert resp.status_code == 403, (
f"user-b with edit grant should be blocked from stop_session "
f"(requires owner). Got {resp.status_code}. If 202, the owner "
f"gate on stop_session is missing — an editor could kill the "
f"owner's session."
)
# The owner (user-a) is NOT blocked by the gate. No runner is bound
# in this app, so the forward is a best-effort no-op and the route
# returns 202 — what matters is that it's not a 403.
resp = await auth_client.post(
f"/v1/sessions/{session_id}/events",
json={"type": "stop_session", "data": {}},
headers={"X-Forwarded-Email": "user-a"},
)
assert resp.status_code == 202, (
f"The session owner should pass the stop_session owner gate "
f"and get 202, got {resp.status_code}: {resp.text}"
)
# ── Grant edit: can POST events but not manage permissions ───
async def test_edit_grant_allows_post_but_blocks_permission_management(
auth_client: httpx.AsyncClient,
) -> None:
"""A user with edit access can POST events but cannot manage permissions."""
agent = await create_test_agent(auth_client, user="bryan")
s1 = await _create_session_as(auth_client, agent["id"], "user-a")
session_id = s1["id"]
# user-a grants user-b edit access.
resp = await _grant_permission(
auth_client,
session_id,
granter="user-a",
target_user="user-b",
level=LEVEL_EDIT,
)
assert resp.status_code == 200
# user-b can PATCH the session title (requires edit).
resp = await auth_client.patch(
f"/v1/sessions/{session_id}",
json={"title": "updated by user-b"},
headers={"X-Forwarded-Email": "user-b"},
)
assert resp.status_code == 200, (
"user-b with edit grant should be able to PATCH the session title, "
f"got {resp.status_code}."
)
assert resp.json()["title"] == "updated by user-b", (
"PATCH should reflect the new title in the response."
)
# user-b cannot grant permissions (requires manage).
resp = await _grant_permission(
auth_client,
session_id,
granter="user-b",
target_user="user-c",
level=LEVEL_READ,
)
assert resp.status_code == 403, (
f"user-b with edit grant should be blocked from granting "
f"permissions (requires manage). Got {resp.status_code}. If 200, "
f"the LEVEL_MANAGE check on PUT /permissions is broken."
)
# user-b cannot list permissions (requires manage).
resp = await _list_permissions(
auth_client,
session_id,
user="user-b",
)
assert resp.status_code == 403, (
f"user-b with edit grant should be blocked from listing "
f"permissions. Got {resp.status_code}."
)
async def test_archive_requires_owner_access(
auth_client: httpx.AsyncClient,
) -> None:
"""Archiving a session is gated owner-only: a read-only viewer and
an editor are both blocked; only the owner succeeds.
Archiving stops the session (an owner-gated lifecycle action), so a
shared editor must not be able to kill the owner's running agent by
archiving — and a viewer must not be able to hide a shared session
from everyone else. Both non-owner arms must 403; the owner arm
confirms the gate isn't accidentally raised above owner.
"""
agent = await create_test_agent(auth_client, user="bryan")
s1 = await _create_session_as(auth_client, agent["id"], "user-a")
session_id = s1["id"]
# user-a grants user-b READ only — read-only cannot archive.
resp = await _grant_permission(
auth_client,
session_id,
granter="user-a",
target_user="user-b",
level=LEVEL_READ,
)
assert resp.status_code == 200
resp = await auth_client.patch(
f"/v1/sessions/{session_id}",
json={"archived": True},
headers={"X-Forwarded-Email": "user-b"},
)
assert resp.status_code == 403, (
f"read-only user-b must not archive a shared session. Got {resp.status_code}."
)
# Upgrade user-b to EDIT — still blocked, archiving is owner-only.
resp = await _grant_permission(
auth_client,
session_id,
granter="user-a",
target_user="user-b",
level=LEVEL_EDIT,
)
assert resp.status_code == 200
resp = await auth_client.patch(
f"/v1/sessions/{session_id}",
json={"archived": True},
headers={"X-Forwarded-Email": "user-b"},
)
assert resp.status_code == 403, (
f"editor user-b must not archive a shared session (archive is "
f"owner-only). Got {resp.status_code}. If 200, the gate is "
f"wrongly left at edit and an editor can stop the owner's runner."
)
# The session is still not archived — the denied PATCHes had no effect.
snap = await auth_client.get(
f"/v1/sessions/{session_id}",
headers={"X-Forwarded-Email": "user-a"},
)
assert snap.json()["archived"] is False, "denied archive must not mutate the session"
# The owner (user-a) can archive.
resp = await auth_client.patch(
f"/v1/sessions/{session_id}",
json={"archived": True},
headers={"X-Forwarded-Email": "user-a"},
)
assert resp.status_code == 200, (
f"the owner should be able to archive; got {resp.status_code}. "
f"If 403, the gate is wrongly raised above owner."
)
assert resp.json()["archived"] is True
async def test_cost_control_override_patch_requires_edit_access(
auth_client: httpx.AsyncClient,
) -> None:
"""The cost-control switch rides the PATCH route's edit gate.
A read-only collaborator must not be able to flip another user's
session out of (or into) cost-optimized mode — that changes how
the owner's turns execute and what they cost. Edit access (the
same level as title / model_override updates) suffices.
"""
agent = await create_test_agent(auth_client, user="bryan")
s1 = await _create_session_as(auth_client, agent["id"], "user-a")
session_id = s1["id"]
# user-a grants user-b READ only — blocked from the PATCH.
resp = await _grant_permission(
auth_client,
session_id,
granter="user-a",
target_user="user-b",
level=LEVEL_READ,
)
assert resp.status_code == 200
resp = await auth_client.patch(
f"/v1/sessions/{session_id}",
json={"cost_control_mode_override": "off"},
headers={"X-Forwarded-Email": "user-b"},
)
assert resp.status_code == 403, (
f"read-only user-b must not set cost_control_mode_override on a "
f"shared session. Got {resp.status_code}. If 200, the PATCH "
f"edit gate is not covering the new field."
)
# The denied PATCH had no effect — the owner still sees unset.
snap = await auth_client.get(
f"/v1/sessions/{session_id}",
headers={"X-Forwarded-Email": "user-a"},
)
assert snap.json()["cost_control_mode_override"] is None, (
"denied PATCH must not mutate the session row"
)
# Upgrade user-b to EDIT — the switch is an edit-level field
# (same gate as title / model_override), so the PATCH now lands.
resp = await _grant_permission(
auth_client,
session_id,
granter="user-a",
target_user="user-b",
level=LEVEL_EDIT,
)
assert resp.status_code == 200
resp = await auth_client.patch(
f"/v1/sessions/{session_id}",
json={"cost_control_mode_override": "off"},
headers={"X-Forwarded-Email": "user-b"},
)
assert resp.status_code == 200, (
f"editor user-b should be able to set the switch; got "
f"{resp.status_code}. If 403, the gate is wrongly raised "
f"above edit."
)
assert resp.json()["cost_control_mode_override"] == "off"
# ── Public access via __public__ sentinel ────────────────────
async def test_public_grant_hides_from_list_but_allows_direct_access(
auth_client: httpx.AsyncClient,
) -> None:
"""A __public__ read grant does NOT list the session, but direct GET works."""
agent = await create_test_agent(auth_client, user="bryan")
s1 = await _create_session_as(
auth_client,
agent["id"],
"user-a",
title="public-session",
)
session_id = s1["id"]
# user-a grants __public__ read.
resp = await _grant_permission(
auth_client,
session_id,
granter="user-a",
target_user="__public__",
level=LEVEL_READ,
)
assert resp.status_code == 200
# user-b (no direct grant) should NOT see the session in the list —
# public-only sessions are excluded from the sidebar.
sessions = await _list_sessions_as(auth_client, "user-b")
session_ids = {s["id"] for s in sessions}
assert session_id not in session_ids, (
"A __public__-only session should not appear in another user's "
"session list. Public sessions are accessible by direct URL only."
)
# user-b CAN still GET the session directly.
resp = await auth_client.get(
f"/v1/sessions/{session_id}",
headers={"X-Forwarded-Email": "user-b"},
)
assert resp.status_code == 200, (
f"user-b should be able to GET a session with __public__ read "
f"grant, got {resp.status_code}."
)
assert resp.json()["id"] == session_id
async def test_get_snapshot_reports_resolved_permission_level(
auth_client: httpx.AsyncClient,
) -> None:
"""The GET-snapshot ``permission_level`` reflects the resolved level
for the caller — owner, direct grant, and public fallback.
Exercises the ``require_access_and_level`` → ``_get_session_snapshot``
level threading end-to-end through the route (the other snapshot
permission tests only assert access, never the displayed level):
- owner sees ``LEVEL_OWNER`` (own grant from session creation),
- a direct READ grantee sees ``LEVEL_READ``,
- a user with no own grant accessing via ``__public__`` sees the
public grant level (``resolved_level`` falls back to it).
"""
agent = await create_test_agent(auth_client, user="bryan")
s1 = await _create_session_as(auth_client, agent["id"], "user-a")
session_id = s1["id"]
# Owner: own grant from creation → LEVEL_OWNER.
resp = await auth_client.get(
f"/v1/sessions/{session_id}",
headers={"X-Forwarded-Email": "user-a"},
)
assert resp.status_code == 200, f"owner GET failed: {resp.status_code}."
assert resp.json()["permission_level"] == LEVEL_OWNER, (
f"owner snapshot should report LEVEL_OWNER, got {resp.json()['permission_level']}."
)
# Direct READ grantee: own grant → LEVEL_READ.
resp = await _grant_permission(
auth_client,
session_id,
granter="user-a",
target_user="user-b",
level=LEVEL_READ,
)
assert resp.status_code == 200
resp = await auth_client.get(
f"/v1/sessions/{session_id}",
headers={"X-Forwarded-Email": "user-b"},
)
assert resp.status_code == 200, f"grantee GET failed: {resp.status_code}."
assert resp.json()["permission_level"] == LEVEL_READ, (
f"READ-grantee snapshot should report LEVEL_READ, got {resp.json()['permission_level']}."
)
# Public fallback: user-c has no own grant; access + level come from
# the __public__ READ grant (resolved_level falls back to it).
resp = await _grant_permission(
auth_client,
session_id,
granter="user-a",
target_user="__public__",
level=LEVEL_READ,
)
assert resp.status_code == 200
resp = await auth_client.get(
f"/v1/sessions/{session_id}",
headers={"X-Forwarded-Email": "user-c"},
)
assert resp.status_code == 200, f"public-access GET failed: {resp.status_code}."
assert resp.json()["permission_level"] == LEVEL_READ, (
"public-only caller's snapshot should fall back to the public READ "
f"grant level, got {resp.json()['permission_level']}."
)
# ── List grants (GET /permissions) ───────────────────────────
async def test_list_permissions_shows_all_grants(
auth_client: httpx.AsyncClient,
) -> None:
"""GET /sessions/{id}/permissions returns all grants for the session."""
agent = await create_test_agent(auth_client, user="bryan")
s1 = await _create_session_as(auth_client, agent["id"], "user-a")
session_id = s1["id"]
# Grant two additional users.
await _grant_permission(
auth_client,
session_id,
granter="user-a",
target_user="user-b",
level=LEVEL_READ,
)
await _grant_permission(
auth_client,
session_id,
granter="user-a",
target_user="user-c",
level=LEVEL_EDIT,
)
resp = await _list_permissions(
auth_client,
session_id,
user="user-a",
)
assert resp.status_code == 200
grants = resp.json()
grant_map = {g["user_id"]: g["level"] for g in grants}
# user-a auto-got owner on creation; user-b and user-c were
# granted explicitly above.
assert grant_map == {
"user-a": LEVEL_OWNER,
"user-b": LEVEL_READ,
"user-c": LEVEL_EDIT,
}, (
f"Expected 3 grants (user-a=owner, user-b=read, user-c=edit), "
f"got {grant_map}. If user-a is missing, the auto-grant on "
f"session creation is broken."
)
# Every grant should reference this session.
for g in grants:
assert g["conversation_id"] == session_id, (
"Each grant's conversation_id must match the session."
)
# ── Single-user local runtime ─────────────────────────────────
async def test_single_user_local_can_access_own_session(
local_auth_client: httpx.AsyncClient,
) -> None:
"""On a single-user local runtime, headerless requests work as 'local'.
The single-user app (``local_single_user=True``) resolves a
missing ``X-Forwarded-Email`` to the reserved ``"local"``
identity, which gets an owner auto-grant on sessions it creates
— the bare `omnigent run` / local web UI flow.
"""
agent = await create_test_agent(local_auth_client, user="bryan")
# Omit X-Forwarded-Email to trigger the "local" fallback —
# explicitly sending "local" as a header is rejected (reserved).
s_local = await _create_session_as(
local_auth_client,
agent["id"],
None,
title="local-session",
)
resp = await local_auth_client.get(
f"/v1/sessions/{s_local['id']}",
# No X-Forwarded-Email header -> UnifiedAuthProvider returns "local"
)
assert resp.status_code == 200, (
f"local user should be able to GET its own session, got {resp.status_code}."
)
assert resp.json()["id"] == s_local["id"]
# ── Admin bypass ─────────────────────────────────────────────
async def test_admin_user_bypasses_permission_checks(
auth_client: httpx.AsyncClient,
db_uri: str,
) -> None:
"""An admin user can access sessions they have no explicit grant for.
Sets the admin flag directly on the permission store to simulate
server startup behavior (the CLI calls
``ensure_user("local", is_admin=True)``).
"""
agent = await create_test_agent(auth_client, user="bryan")
s1 = await _create_session_as(auth_client, agent["id"], "user-a")
session_id = s1["id"]
# Set "admin-user" as admin directly in the store.
perm_store = SqlAlchemyPermissionStore(db_uri)
perm_store.ensure_user("admin-user", is_admin=True)
# admin-user can GET user-a's session without any grant.
resp = await auth_client.get(
f"/v1/sessions/{session_id}",
headers={"X-Forwarded-Email": "admin-user"},
)
assert resp.status_code == 200, (
f"admin user should bypass permission checks and GET any session, "
f"got {resp.status_code}. If 404, the is_admin check in "
f"check_session_access is not working."
)
assert resp.json()["id"] == session_id
# admin-user can also list permissions on user-a's session.
resp = await _list_permissions(
auth_client,
session_id,
user="admin-user",
)
assert resp.status_code == 200, (
f"admin user should be able to list permissions, got {resp.status_code}."
)
# ── Revoke nonexistent: idempotent 204 ──────────────────────
async def test_revoke_nonexistent_grant_returns_204(
auth_client: httpx.AsyncClient,
) -> None:
"""Revoking a user who has no grant returns 204 (no error)."""
agent = await create_test_agent(auth_client, user="bryan")
s1 = await _create_session_as(auth_client, agent["id"], "user-a")
session_id = s1["id"]
# "stranger" has never been granted anything on this session.
resp = await _revoke_permission(
auth_client,
session_id,
revoker="user-a",
target_user="stranger",
)
# Idempotent revoke: 204 whether or not the grant existed.
assert resp.status_code == 204, (
f"Revoking a nonexistent grant should return 204, "
f"got {resp.status_code}. If 404 or 400, the revoke "
f"endpoint is not idempotent."
)
# ── Cross-session isolation ──────────────────────────────────
async def test_grant_on_one_session_does_not_leak_to_another(
auth_client: httpx.AsyncClient,
) -> None:
"""A grant on session A does not grant access to session B."""
agent = await create_test_agent(auth_client, user="bryan")
s1 = await _create_session_as(
auth_client,
agent["id"],
"user-a",
title="session-A",
)
s2 = await _create_session_as(
auth_client,
agent["id"],
"user-a",
title="session-B",
)
# Grant user-b read on session A only.
resp = await _grant_permission(
auth_client,
s1["id"],
granter="user-a",
target_user="user-b",
level=LEVEL_READ,
)
assert resp.status_code == 200
# user-b can GET session A.
resp = await auth_client.get(
f"/v1/sessions/{s1['id']}",
headers={"X-Forwarded-Email": "user-b"},
)
assert resp.status_code == 200
# user-b cannot GET session B (no grant).
resp = await auth_client.get(
f"/v1/sessions/{s2['id']}",
headers={"X-Forwarded-Email": "user-b"},
)
assert resp.status_code == 404, (
f"user-b has a grant on session A but NOT session B. "
f"GET on session B should return 404, got {resp.status_code}. "
f"If 200, grants are leaking across sessions."
)
# ── Non-manager cannot grant or revoke ───────────────────────
async def test_non_manager_cannot_grant_permissions(
auth_client: httpx.AsyncClient,
) -> None:
"""A user with only read access cannot grant permissions."""
agent = await create_test_agent(auth_client, user="bryan")
s1 = await _create_session_as(auth_client, agent["id"], "user-a")
session_id = s1["id"]
# Grant user-b read only.
await _grant_permission(
auth_client,
session_id,
granter="user-a",
target_user="user-b",
level=LEVEL_READ,
)
# user-b tries to grant user-c -> blocked (requires manage).
resp = await _grant_permission(
auth_client,
session_id,
granter="user-b",
target_user="user-c",
level=LEVEL_READ,
)
assert resp.status_code == 403, (
f"user-b with read grant should be blocked from granting "
f"permissions. Got {resp.status_code}."
)
async def test_non_manager_cannot_revoke_permissions(
auth_client: httpx.AsyncClient,
) -> None:
"""A user without manage access cannot revoke permissions."""
agent = await create_test_agent(auth_client, user="bryan")
s1 = await _create_session_as(auth_client, agent["id"], "user-a")
session_id = s1["id"]
# Grant user-b edit (not manage).
await _grant_permission(
auth_client,
session_id,
granter="user-a",
target_user="user-b",
level=LEVEL_EDIT,
)
# user-b tries to revoke user-a -> blocked (requires manage).
resp = await _revoke_permission(
auth_client,
session_id,
revoker="user-b",
target_user="user-a",
)
assert resp.status_code == 403, (
f"user-b with edit grant should be blocked from revoking "
f"permissions. Got {resp.status_code}."
)
# ── Session creator auto-grant ───────────────────────────────
async def test_session_creator_gets_manage_grant(
auth_client: httpx.AsyncClient,
) -> None:
"""Creating a session auto-grants the creator manage access."""
agent = await create_test_agent(auth_client, user="bryan")
s1 = await _create_session_as(auth_client, agent["id"], "owner")
session_id = s1["id"]
# The creator should have a manage grant.
resp = await _list_permissions(
auth_client,
session_id,
user="owner",
)
assert resp.status_code == 200
grants = resp.json()
# Exactly one grant: the creator with owner level.
assert len(grants) == 1, (
f"Expected exactly 1 auto-grant on a fresh session, "
f"got {len(grants)}. If 0, the auto-grant on create is broken."
)
assert grants[0]["user_id"] == "owner"
assert grants[0]["level"] == LEVEL_OWNER, (
f"Auto-grant should be owner (level {LEVEL_OWNER}), got {grants[0]['level']}."
)
# ── Upsert: grant upgrades existing level ───────────────────
async def test_grant_upgrade_via_upsert(
auth_client: httpx.AsyncClient,
) -> None:
"""Granting a higher level to an existing user upgrades the grant."""
agent = await create_test_agent(auth_client, user="bryan")
s1 = await _create_session_as(auth_client, agent["id"], "user-a")
session_id = s1["id"]
# Grant user-b read first.
await _grant_permission(
auth_client,
session_id,
granter="user-a",
target_user="user-b",
level=LEVEL_READ,
)
# Upgrade user-b to manage.
resp = await _grant_permission(
auth_client,
session_id,
granter="user-a",
target_user="user-b",
level=LEVEL_MANAGE,
)
assert resp.status_code == 200
assert resp.json()["level"] == LEVEL_MANAGE, (
"Upsert should upgrade the level from read to manage."
)
# Verify via list.
resp = await _list_permissions(
auth_client,
session_id,
user="user-a",
)
grant_map = {g["user_id"]: g["level"] for g in resp.json()}
assert grant_map["user-b"] == LEVEL_MANAGE, (
"After upgrade, user-b should have manage level in the store."
)
# ── Unauthenticated request: no header ────────────────────
async def test_no_header_rejected_in_header_mode(
auth_client: httpx.AsyncClient,
) -> None:
"""Requests without X-Forwarded-Email are rejected (401) in header mode.
Regression test at the route level: on a deployed
header-mode server (no single-user marker), a missing or
proxy-dropped identity header must fail closed. Before the fix
it resolved to the shared "local" identity, giving every
unauthenticated request OWNER access to every other
unauthenticated user's sessions.
"""
agent = await create_test_agent(auth_client, user="bryan")
# Create without header -> 401, nothing created.
resp = await auth_client.post(
"/v1/sessions",
json={"agent_id": agent["id"]},
# No X-Forwarded-Email header
)
assert resp.status_code == 401, (
f"Creating a session without X-Forwarded-Email must be rejected "
f"with 401 in header mode, got {resp.status_code}. A 201 means "
f"unauthenticated requests share the 'local' identity."
)
# Reads fail closed too: a headerless request can't list sessions.
s1 = await _create_session_as(auth_client, agent["id"], "user-a")
resp = await auth_client.get("/v1/sessions")
assert resp.status_code == 401, (
f"Listing sessions without X-Forwarded-Email must be rejected "
f"with 401 in header mode, got {resp.status_code}."
)
resp = await auth_client.get(f"/v1/sessions/{s1['id']}")
assert resp.status_code == 401, (
f"Reading a session without X-Forwarded-Email must be rejected "
f"with 401 in header mode, got {resp.status_code}."
)
async def test_no_header_defaults_to_local_user_in_single_user_mode(
local_auth_client: httpx.AsyncClient,
) -> None:
"""Headerless requests default to 'local' on a single-user runtime."""
agent = await create_test_agent(local_auth_client, user="bryan")
# Create session without header -> should succeed as "local".
resp = await local_auth_client.post(
"/v1/sessions",
json={"agent_id": agent["id"]},
# No X-Forwarded-Email header
)
assert resp.status_code == 201, (
f"Creating a session without X-Forwarded-Email should default "
f"to 'local' user on a single-user runtime, got {resp.status_code}."
)
session_id = resp.json()["id"]
# "local" should have an owner grant. Use user=None to omit
# the header and trigger the "local" fallback — sending
# "local" as a header would be rejected (reserved name).
resp = await _list_permissions(
local_auth_client,
session_id,
user=None,
)
assert resp.status_code == 200
grants = resp.json()
assert any(g["user_id"] == "local" and g["level"] == LEVEL_OWNER for g in grants), (
"The 'local' user (default from missing header) should have "
"an owner auto-grant on the created session."
)
# ── Self-downgrade blocked ─────────────────────────────────
async def test_self_grant_blocked_at_any_level(
auth_client: httpx.AsyncClient,
) -> None:
"""The session owner cannot grant themselves ANY level — self-modification is fully blocked.
The route checks ``body.user_id == user_id`` before touching
the store, so every self-grant level (1, 2, 3) must return 403
with "Cannot modify your own permissions". This is not limited
to downgrades — even re-granting the same level is blocked.
"""
agent = await create_test_agent(auth_client, user="bryan")
s1 = await _create_session_as(auth_client, agent["id"], "bryan")
session_id = s1["id"]
for level in (LEVEL_READ, LEVEL_EDIT, LEVEL_MANAGE):
resp = await _grant_permission(
auth_client,
session_id,
granter="bryan",
target_user="bryan",
level=level,
)
# Self-grant at ANY level must be blocked. If 200, the
# ``body.user_id == user_id`` guard in grant_permission is
# missing or not covering this level.
assert resp.status_code == 403, (
f"Self-grant at level={level} should return 403, "
f"got {resp.status_code}. If 200, the self-modification "
f"guard is broken or has a level exception."
)
body = resp.json()
assert "Cannot modify your own permissions" in body["error"]["message"], (
f"Expected 'Cannot modify your own permissions' in error "
f"message, got {body['error']['message']!r}."
)
# ── Self-revoke blocked (general) ──────────────────────────
async def test_self_revoke_blocked(
auth_client: httpx.AsyncClient,
) -> None:
"""The session owner cannot revoke themselves — self-modification is fully blocked.
The existing lifecycle test (step 6) checks self-revoke for the
owner. This test verifies the same guard independently and
confirms the error message explicitly mentions self-modification.
"""
agent = await create_test_agent(auth_client, user="bryan")
s1 = await _create_session_as(auth_client, agent["id"], "bryan")
session_id = s1["id"]
resp = await _revoke_permission(
auth_client,
session_id,
revoker="bryan",
target_user="bryan",
)
# Self-revoke must always be blocked. If 204, the
# ``target_user_id == user_id`` guard in revoke_permission
# is missing or was removed.
assert resp.status_code == 403, (
f"Self-revoke should return 403, got {resp.status_code}. "
f"If 204, the self-modification guard is broken and the "
f"session could be orphaned."
)
body = resp.json()
assert "Cannot modify your own permissions" in body["error"]["message"], (
f"Expected 'Cannot modify your own permissions' in error "
f"message, got {body['error']['message']!r}."
)
# ── Multi-session list with mixed access ───────────────────
async def test_multi_session_list_mixed_access(
auth_client: httpx.AsyncClient,
) -> None:
"""Multiple sessions with mixed grants produce correct per-user visibility.
Bryan creates S1-S5. Grants:
- corey: read on S1, edit on S3
- __public__: read on S5
- rice: manage on S2
Expected visibility (public-only sessions are excluded from the list):
- bryan: all 5 (owner)
- corey: S1 + S3 (direct grants only) = 2
- rice: S2 (direct grant only) = 1
- nobody: nothing (no direct grants)
"""
agent = await create_test_agent(auth_client, user="bryan")
sessions = []
for i in range(1, 6):
s = await _create_session_as(
auth_client,
agent["id"],
"bryan",
title=f"session-{i}",
)
sessions.append(s)
s1, s2, s3, _, s5 = sessions
# Grant corey read on S1, edit on S3.
resp = await _grant_permission(
auth_client,
s1["id"],
granter="bryan",
target_user="corey",
level=LEVEL_READ,
)
assert resp.status_code == 200
resp = await _grant_permission(
auth_client,
s3["id"],
granter="bryan",
target_user="corey",
level=LEVEL_EDIT,
)
assert resp.status_code == 200
# Grant __public__ read on S5.
resp = await _grant_permission(
auth_client,
s5["id"],
granter="bryan",
target_user="__public__",
level=LEVEL_READ,
)
assert resp.status_code == 200
# Grant rice manage on S2.
resp = await _grant_permission(
auth_client,
s2["id"],
granter="bryan",
target_user="rice",
level=LEVEL_MANAGE,
)
assert resp.status_code == 200
all_ids = {s["id"] for s in sessions}
# Bryan (owner of all 5) sees all 5.
bryan_sessions = await _list_sessions_as(auth_client, "bryan")
bryan_ids = {s["id"] for s in bryan_sessions}
# Bryan owns all five sessions and must see every one.
# If any are missing, the accessible_by filter is too restrictive
# for owners.
assert all_ids.issubset(bryan_ids), (
f"Bryan should see all 5 sessions, but is missing {all_ids - bryan_ids}."
)
# Corey sees S1 (read), S3 (edit) = 2 of bryan's sessions.
# S5 is public-only (no direct grant for corey) so it's excluded.
corey_sessions = await _list_sessions_as(auth_client, "corey")
corey_ids = {s["id"] for s in corey_sessions}
expected_corey = {s1["id"], s3["id"]}
assert expected_corey == corey_ids & all_ids, (
f"Corey should see exactly S1, S3 from bryan's sessions, "
f"but sees {corey_ids & all_ids}. Expected {expected_corey}."
)
# Rice sees S2 (manage) = 1 of bryan's sessions.
# S5 is public-only (no direct grant for rice) so it's excluded.
rice_sessions = await _list_sessions_as(auth_client, "rice")
rice_ids = {s["id"] for s in rice_sessions}
expected_rice = {s2["id"]}
assert expected_rice == rice_ids & all_ids, (
f"Rice should see exactly S2 from bryan's sessions, "
f"but sees {rice_ids & all_ids}. Expected {expected_rice}."
)
# Nobody (zero direct grants) sees nothing — public-only sessions
# are excluded from the list (accessible by direct URL only).
nobody_sessions = await _list_sessions_as(auth_client, "nobody")
nobody_ids = {s["id"] for s in nobody_sessions}
assert not (nobody_ids & all_ids), (
f"Nobody should see none of bryan's sessions, but sees {nobody_ids & all_ids}."
)
# ── Invalid grant levels at HTTP boundary ──────────────────
@pytest.mark.parametrize(
"invalid_level",
[0, 4, -1],
ids=["zero", "four", "negative"],
)
async def test_grant_invalid_level_returns_422(
auth_client: httpx.AsyncClient,
invalid_level: int,
) -> None:
"""Out-of-range grant levels (0, 4, -1) are rejected with 422 by Pydantic.
The ``GrantPermissionRequest.level`` field has ``Field(ge=1, le=3)``,
so any value outside [1, 3] triggers a Pydantic validation error
before the route handler runs. 422 means the request body was
syntactically valid JSON but semantically invalid.
"""
agent = await create_test_agent(auth_client, user="bryan")
s1 = await _create_session_as(auth_client, agent["id"], "bryan")
session_id = s1["id"]
resp = await _grant_permission(
auth_client,
session_id,
granter="bryan",
target_user="someone",
level=invalid_level,
)
# Pydantic's Field(ge=1, le=3) rejects out-of-range levels with
# a 422 Unprocessable Entity before the handler runs. If 200,
# either the Field constraint was removed or the level is not
# validated.
assert resp.status_code == 422, (
f"Grant with level={invalid_level} should return 422, "
f"got {resp.status_code}. If 200, the Field(ge=1, le=3) "
f"constraint on GrantPermissionRequest.level is missing."
)
async def test_grant_valid_level_succeeds(
auth_client: httpx.AsyncClient,
) -> None:
"""A valid grant level (2) succeeds with 200, confirming the validation boundary.
This is the positive counterpart to the invalid-level tests: proves
that the validation logic does not over-reject.
"""
agent = await create_test_agent(auth_client, user="bryan")
s1 = await _create_session_as(auth_client, agent["id"], "bryan")
session_id = s1["id"]
resp = await _grant_permission(
auth_client,
session_id,
granter="bryan",
target_user="someone",
level=LEVEL_EDIT,
)
# Valid level=2 must succeed. If 422, the validation is too strict.
assert resp.status_code == 200, (
f"Grant with valid level=2 should return 200, got {resp.status_code}."
)
assert resp.json()["level"] == LEVEL_EDIT, "Returned level should match the granted level."
# ── Pagination with permission filter ──────────────────────
async def test_pagination_with_permission_filter(
auth_client: httpx.AsyncClient,
) -> None:
"""Session list respects pagination cursors when filtered by permission.
Bryan creates S1, S2, S3 and grants corey read on all three.
Corey lists with limit=1 and paginates through all three sessions
using the ``after`` cursor from each page.
"""
agent = await create_test_agent(auth_client, user="bryan")
session_ids = []
for i in range(1, 4):
s = await _create_session_as(
auth_client,
agent["id"],
"bryan",
title=f"page-{i}",
)
session_ids.append(s["id"])
# Grant corey read on each session.
resp = await _grant_permission(
auth_client,
s["id"],
granter="bryan",
target_user="corey",
level=LEVEL_READ,
)
assert resp.status_code == 200
# Corey paginates with limit=1.
collected_ids: list[str] = []
after_cursor: str | None = None
pages_seen = 0
while True:
params: dict[str, Any] = {"limit": 1}
if after_cursor is not None:
params["after"] = after_cursor
resp = await auth_client.get(
"/v1/sessions",
params=params,
headers={"X-Forwarded-Email": "corey"},
)
assert resp.status_code == 200, f"List sessions failed: {resp.status_code} {resp.text}"
body = resp.json()
page_data = body["data"]
# Each page must contain exactly 1 session (limit=1) except
# possibly the last page if we've exhausted the list.
for item in page_data:
collected_ids.append(item["id"])
pages_seen += 1
if not body["has_more"]:
break
# Use last_id as the cursor for the next page.
after_cursor = body["last_id"]
# Safety: prevent infinite loops in case of a bug.
assert pages_seen <= 5, (
f"Pagination did not terminate after 5 pages — possible "
f"infinite loop. Collected: {collected_ids}"
)
# All 3 sessions must appear exactly once across all pages.
# If fewer, the permission filter or pagination cursor is broken.
# If duplicates, the cursor is not advancing correctly.
assert set(collected_ids) == set(session_ids), (
f"Expected all 3 sessions {session_ids} across paginated pages, "
f"got {collected_ids}. If fewer, pagination or permission "
f"filter dropped a session. If duplicates, the cursor "
f"is not advancing."
)
assert len(collected_ids) == len(session_ids), (
f"Expected exactly {len(session_ids)} session ids (no duplicates), "
f"got {len(collected_ids)}: {collected_ids}."
)
# ── Transfer ownership ─────────────────────────────────────
async def test_owner_grant_is_immutable(
auth_client: httpx.AsyncClient,
) -> None:
"""Owner (level 4) grants cannot be revoked or overwritten.
Bryan creates S1 (gets owner), grants corey manage. Corey
attempts to revoke bryan — blocked because bryan has LEVEL_OWNER.
Bryan's owner grant also cannot be downgraded via a new grant.
"""
agent = await create_test_agent(auth_client, user="bryan")
s1 = await _create_session_as(auth_client, agent["id"], "bryan")
session_id = s1["id"]
# Bryan grants corey manage access.
resp = await _grant_permission(
auth_client,
session_id,
granter="bryan",
target_user="corey",
level=LEVEL_MANAGE,
)
assert resp.status_code == 200
# Corey attempts to revoke bryan — blocked (owner is immutable).
resp = await _revoke_permission(
auth_client,
session_id,
revoker="corey",
target_user="bryan",
)
assert resp.status_code == 403, (
f"Revoking an owner grant should return 403. "
f"Got {resp.status_code}. If 204, the owner immutability "
f"guard is broken."
)
# Bryan still has full access.
resp = await auth_client.get(
f"/v1/sessions/{session_id}",
headers={"X-Forwarded-Email": "bryan"},
)
assert resp.status_code == 200
# Verify both grants remain.
resp = await _list_permissions(
auth_client,
session_id,
user="bryan",
)
assert resp.status_code == 200
grants = resp.json()
grant_map = {g["user_id"]: g["level"] for g in grants}
assert grant_map == {"bryan": LEVEL_OWNER, "corey": LEVEL_MANAGE}, (
f"Expected bryan=owner(4) and corey=manage(3), got {grant_map}."
)
# ── PATCH session: title requires edit, runner_id requires owner ──
async def test_patch_session_requires_edit_access(
auth_client: httpx.AsyncClient,
) -> None:
"""Read-only cannot PATCH title; edit can. Runner_id requires owner.
Bryan creates S1, grants corey read. Corey tries PATCH title -> 403.
Bryan upgrades corey to edit. Corey PATCHes title -> 200.
Corey tries PATCH runner_id -> 403 (owner required), with fork hint.
Bryan (owner) PATCHes runner_id -> 200.
"""
agent = await create_test_agent(auth_client, user="bryan")
s1 = await _create_session_as(
auth_client,
agent["id"],
"bryan",
title="original-title",
)
session_id = s1["id"]
# Grant corey read only.
resp = await _grant_permission(
auth_client,
session_id,
granter="bryan",
target_user="corey",
level=LEVEL_READ,
)
assert resp.status_code == 200
# Corey tries PATCH title with read-only -> blocked.
resp = await auth_client.patch(
f"/v1/sessions/{session_id}",
json={"title": "corey-was-here"},
headers={"X-Forwarded-Email": "corey"},
)
assert resp.status_code == 403, (
f"Corey with read-only should get 403 on PATCH, got {resp.status_code}."
)
# Bryan upgrades corey to edit.
resp = await _grant_permission(
auth_client,
session_id,
granter="bryan",
target_user="corey",
level=LEVEL_EDIT,
)
assert resp.status_code == 200
# Corey PATCHes title -> succeeds with edit.
resp = await auth_client.patch(
f"/v1/sessions/{session_id}",
json={"title": "corey-was-here"},
headers={"X-Forwarded-Email": "corey"},
)
assert resp.status_code == 200, (
f"Corey with edit access should be able to PATCH title, got {resp.status_code}."
)
assert resp.json()["title"] == "corey-was-here", (
"PATCH response should reflect the updated title."
)
# Corey tries PATCH runner_id -> blocked (requires owner).
resp = await auth_client.patch(
f"/v1/sessions/{session_id}",
json={"runner_id": "runner_fake"},
headers={"X-Forwarded-Email": "corey"},
)
assert resp.status_code == 403, (
f"Corey with edit access should get 403 when setting runner_id "
f"(requires owner), got {resp.status_code}."
)
error_msg = resp.json()["error"]["message"]
assert "owner" in error_msg.lower(), "403 message should mention owner requirement."
assert f"--fork {session_id}" in error_msg, "403 message should suggest the fork CLI command."
# ── GET /sessions/{id}/items with read access ──────────────
async def test_get_session_items_with_read_access(
auth_client: httpx.AsyncClient,
) -> None:
"""A user with read access can GET session items; a user with no access gets 404."""
agent = await create_test_agent(auth_client, user="bryan")
s1 = await _create_session_as(auth_client, agent["id"], "bryan")
session_id = s1["id"]
# Grant corey read.
resp = await _grant_permission(
auth_client,
session_id,
granter="bryan",
target_user="corey",
level=LEVEL_READ,
)
assert resp.status_code == 200
# Corey can GET items.
resp = await auth_client.get(
f"/v1/sessions/{session_id}/items",
headers={"X-Forwarded-Email": "corey"},
)
# Read access is sufficient for GET /items. If 403, the items
# endpoint requires a higher level than LEVEL_READ.
assert resp.status_code == 200, (
f"Corey with read grant should GET /items -> 200, got {resp.status_code}."
)
# Stranger has no access -> 404.
resp = await auth_client.get(
f"/v1/sessions/{session_id}/items",
headers={"X-Forwarded-Email": "stranger"},
)
# No grant at all -> 404 (session existence hidden).
assert resp.status_code == 404, (
f"Stranger with no grant should get 404 on /items, "
f"got {resp.status_code}. If 200, the permission check "
f"on GET /items is missing."
)
# ── SSE stream without permissions → 404 ───────────────────
async def test_stream_session_denied_without_access(
auth_client: httpx.AsyncClient,
) -> None:
"""A user with no grant gets 404 when requesting the SSE stream.
The permission check fires before the streaming generator starts,
so the response completes immediately with a JSON error body.
We do NOT test the success case (200) here because the SSE stream
never terminates and the ASGI in-process transport blocks. The
permission guard is shared code (``_require_access``) tested on
other endpoints; the 404 case confirms the stream endpoint wires
it in.
"""
agent = await create_test_agent(auth_client, user="bryan")
s1 = await _create_session_as(auth_client, agent["id"], "bryan")
session_id = s1["id"]
# Nobody has no grant -> 404 (session existence hidden).
resp = await auth_client.get(
f"/v1/sessions/{session_id}/stream",
headers={"X-Forwarded-Email": "nobody"},
)
# No grant at all -> 404. If 200, the permission check on
# GET /stream is missing. The error response body is a normal
# JSON payload (not SSE) because the error fires before the
# streaming generator starts.
assert resp.status_code == 404, (
f"Nobody with no grant should get 404 on /stream, "
f"got {resp.status_code}. If 200, the permission check "
f"on GET /stream is missing."
)
# Also verify a user with insufficient (read-only) access can
# at least reach the endpoint (the stream requires LEVEL_READ,
# which they have). We test this indirectly: grant corey read,
# then confirm corey does NOT get 404 on the stream URL by
# checking that the GET /session (same permission level) works.
resp = await _grant_permission(
auth_client,
session_id,
granter="bryan",
target_user="corey",
level=LEVEL_READ,
)
assert resp.status_code == 200
# Corey can GET the session (same LEVEL_READ check as /stream).
resp = await auth_client.get(
f"/v1/sessions/{session_id}",
headers={"X-Forwarded-Email": "corey"},
)
# If corey can GET the session, the read-level permission is
# sufficient. The stream endpoint uses the same guard.
assert resp.status_code == 200, (
f"Corey with read grant should GET session -> 200, got {resp.status_code}."
)
# ── Fork session requires read access ───────────────────────
async def test_fork_session_requires_read_access(
auth_client: httpx.AsyncClient,
) -> None:
"""A user with no access cannot fork; a user with read access can.
Bryan creates S1. Nobody tries to fork -> 404 (no access).
Bryan grants corey read. Corey forks -> 201.
"""
agent = await create_test_agent(auth_client, user="bryan")
s1 = await _create_session_as(
auth_client,
agent["id"],
"bryan",
title="original",
)
session_id = s1["id"]
# Nobody (no grant) tries to fork -> 404 (hides existence).
resp = await auth_client.post(
f"/v1/sessions/{session_id}/fork",
json={},
headers={"X-Forwarded-Email": "nobody"},
)
assert resp.status_code == 404, (
f"User with no access should get 404 on fork, got {resp.status_code}."
)
# Grant corey read.
resp = await _grant_permission(
auth_client,
session_id,
granter="bryan",
target_user="corey",
level=LEVEL_READ,
)
assert resp.status_code == 200
# Corey forks -> succeeds.
resp = await auth_client.post(
f"/v1/sessions/{session_id}/fork",
json={},
headers={"X-Forwarded-Email": "corey"},
)
assert resp.status_code == 201, (
f"Corey with read access should be able to fork, got {resp.status_code}."
)
fork = resp.json()
assert fork["id"] != session_id, "Fork should have a new session id."
assert fork["permission_level"] == LEVEL_OWNER, (
f"Forking user should be the owner of the new session, "
f"got permission_level={fork['permission_level']}."
)
# Bryan should NOT have access to Corey's fork.
resp = await auth_client.get(
f"/v1/sessions/{fork['id']}",
headers={"X-Forwarded-Email": "bryan"},
)
assert resp.status_code == 404, (
f"Bryan should not have access to Corey's fork, got {resp.status_code}."
)
# ── GET /sessions/{id}/owner ─────────────────────────────────
async def test_get_owner_returns_creator(
auth_client: httpx.AsyncClient,
) -> None:
"""GET /sessions/{id}/owner returns the session creator for any user with read access."""
agent = await create_test_agent(auth_client, user="bryan")
session = await _create_session_as(auth_client, agent["id"], "bryan")
session_id = session["id"]
# Grant corey read access.
resp = await _grant_permission(
auth_client,
session_id,
granter="bryan",
target_user="corey",
level=LEVEL_READ,
)
assert resp.status_code == 200
# Bryan (owner) sees himself as owner.
resp = await auth_client.get(
f"/v1/sessions/{session_id}/owner",
headers={"X-Forwarded-Email": "bryan"},
)
assert resp.status_code == 200
assert resp.json()["owner"] == "bryan"
# Corey (read-only) also sees bryan as owner.
resp = await auth_client.get(
f"/v1/sessions/{session_id}/owner",
headers={"X-Forwarded-Email": "corey"},
)
assert resp.status_code == 200
assert resp.json()["owner"] == "bryan"
async def test_get_owner_forbidden_without_access(
auth_client: httpx.AsyncClient,
) -> None:
"""GET /sessions/{id}/owner returns 404 for users with no access."""
agent = await create_test_agent(auth_client, user="bryan")
session = await _create_session_as(auth_client, agent["id"], "bryan")
resp = await auth_client.get(
f"/v1/sessions/{session['id']}/owner",
headers={"X-Forwarded-Email": "nobody"},
)
assert resp.status_code == 404
# ── owner field in GET /v1/sessions list ────────────────────
async def test_list_sessions_includes_owner_for_shared_session(
auth_client: httpx.AsyncClient,
) -> None:
"""GET /v1/sessions includes the owner field so the sidebar
can display it without a separate per-session API call.
Bryan creates a session, grants Corey read access. When Corey
lists sessions, the item must carry ``owner: "bryan"``.
"""
agent = await create_test_agent(auth_client, user="bryan")
session = await _create_session_as(auth_client, agent["id"], "bryan", title="shared-test")
session_id = session["id"]
resp = await _grant_permission(
auth_client,
session_id,
granter="bryan",
target_user="corey",
level=LEVEL_READ,
)
assert resp.status_code == 200
# Corey lists sessions and should see the owner field.
corey_sessions = await _list_sessions_as(auth_client, "corey")
matched = [s for s in corey_sessions if s["id"] == session_id]
assert len(matched) == 1, (
f"Corey should see exactly one session ({session_id}), "
f"got {len(matched)}. If 0, the grant didn't propagate to the list filter."
)
# owner must be the creator, not the requesting user.
assert matched[0]["owner"] == "bryan", (
f"Expected owner='bryan' (the session creator), "
f"got owner={matched[0].get('owner')!r}. If None, the list "
f"endpoint is not populating the owner field."
)
async def test_list_sessions_includes_owner_for_own_session(
auth_client: httpx.AsyncClient,
) -> None:
"""The owner field is present even when the requesting user
is the session owner — the sidebar needs it to know whether
to render the owner subtitle.
"""
agent = await create_test_agent(auth_client, user="bryan")
session = await _create_session_as(auth_client, agent["id"], "bryan", title="own-test")
session_id = session["id"]
bryan_sessions = await _list_sessions_as(auth_client, "bryan")
matched = [s for s in bryan_sessions if s["id"] == session_id]
assert len(matched) == 1, f"Bryan should see the session they created ({session_id})."
assert matched[0]["owner"] == "bryan", (
f"Expected owner='bryan' for the creator's own session, "
f"got owner={matched[0].get('owner')!r}."
)
# ── session-create authorization ─────────────
async def _create_bundled_session_as(
client: httpx.AsyncClient,
user: str,
*,
name: str = "test-agent",
title: str | None = None,
) -> dict[str, Any]:
"""Create a session via multipart upload as a specific user.
Returns the full session snapshot (including ``agent_id``).
:param client: The test HTTP client.
:param user: User identity for ``X-Forwarded-Email``.
:param name: Agent name to write into the bundle.
:param title: Optional session title.
:returns: Parsed ``GET /v1/sessions/{id}`` snapshot.
"""
metadata: dict[str, Any] = {}
if title is not None:
metadata["title"] = title
bundle = build_agent_bundle(name=name)
resp = await client.post(
"/v1/sessions",
data={"metadata": json.dumps(metadata)},
files={"bundle": ("agent.tar.gz", bundle, "application/gzip")},
headers={"X-Forwarded-Email": user},
)
assert resp.status_code == 201, (
f"bundled session create failed: {resp.status_code} {resp.text}"
)
session_id = resp.json()["session_id"]
snapshot = await client.get(
f"/v1/sessions/{session_id}",
headers={"X-Forwarded-Email": user},
)
assert snapshot.status_code == 200
return snapshot.json()
async def test_w5_01_parent_session_id_requires_access(
auth_client: httpx.AsyncClient,
) -> None:
"""A caller cannot supply another user's session as
``parent_session_id`` to inherit runner bindings and establish
a parent-child link.
Alice creates a session. Bob tries to create a child session
referencing Alice's session as parent — the server must reject
with 404 (session not found from Bob's perspective) since Bob
has no access to Alice's session.
"""
agent = await create_test_agent(auth_client, user="bryan")
alice_session = await _create_session_as(
auth_client, agent["id"], "alice", title="alice-parent"
)
alice_session_id = alice_session["id"]
# Bob tries to create a child session referencing Alice's session.
resp = await auth_client.post(
"/v1/sessions",
json={
"agent_id": agent["id"],
"parent_session_id": alice_session_id,
},
headers={"X-Forwarded-Email": "bob"},
)
assert resp.status_code in (403, 404), (
f"Expected 403/404 when Bob references Alice's session as parent, "
f"got {resp.status_code}: {resp.text}"
)
async def test_w5_01_parent_session_id_allowed_with_access(
auth_client: httpx.AsyncClient,
) -> None:
"""Positive path: when Alice grants Bob read access to
her session, Bob can reference it as ``parent_session_id``.
"""
agent = await create_test_agent(auth_client, user="bryan")
alice_session = await _create_session_as(
auth_client, agent["id"], "alice", title="alice-parent"
)
alice_session_id = alice_session["id"]
# Alice grants Bob read access.
grant_resp = await _grant_permission(
auth_client,
alice_session_id,
granter="alice",
target_user="bob",
level=LEVEL_READ,
)
assert grant_resp.status_code == 200
# Bob can now create a child session referencing Alice's session.
# Use Alice's session-scoped agent (Bob has read access to Alice's
# owning session, so the agent access check passes).
resp = await auth_client.post(
"/v1/sessions",
json={
"agent_id": alice_session["agent_id"],
"parent_session_id": alice_session_id,
},
headers={"X-Forwarded-Email": "bob"},
)
assert resp.status_code == 201, (
f"Expected 201 when Bob has read access to Alice's parent session, "
f"got {resp.status_code}: {resp.text}"
)
async def test_w5_01_multipart_parent_session_id_requires_access(
auth_client: httpx.AsyncClient,
) -> None:
"""Multipart path: a caller cannot supply another user's
session as ``metadata.parent_session_id`` on the bundled create.
The bundle-mode ``sys_session_create`` rides this endpoint, so a
missing check would let any user parent a session into someone
else's tree (and inherit their runner binding) by uploading a
bundle. Must reject with 404 (no access = existence hidden).
"""
alice_session = await _create_session_as(
auth_client, "ignored", "alice", title="alice-multipart-parent"
)
bundle = build_agent_bundle(name="bob-bundle-agent")
resp = await auth_client.post(
"/v1/sessions",
data={"metadata": json.dumps({"parent_session_id": alice_session["id"]})},
files={"bundle": ("agent.tar.gz", bundle, "application/gzip")},
headers={"X-Forwarded-Email": "bob"},
)
assert resp.status_code in (403, 404), (
f"Expected 403/404 when Bob references Alice's session as multipart "
f"parent, got {resp.status_code}: {resp.text}"
)
async def test_w5_01_multipart_parent_session_id_allowed_with_access(
auth_client: httpx.AsyncClient,
) -> None:
"""Positive path, multipart: with READ access to Alice's
session, Bob's bundled create may parent into it.
The created child must be linked to Alice's session — proving the
metadata field traversed authorization → store → conversation row,
not just that the request was accepted.
"""
alice_session = await _create_session_as(
auth_client, "ignored", "alice", title="alice-multipart-parent-ok"
)
grant_resp = await _grant_permission(
auth_client,
alice_session["id"],
granter="alice",
target_user="bob",
level=LEVEL_READ,
)
assert grant_resp.status_code == 200
bundle = build_agent_bundle(name="bob-granted-bundle-agent")
resp = await auth_client.post(
"/v1/sessions",
data={"metadata": json.dumps({"parent_session_id": alice_session["id"]})},
files={"bundle": ("agent.tar.gz", bundle, "application/gzip")},
headers={"X-Forwarded-Email": "bob"},
)
assert resp.status_code == 201, (
f"Expected 201 when Bob has read access to Alice's parent session, "
f"got {resp.status_code}: {resp.text}"
)
child_id = resp.json()["session_id"]
snap = await auth_client.get(
f"/v1/sessions/{child_id}",
headers={"X-Forwarded-Email": "bob"},
)
assert snap.status_code == 200, snap.text
assert snap.json()["parent_session_id"] == alice_session["id"]
async def test_w7_2_session_scoped_agent_requires_access(
auth_client: httpx.AsyncClient,
) -> None:
"""A caller cannot execute another user's session-scoped
agent by raw ``agent_id``.
Alice creates a bundled session (which produces a session-scoped
agent). Bob extracts the agent id and tries to create a new
session with it — the server must reject because Bob has no
access to Alice's owning session.
"""
alice_session = await _create_bundled_session_as(
auth_client, "alice", name="alice-private-agent", title="alice-bundled"
)
alice_agent_id = alice_session["agent_id"]
# Bob tries to create a session using Alice's session-scoped agent.
resp = await auth_client.post(
"/v1/sessions",
json={"agent_id": alice_agent_id},
headers={"X-Forwarded-Email": "bob"},
)
assert resp.status_code in (403, 404), (
f"Expected 403/404 when Bob uses Alice's session-scoped agent, "
f"got {resp.status_code}: {resp.text}"
)
async def test_w7_2_session_scoped_agent_allowed_with_access(
auth_client: httpx.AsyncClient,
) -> None:
"""Positive path: when Alice grants Bob read access to
the owning session, Bob can use the session-scoped agent.
"""
alice_session = await _create_bundled_session_as(
auth_client, "alice", name="alice-shared-agent", title="alice-shared"
)
alice_agent_id = alice_session["agent_id"]
alice_session_id = alice_session["id"]
# Alice grants Bob read access to the owning session.
grant_resp = await _grant_permission(
auth_client,
alice_session_id,
granter="alice",
target_user="bob",
level=LEVEL_READ,
)
assert grant_resp.status_code == 200
# Bob can now create a session with Alice's session-scoped agent.
resp = await auth_client.post(
"/v1/sessions",
json={"agent_id": alice_agent_id},
headers={"X-Forwarded-Email": "bob"},
)
assert resp.status_code == 201, (
f"Expected 201 when Bob has read access to Alice's owning session, "
f"got {resp.status_code}: {resp.text}"
)
async def test_w7_2_session_scoped_agent_requires_owning_session_access(
auth_client: httpx.AsyncClient,
) -> None:
"""Session-scoped agents require the caller to have READ
access to the owning session before binding the agent to a new
session. Without a grant, Bob cannot use Bryan's agent.
"""
agent = await create_test_agent(auth_client, user="bryan")
# Bob tries to create a session with Bryan's agent — should be
# denied because Bob has no access to the owning session.
resp = await auth_client.post(
"/v1/sessions",
json={"agent_id": agent["id"]},
headers={"X-Forwarded-Email": "bob"},
)
assert resp.status_code in (403, 404), (
f"Expected 403/404 for session-scoped agent without access, "
f"got {resp.status_code}: {resp.text}"
)
async def test_create_session_rejects_other_users_host(
host_perm_app: FastAPI,
host_perm_client: httpx.AsyncClient,
db_uri: str,
) -> None:
"""
Creating a session that targets another user's host is rejected
with 403 BEFORE the host is ever contacted.
The create-session workspace validation does a ``host.stat``
round-trip to the target host. Without an ownership check on that
path, a user could probe another user's host filesystem (path
existence / cwd-boundary) just by naming their ``host_id`` — a
cross-user disclosure the launch-time ``resolve_host_launch`` check
would not prevent, since the stat already happened. The shared
``resolve_host_owner`` check must fire first.
The ``detail == "not your host"`` assertion pins the 403 to the
host-owner check (an agent-access rejection would 403 differently),
and the empty ``outbound_queue`` proves the order: a missing check
would have enqueued the stat frame to Alice's connection.
"""
# Alice owns an online host.
_register_online_host(host_perm_app, "host_alice", "alice@example.com")
alice_conn = host_perm_app.state.host_registry.get("host_alice")
assert alice_conn is not None
# A bindable BUILT-IN (template) agent: session_id IS NULL, so any
# user can bind it. Session-scoped agents are owned by one session
# and would block Bob at the agent-access check before the host
# check (see designs/BUILTIN_AGENTS.md); creating a template here
# isolates the host-owner check.
SqlAlchemyAgentStore(db_uri).create(
"ag_builtin_xuser", "builtin-xuser-agent", "ag_builtin_xuser/bundle"
)
# Bob targets Alice's host.
resp = await host_perm_client.post(
"/v1/sessions",
json={
"agent_id": "ag_builtin_xuser",
"host_id": "host_alice",
"workspace": "/tmp",
},
headers={"X-Forwarded-Email": "bob@example.com"},
)
assert resp.status_code == 403, (
f"Bob targeting Alice's host should be 403, got {resp.status_code}: {resp.text}"
)
assert resp.json().get("detail") == "not your host", (
f"403 should come from the host-owner check, got detail={resp.json().get('detail')!r}"
)
# The ownership check fired before the host.stat round-trip: nothing
# was ever enqueued to Alice's host connection.
assert alice_conn.outbound_queue.empty(), (
"Bob's create reached Alice's host (a frame was enqueued) before "
"the ownership check — cross-user host probe."
)
# ── Fork permission isolation ─────────────
async def test_read_only_collaborator_can_fork_and_owns_the_fork(
auth_client: httpx.AsyncClient,
) -> None:
"""A read-only collaborator can fork a shared session; the fork is
owned by the forking user and its permissions are isolated from the
source (source grants not copied, source not mutated, no sidebar
leak)."""
agent = await create_test_agent(auth_client, user="bryan")
# Bryan owns the source (creator auto-gets LEVEL_OWNER); shares read
# with corey.
source_id = (await _create_session_as(auth_client, agent["id"], "bryan"))["id"]
grant = await _grant_permission(
auth_client, source_id, granter="bryan", target_user="corey", level=LEVEL_READ
)
assert grant.status_code == 200
# A read-only collaborator can fork.
fork_resp = await auth_client.post(
f"/v1/sessions/{source_id}/fork",
json={},
headers={"X-Forwarded-Email": "corey"},
)
assert fork_resp.status_code == 201, fork_resp.text
fork_id = fork_resp.json()["id"]
# Fork: corey owns it, bryan has no grant on it.
fork_perms = {
p["user_id"]: p["level"]
for p in (await _list_permissions(auth_client, fork_id, user="corey")).json()
}
assert fork_perms == {"corey": LEVEL_OWNER}
# Source grants unchanged by the fork.
src_perms = {
p["user_id"]: p["level"]
for p in (await _list_permissions(auth_client, source_id, user="bryan")).json()
}
assert src_perms == {"bryan": LEVEL_OWNER, "corey": LEVEL_READ}
# Visibility: corey sees the fork; bryan does not.
assert fork_id in {s["id"] for s in await _list_sessions_as(auth_client, "corey")}
assert fork_id not in {s["id"] for s in await _list_sessions_as(auth_client, "bryan")}
async def test_bob_cannot_create_worktree_session_on_alice_host(
host_perm_app: FastAPI,
host_perm_client: httpx.AsyncClient,
) -> None:
"""
Bob cannot create a git-worktree session on Alice's host.
Distinct from the workspace-only host-owner test above: this one
carries a ``git`` block, so it exercises the worktree-creation
path. Worktree creation writes to the host (``git worktree add``),
and host ownership is checked (in ``_validate_session_workspace``)
BEFORE that path runs. If someone reordered worktree creation
ahead of the ownership gate, a ``host.create_worktree`` frame would
reach Alice's host and the empty-queue assertion below would fail —
a regression the workspace-only test can't catch.
"""
_register_online_host(host_perm_app, "host_alice", "alice@example.com")
alice_conn = host_perm_app.state.host_registry.get("host_alice")
assert alice_conn is not None
# Bob owns an agent he is allowed to bind (passes the agent
# access check), isolating the host-owner check as the rejection.
bob_agent = await create_test_agent(
host_perm_client, name="bob-worktree-agent", user="bob@example.com"
)
resp = await host_perm_client.post(
"/v1/sessions",
json={
"agent_id": bob_agent["id"],
"host_id": "host_alice",
"workspace": "/Users/alice/repo",
"git": {"branch_name": "feature/x"},
},
headers={"X-Forwarded-Email": "bob@example.com"},
)
# 403 from the host-owner check (404 also acceptable if the host
# were hidden); either way, rejected before any worktree write.
assert resp.status_code in (403, 404), (
f"Bob creating a worktree on Alice's host should be 403/404, "
f"got {resp.status_code}: {resp.text}"
)
# Nothing reached Alice's host — no stat, and crucially no
# host.create_worktree (the write path stayed behind the gate).
assert alice_conn.outbound_queue.empty(), (
"Bob's worktree create reached Alice's host before the ownership "
"check — the worktree-write path is not gated."
)
async def test_bob_cannot_clean_up_alice_worktree_via_delete(
host_perm_app: FastAPI,
host_perm_client: httpx.AsyncClient,
db_uri: str,
) -> None:
"""
Bob cannot trigger worktree cleanup on Alice's session.
The delete endpoint's owner check fires before the cleanup branch,
so Bob's ``?delete_branch=true`` is rejected and no
``host.remove_worktree`` frame reaches Alice's host. Alice's
session (and its worktree) survive.
"""
_register_online_host(host_perm_app, "host_alice", "alice@example.com")
alice_conn = host_perm_app.state.host_registry.get("host_alice")
assert alice_conn is not None
# Alice owns a worktree session. Built via the store + an explicit
# owner grant because the public API has no way to set git_branch.
conv_store = SqlAlchemyConversationStore(db_uri)
conv = conv_store.create_conversation(
agent_id=None,
host_id="host_alice",
workspace="/Users/alice/repo-worktrees/feature-x",
git_branch="feature/x",
)
perm = SqlAlchemyPermissionStore(db_uri)
perm.ensure_user("alice@example.com")
perm.grant("alice@example.com", conv.id, LEVEL_OWNER)
resp = await host_perm_client.delete(
f"/v1/sessions/{conv.id}?delete_branch=true",
headers={"X-Forwarded-Email": "bob@example.com"},
)
# 404 (not enumerable) — Bob has no grant on Alice's session.
assert resp.status_code in (403, 404), (
f"Bob deleting Alice's session should be 403/404, got {resp.status_code}: {resp.text}"
)
# No remove_worktree frame reached Alice's host.
assert alice_conn.outbound_queue.empty(), (
"Bob's delete reached Alice's host before the owner check — cross-user worktree cleanup."
)
# Alice's session still exists (Bob's delete didn't go through).
assert conv_store.get_conversation(conv.id) is not None, (
"Bob's rejected delete must not have removed Alice's session."
)
# ── child_sessions: enumeration is gated on parent READ ──────
async def test_list_child_sessions_blocks_cross_user(
auth_client: httpx.AsyncClient,
db_uri: str,
) -> None:
"""
A user without READ on the parent cannot enumerate its child sub-agents.
Regression guard for the missing access check on
``GET /v1/sessions/{id}/child_sessions``: before the fix the route ran
no ``_require_access`` at all, so any authenticated user could read
another user's sub-agent titles, message previews, and
pending-elicitation counts (cross-user data exposure + an existence
oracle). ``user-a`` owns the parent; ``user-b`` (no grant) must get
the existence-hiding 404; the owner still sees the seeded child.
:param auth_client: Permission-enabled test client.
:param db_uri: Per-test SQLite database URI, used to seed the child.
"""
agent = await create_test_agent(auth_client, user="user-a")
s1 = await _create_session_as(auth_client, agent["id"], "user-a")
parent_id = s1["id"]
# Seed one sub-agent child under user-a's parent — direct store write,
# mirroring spawn._spawn_one minus the workflow start / SSE publish.
conv_store = SqlAlchemyConversationStore(db_uri)
child = conv_store.create_conversation(
kind="sub_agent",
title="researcher:auth",
parent_conversation_id=parent_id,
)
# user-b holds no grant on the parent → 404, not 403, so existence
# isn't leaked. The pre-fix bug returned 200 with the full child listing.
denied = await auth_client.get(
f"/v1/sessions/{parent_id}/child_sessions",
headers={"X-Forwarded-Email": "user-b"},
)
assert denied.status_code == 404, (
f"user-b without a grant must get the existence-hiding 404; got "
f"{denied.status_code}: {denied.text}. A 403 leaks that the parent "
f"exists, and a 200 means the enumeration hole is open."
)
# The denial must leak nothing — neither the child id nor its title.
assert child.id not in denied.text and "researcher:auth" not in denied.text, (
f"a denied response must not leak child data; got {denied.text}"
)
# The owner still lists the child end-to-end — the fix didn't break the
# happy path, and this is exactly the data user-b was denied.
owned = await auth_client.get(
f"/v1/sessions/{parent_id}/child_sessions",
headers={"X-Forwarded-Email": "user-a"},
)
assert owned.status_code == 200, f"owner list failed: {owned.text}"
child_ids = [row["id"] for row in owned.json()["data"]]
assert child.id in child_ids, (
f"owner should see the seeded child {child.id!r}, got {child_ids}"
)
async def test_list_child_sessions_allows_read_grant(
auth_client: httpx.AsyncClient,
db_uri: str,
) -> None:
"""
A user granted READ on the parent may enumerate its children.
Confirms the gate is ``LEVEL_READ`` and not over-restricted to
EDIT/OWNER: enumerating children is a read, so a read grant suffices.
:param auth_client: Permission-enabled test client.
:param db_uri: Per-test SQLite database URI, used to seed the child.
"""
agent = await create_test_agent(auth_client, user="user-a")
s1 = await _create_session_as(auth_client, agent["id"], "user-a")
parent_id = s1["id"]
conv_store = SqlAlchemyConversationStore(db_uri)
child = conv_store.create_conversation(
kind="sub_agent",
title="researcher:auth",
parent_conversation_id=parent_id,
)
grant = await _grant_permission(
auth_client, parent_id, granter="user-a", target_user="user-b", level=LEVEL_READ
)
assert grant.status_code == 200, f"grant failed: {grant.text}"
resp = await auth_client.get(
f"/v1/sessions/{parent_id}/child_sessions",
headers={"X-Forwarded-Email": "user-b"},
)
assert resp.status_code == 200, (
f"a READ grant must allow child enumeration; got {resp.status_code}: "
f"{resp.text}. A 403 means the gate is over-restricted (EDIT/OWNER)."
)
child_ids = [row["id"] for row in resp.json()["data"]]
assert child.id in child_ids, (
f"read-granted user-b should see the seeded child {child.id!r}, got {child_ids}"
)
# ── SSE stream presence (who-is-viewing circles) ─────────────
#
# The ASGI in-process transport buffers streaming responses, so these
# tests never iterate a live SSE body. Instead: the stream request
# runs as a background task (the route generator executes eagerly
# inside it), presence effects are observed through a real
# ``session_stream`` collector — the same pub/sub the route publishes
# to — and the stream is terminated via ``session_stream.close`` /
# task cancellation, after which the buffered body (including the
# snapshot-on-connect frames) becomes readable.
async def _end_stream_via_close(session_id: str, task: asyncio.Task[Any]) -> httpx.Response:
"""
Terminate a buffered SSE stream request and return its response.
Repeatedly broadcasts end-of-stream — ``close`` only reaches
subscribers whose slot is already registered, and the stream task
may not have subscribed yet — until the request task completes.
:param session_id: The streamed session, e.g. ``"conv_abc123"``.
:param task: The background ``client.get(...)`` request task.
:returns: The completed (fully buffered) SSE response.
"""
for _ in range(200):
session_stream.close(session_id)
if task.done():
break
await asyncio.sleep(0.01)
return await asyncio.wait_for(task, 2.0)
def _sse_presence_events(body: str) -> list[dict[str, Any]]:
"""
Parse ``session.presence`` frames out of a raw SSE body.
:param body: The buffered ``text/event-stream`` payload.
:returns: Decoded presence event dicts, in wire order.
"""
events: list[dict[str, Any]] = []
for line in body.splitlines():
if not line.startswith("data: ") or line == "data: [DONE]":
continue
payload = json.loads(line[len("data: ") :])
if payload.get("type") == "session.presence":
events.append(payload)
return events
async def test_stream_presence_join_broadcast_and_snapshot(
auth_client: httpx.AsyncClient,
) -> None:
"""Opening the stream registers the viewer, broadcasts the join to
co-subscribers, and the stream's own snapshot-on-connect carries the
full viewer list — with the ``idle`` query param applied."""
agent = await create_test_agent(auth_client, user="alice@example.com")
session_id = (await _create_session_as(auth_client, agent["id"], "alice@example.com"))["id"]
collector = await start_session_stream_collector(session_id)
task = asyncio.create_task(
auth_client.get(
f"/v1/sessions/{session_id}/stream?idle=true",
headers={"X-Forwarded-Email": "alice@example.com"},
)
)
try:
# The join broadcast reaches an already-subscribed co-viewer
# (the collector). Wrong/missing viewers here means the route
# never registered the stream with the presence registry, or
# dropped the idle query param on the floor.
join = await collector.next_event()
assert join["type"] == "session.presence"
assert [v["user_id"] for v in join["viewers"]] == ["alice@example.com"]
assert join["viewers"][0]["idle"] is True
resp = await _end_stream_via_close(session_id, task)
assert resp.status_code == 200
# The buffered body holds the snapshot-on-connect frames: the
# presence snapshot must list the connecting viewer themself.
# An empty list here means the ``_resource_snapshot`` append is
# missing — joiners would see nobody until the next edge.
snapshots = _sse_presence_events(resp.text)
assert snapshots, f"no session.presence frame in stream body: {resp.text[:500]}"
assert [v["user_id"] for v in snapshots[0]["viewers"]] == ["alice@example.com"]
assert snapshots[0]["viewers"][0]["idle"] is True
finally:
task.cancel()
await asyncio.gather(task, return_exceptions=True)
await collector.stop()
async def test_stream_disconnect_broadcasts_leave_after_grace(
auth_client: httpx.AsyncClient,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Dropping the stream (client disconnect) drives the generator's
``finally`` → presence deregistration → grace → leave broadcast."""
monkeypatch.setattr(presence, "_LEAVE_GRACE_S", 0.05)
agent = await create_test_agent(auth_client, user="alice@example.com")
session_id = (await _create_session_as(auth_client, agent["id"], "alice@example.com"))["id"]
collector = await start_session_stream_collector(session_id)
task = asyncio.create_task(
auth_client.get(
f"/v1/sessions/{session_id}/stream",
headers={"X-Forwarded-Email": "alice@example.com"},
)
)
try:
join = await collector.next_event()
assert [v["user_id"] for v in join["viewers"]] == ["alice@example.com"]
# Default idle (no query param) is active — a True here means
# the route invented an idle state for a plain connect.
assert join["viewers"][0]["idle"] is False
# Cancel the request: with the in-process transport the app
# coroutine runs inside this task, so once the gather returns
# the generator's ``finally`` (and presence.disconnect) has run.
task.cancel()
await asyncio.gather(task, return_exceptions=True)
# The leave lands only after the grace timer fires. No event =
# the finally never deregistered (ghost viewers forever).
leave = await collector.next_event()
assert leave["type"] == "session.presence"
assert leave["viewers"] == []
# Top-level session: the presence scope (root) is the session itself.
assert presence.snapshot(session_id, session_id)["viewers"] == []
finally:
await collector.stop()
async def test_stream_local_single_user_not_tracked(
local_auth_client: httpx.AsyncClient,
) -> None:
"""A single-user request with no identity falls back to the reserved
``local`` user, which presence must NOT track (same attribution rule
as ``created_by`` on messages).
Uses ``local_auth_client`` (``local_single_user=True``) — the only
posture in which a missing ``X-Forwarded-Email`` resolves to
``local`` instead of being rejected. Default header mode fails closed
on missing identity, so ``auth_client``
would 401 here and never exercise the presence attribution filter.
"""
agent = await create_test_agent(local_auth_client, user=None)
session_id = (await _create_session_as(local_auth_client, agent["id"], None))["id"]
task = asyncio.create_task(local_auth_client.get(f"/v1/sessions/{session_id}/stream"))
try:
resp = await _end_stream_via_close(session_id, task)
assert resp.status_code == 200
# The stream's own snapshot-on-connect ran AFTER any (buggy)
# registration would have happened, so a "local" viewer in it
# proves the attribution filter was dropped from the route.
snapshots = _sse_presence_events(resp.text)
assert snapshots, f"no session.presence frame in stream body: {resp.text[:500]}"
assert snapshots[0]["viewers"] == []
# Top-level session: the presence scope (root) is the session itself.
assert presence.snapshot(session_id, session_id)["viewers"] == []
finally:
task.cancel()
await asyncio.gather(task, return_exceptions=True)
async def test_stream_presence_spans_subagent_conversations(
auth_client: httpx.AsyncClient,
db_uri: str,
) -> None:
"""Viewers of a sub-agent page appear in the root page's presence
(and vice versa).
Regression test for tree-scoped presence: the web's sub-agent page
opens the CHILD conversation's stream, and pre-fix presence was
keyed by the streamed conversation id — so two users on the same
session but different agents never saw each other's circles.
:param auth_client: Permission-enabled test client.
:param db_uri: Per-test SQLite database URI, used to seed the child.
"""
agent = await create_test_agent(auth_client, user="alice@example.com")
parent_id = (await _create_session_as(auth_client, agent["id"], "alice@example.com"))["id"]
# Seed one sub-agent child under Alice's session — direct store
# write, mirroring spawn._spawn_one minus workflow start.
conv_store = SqlAlchemyConversationStore(db_uri)
child = conv_store.create_conversation(
kind="sub_agent",
title="researcher:auth",
parent_conversation_id=parent_id,
)
grant = await _grant_permission(
auth_client,
parent_id,
granter="alice@example.com",
target_user="bob@example.com",
level=LEVEL_READ,
)
assert grant.status_code == 200, f"grant failed: {grant.text}"
root_collector = await start_session_stream_collector(parent_id)
child_collector = await start_session_stream_collector(child.id)
alice_task = asyncio.create_task(
auth_client.get(
f"/v1/sessions/{parent_id}/stream",
headers={"X-Forwarded-Email": "alice@example.com"},
)
)
try:
join = await root_collector.next_event()
assert join["type"] == "session.presence"
assert [v["user_id"] for v in join["viewers"]] == ["alice@example.com"]
# Bob opens the SUB-AGENT conversation's stream only after
# Alice's join landed, so the next root-stream event is
# deterministically Bob's join.
bob_task = asyncio.create_task(
auth_client.get(
f"/v1/sessions/{child.id}/stream",
headers={"X-Forwarded-Email": "bob@example.com"},
)
)
try:
# Alice's root stream learns of Bob. Pre-fix this never
# fired: Bob's registration lived under the child id and
# the root stream got no presence event at all.
both = await root_collector.next_event()
assert both["type"] == "session.presence"
assert both["conversation_id"] == parent_id
assert [v["user_id"] for v in both["viewers"]] == [
"alice@example.com",
"bob@example.com",
]
# Bob's child stream gets the SAME tree-wide list, stamped
# with the child id his client guards incoming events by.
child_event = await child_collector.next_event()
assert child_event["type"] == "session.presence"
assert child_event["conversation_id"] == child.id
assert [v["user_id"] for v in child_event["viewers"]] == [
"alice@example.com",
"bob@example.com",
]
finally:
bob_task.cancel()
await asyncio.gather(bob_task, return_exceptions=True)
finally:
alice_task.cancel()
await asyncio.gather(alice_task, return_exceptions=True)
await root_collector.stop()
await child_collector.stop()