feat(widget): conversation thread list — list, new, switch, auto-title - #59
Conversation
|
PR #59 The thread-list feature looks good, but I see a few identity and conversation-ownership issues that should be addressed before merging. First, the active conversation ID is stored using only the endpoint. It should also be scoped by Second, The backend should load the conversation session, validate that it belongs to the authenticated user, and derive the user identity from the session rather than trusting a The new conversation-list endpoint also accepts I would request changes here before merging, especially because user identity can affect tool access and authorization. |
2093eda to
e4705b0
Compare
eaaea03 to
0c6ed9f
Compare
|
All three points were right — fixed in 1. Active conversation id scoped only by endpoint. Correct, and it's a real cross-user leak on a shared browser (or when a host app signs a new user in via 2. session = await self._require(conversation_id)
if session.user_id and user_id and user_id != session.user_id:
raise ConversationAccessDenied(conversation_id)
user_id = session.user_id or user_idSo a caller that sends no 3. New tests: a turn with no client Rebased on top of the updated #58. |
… user Review feedback on extra-org#59, all three points: 1. The widget remembered the active conversation under a key built from the endpoint alone, so a second user on the same browser (shared machine, or a host app signing someone else in via `<agent-chat user="...">`) resumed the previous user's chat. The key is now `agent-chat:<endpoint>:<userId>`. 2. `user_id` was sent when a conversation was created but not on send/stream, so the run executed with `RunContext.user_id = None` — exactly the field hooks and tools authorize on. The service now loads the session and takes the identity from it: a caller that omits user_id runs as the session's owner, and one that supplies a *different* user_id is refused with ConversationAccessDenied (403) instead of being silently rebound. The browser no longer decides who it is on a turn. 3. `GET /conversations?user_id=` stays a scoping parameter, not an authorization boundary — now stated in the route's docstring, with the place to put real auth. `_require` returns the session it already had to fetch, so the ownership check costs no extra query. Tests: a turn with no client user_id runs as the owner; a mismatched user_id raises and persists nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Thanks for addressing the previous feedback. The However, I think the main authorization issue is still only partially resolved. The current check rejects a request only when the client explicitly sends a different if session.user_id and user_id and user_id != session.user_id:
raise ConversationAccessDenied(...)But the widget does not send user_id = session.user_id or user_idBecause I think ownership should be validated against a trusted authenticated principal derived by middleware or a dependency, rather than against an optional browser-supplied The same validation should apply to:
The browser-generated user ID can still be useful for anonymous session scoping, but it should not be treated as an authorization boundary. So I would still hold off on merging until the session owner is compared against a trusted caller identity, or until these routes are explicitly separated and documented as anonymous-only APIs. |
|
You're right, and the hole was wider than the The problem was that the caller's identity had three possible sources (request body, query string, or nothing at all) and the "nothing at all" branch fell back to the owner. So the fix was to give it exactly one source and make every route authorize against it. Trusted principal via a dependency. CALLER_HEADER = "X-Agent-Chat-User"
def get_caller_id(request: Request) -> str | None:
return request.headers.get(CALLER_HEADER)Being the single source is what makes it replaceable. A deployment with real auth overrides this one dependency with a principal derived from a verified credential, and all five routes tighten with it: app.dependency_overrides[get_caller_id] = my_authenticated_principalOwnership checked, not inherited. async def _authorize(self, conversation_id: str, caller_id: str | None) -> ConversationSession:
session = await self._require(conversation_id)
if session.user_id != caller_id:
raise ConversationAccessDenied(conversation_id)
return sessionExact means an owned conversation is unreachable both by a different caller and by an anonymous one — the case you identified, where omitting the id was the way in. The turn still runs as the session owner, so Anonymous by default, and documented as such. Your last point stands: the browser-generated id is scoping, not authorization. I did not add authentication itself. There is none in this service today and picking a scheme is a separate decision — this leaves the seam for it. Tests: a non-owner and an anonymous caller both get 403 on all four conversation routes, plus service-level coverage that an unowned caller can't read history or usage and that listing is empty. The old "runs as the session owner when the caller sends no user_id" test is inverted into a refusal, since that behavior was the bug. |
|
Thanks for addressing the authorization issue. The new However, I found another ownership bypass in the conversation creation flow.
class CreateConversationRequest(BaseModel):
session_id: str | None = NoneThe route then passes both the caller identity and the supplied session ID into In row.user_id = user_id if user_id is not None else row.user_idThis means that a caller who knows an existing conversation ID could call: POST /conversations
X-Agent-Chat-User: another-user
{
"session_id": "existing-conversation-id"
}and potentially replace the current owner with themselves. After that, the new There is also inconsistent behavior between the repositories: I think one of the following should happen before merging:
In either case, It would also be useful to add a SQL repository regression test that:
I would still hold off on merging until this ownership reassignment path is closed. |
Review feedback on #59. `POST /conversations` accepts a caller-supplied session_id, and SqlRepository.create_session() overwrote an existing row's user_id from it. Naming a live conversation id was therefore enough to become its owner, after which _authorize() agreed: the takeover left no trace to detect. MemoryRepository returned the existing session untouched, so the API tests — memory-backed — could not see it. Ownership is now set once and never reassigned: - SqlRepository no longer writes user_id on an existing row, and the Repository port states the invariant rather than leaving it to each backend to infer. - ConversationService.create() refuses a session_id owned by anyone else (409 from the route). Re-creating an id you already own still returns it, because agentctl reuses a --session id across runs — the owner re-creating is the normal path, not an attack. Test: a contract test that creates a session as alice, re-creates it as bob, and asserts the owner is unchanged. It runs against both backends, so the divergence that hid this cannot come back — it fails on SQL before this change and passes on memory. Plus API coverage for the 409 and the 403 that follows it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Confirmed and fixed — thanks, this one was the most serious of the three. I reproduced it before fixing: the regression test fails on SQL with Ownership is now set once and never moves (
I took your option 2 rather than removing caller-supplied ids, because re-creating an id you already own is a real path — The regression test is a contract test, run against both backends. A memory-only suite missing a SQL-only vulnerability was the more interesting half of your comment, so Your comment made me audit the rest of the flow, and it found three more (
Also on the widget side: it now treats One structural change ( Every fix above was verified by reverting it and watching the test fail, not just by a green suite. 555 pytest, 16 Playwright, CI green. Two things I deliberately left alone: |
Let a user see their past conversations, start a new one, and switch between them, backed by the agent manager as the source of truth. Backend: - Repository.list_sessions(user_id) + rename_session (port, memory, sql) - GET /conversations?user_id=... returns id, title, last_message_at (most-recently-active first); ConversationService.list_conversations - thread_title() derives a title from the first user message; the service sets it on the first turn of a conversation Widget (stateless; BE owns the list and titles): - anonymous per-browser user id in localStorage, sent on create/list; an optional <agent-chat user="..."> attribute overrides it, leaving the seam for real host identity later - AgentChatClient.listConversations + useConversation listThreads/ switchTo/startNew - header history + new-chat buttons and a slide-over thread drawer; switching loads that thread's messages and usage; drawer is inert when closed. Degrades to empty list against a backend without the endpoint Tests: listing scoped by user, auto-title, and a widget e2e for the drawer (list, switch, new chat). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… user Review feedback on #59, all three points: 1. The widget remembered the active conversation under a key built from the endpoint alone, so a second user on the same browser (shared machine, or a host app signing someone else in via `<agent-chat user="...">`) resumed the previous user's chat. The key is now `agent-chat:<endpoint>:<userId>`. 2. `user_id` was sent when a conversation was created but not on send/stream, so the run executed with `RunContext.user_id = None` — exactly the field hooks and tools authorize on. The service now loads the session and takes the identity from it: a caller that omits user_id runs as the session's owner, and one that supplies a *different* user_id is refused with ConversationAccessDenied (403) instead of being silently rebound. The browser no longer decides who it is on a turn. 3. `GET /conversations?user_id=` stays a scoping parameter, not an authorization boundary — now stated in the route's docstring, with the place to put real auth. `_require` returns the session it already had to fetch, so the ownership check costs no extra query. Tests: a turn with no client user_id runs as the owner; a mismatched user_id raises and persists nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A caller that knew a conversation id inherited its owner's identity: prepare_turn resolved `session.user_id or user_id`, and the widget never sent a user id on send or stream. That identity becomes RunContext.user_id, which hooks and tools authorize on, so an unauthenticated caller could act as the session owner. Reading messages and token usage checked ownership not at all. Caller identity now has exactly one source — the `X-Agent-Chat-User` header, read by the `get_caller_id` dependency — and every conversation route authorizes against it: list, history, usage, send, stream. No route reads a user id from a request body or query string anymore. Ownership is an exact match, so an owned conversation is unreachable by a different caller *and* by an anonymous one; the turn still runs as the session owner, but only the owner can get there. The header is unverified by design: the widget generates the id and stores it in localStorage, which scopes conversations without proving anything. Being the single source of identity is what makes it replaceable — a deployment that needs a real boundary overrides `get_caller_id` with a principal derived from a verified credential and every route tightens with it. Documented as anonymous-until-overridden in docs/api.mdx. Also folds the widget client's repeated fetch + status check into one `request()` helper, so no call site can omit the identity header. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A stray space in `X-Agent- Chat-User` meant the request carried no caller id, so the listing was correctly empty and the assertion failed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review feedback on #59. `POST /conversations` accepts a caller-supplied session_id, and SqlRepository.create_session() overwrote an existing row's user_id from it. Naming a live conversation id was therefore enough to become its owner, after which _authorize() agreed: the takeover left no trace to detect. MemoryRepository returned the existing session untouched, so the API tests — memory-backed — could not see it. Ownership is now set once and never reassigned: - SqlRepository no longer writes user_id on an existing row, and the Repository port states the invariant rather than leaving it to each backend to infer. - ConversationService.create() refuses a session_id owned by anyone else (409 from the route). Re-creating an id you already own still returns it, because agentctl reuses a --session id across runs — the owner re-creating is the normal path, not an attack. Test: a contract test that creates a session as alice, re-creates it as bob, and asserts the owner is unchanged. It runs against both backends, so the divergence that hid this cannot come back — it fails on SQL before this change and passes on memory. Plus API coverage for the 409 and the 403 that follows it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Follow-up audit of the ownership fix, before asking for another review. append_message also wrote session ownership — SqlRepository filled a null user_id from the message, MemoryRepository did the same one line over. Unreachable through the API now that _authorize runs first, but it is the same latent shape as the create_session bug, so both are gone and a contract test pins it for every backend. Nothing depended on it. create() no longer pre-checks with get_session: two callers racing for one id could both pass the check. The repository is the authority, so the loser is now told the name is taken instead of being handed the winner's conversation. An empty X-Agent-Chat-User header owned conversations under the id "", which list_conversations treats as no caller — the conversation existed but no listing could ever reach it. Empty and absent now both mean anonymous. The header is also bounded at 64 characters, the width of the column it becomes. The widget treats 403 like 404 on a stored conversation: after an override makes the caller id a real principal, a stored id outlives the identity that created it, and the old code would retry it forever instead of starting a fresh thread. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The status mapping was copy-pasted into eleven except clauses, which is how list_messages and get_usage came to answer no 403 at all: adding a route meant remembering every refusal by hand. One table plus a context manager now decides, so a route opts into the whole mapping or none of it. routes.py loses ~30 lines and both message routes keep their exact statuses. Comments that restated the code are gone. What remains explains what code cannot: why a magic 64 matches a column, why the create check runs after the write rather than before, and two places where the *absence* of a user_id assignment is the invariant. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Thanks for fixing the ownership reassignment issue. The ownership invariant is now much stronger: existing sessions no longer change owners, message writes cannot claim a conversation, and foreign reuse is mapped to However, I do not think the concurrent creation case is fully handled yet. The current SQL flow still does: row = await session.get(ConversationSessionRow, sid)
if row is None:
row = ConversationSessionRow(...)
session.add(row)With two truly concurrent requests for the same One transaction will win, while the other will likely fail with a primary-key In that case, if session.user_id != user_id:
raise ConversationAlreadyExists(session.session_id)The losing request may therefore return The test named I think the SQL repository should make this operation atomic, for example by using:
Then the service can reliably compare the returned owner:
Please also add a real SQL concurrency regression test using two separate database sessions and concurrent calls, such as
You can paste the prompt below into your coding agent to implement the fix. Prompt for the agent: Fix the remaining concurrent conversation-creation race in PR #59. Problem
row = await session.get(ConversationSessionRow, sid)
if row is None:
row = ConversationSessionRow(...)
session.add(row)Two concurrent callers using the same One insert succeeds. The other can fail with a primary-key That exception currently bypasses the domain-level ownership check in The existing Required behaviorConversation creation must be atomic and preserve these invariants:
ImplementationUpdate Prefer one of these approaches: Option A: database upsertUse the appropriate SQLAlchemy dialect insert:
After the insert attempt, load the session row by Do not update Option B: handle duplicate-key failureAttempt the insert and flush it inside the transaction. If a uniqueness or primary-key
Do not broadly catch unrelated integrity errors. Only translate the duplicate-session conflict. Service behaviorKeep the ownership comparison in session = await repository.create_session(...)
if session.user_id != user_id:
raise ConversationAlreadyExists(session.session_id)This should remain the authoritative decision:
TestsAdd a real SQL concurrency regression test. The test must:
Avoid a test where one creation completes before the second starts; that does not reproduce the race. Also keep the existing repository contract tests proving that:
Run the full lint, typecheck, pytest, widget checks, and relevant API tests after the change. |
f323b99 to
3bbdfc5
Compare
Review feedback on #59. create_session() read the row and then inserted it, so two callers naming one id could both find nothing and both insert. The loser's flush hit the primary key and the IntegrityError escaped the repository, past the ownership check in ConversationService.create() that turns a foreign id into a 409 — the loser got a 500 instead. The insert now runs in its own transaction and a duplicate-key failure is translated: reload the id and return the winner's session, which the service compares against the caller as before. If the reload finds nothing, the conflict was not a duplicate id — a bad user_id, say — and it is re-raised rather than swallowed. Dialect-agnostic, so SQLite and Postgres behave alike without dialect-specific upserts. Tests: a new SQL concurrency module races two repositories over one on-disk database with asyncio.gather — in-memory is a single shared connection and races nothing. It covers the repository (one owner, no IntegrityError), the service (exactly one caller refused, ownership unmoved afterwards), and the API (409, not a 500). The API case needs two app instances, the way two replicas would be: one app serialises the requests and never reaches the race. All three fail before this change. Deletes test_create_refuses_an_id_that_lost_the_race, which raced nothing and was a weaker copy of the test above it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review feedback on #59. create_session() read the row and then inserted it, so two callers naming one id could both find nothing and both insert. The loser's flush hit the primary key and the IntegrityError escaped the repository, past the ownership check in ConversationService.create() that turns a foreign id into a 409 — the loser got a 500 instead. The insert is now INSERT ... ON CONFLICT DO NOTHING, followed by reading the row back: whoever won owns it, both callers agree on the result, and nobody sees a duplicate-key error. The database arbitrates in one statement rather than the application recovering from a failure, so a routine concurrent create no longer logs an error or leaves an aborted transaction behind, and there is no chance of mistaking an unrelated integrity failure — the foreign key on user_id, say — for this one. SQLAlchemy has no portable upsert, so the statement is chosen per dialect; both supported backends have it. Tests: a new SQL concurrency module races two repositories over one on-disk database with asyncio.gather — in-memory is a single shared connection and races nothing. It covers the repository (one owner, no IntegrityError), the service (exactly one caller refused, ownership unmoved afterwards), and the API (409, not a 500). The API case needs two app instances, the way two replicas would be: one app serialises the requests and never reaches the race. All three fail before this change. Deletes test_create_refuses_an_id_that_lost_the_race, which raced nothing and was a weaker copy of the test above it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
3bbdfc5 to
3529c3f
Compare
Review feedback on #59. create_session() read the row and then inserted it, so two callers naming one id could both find nothing and both insert. The loser's flush hit the primary key and the IntegrityError escaped the repository, past the ownership check in ConversationService.create() that turns a foreign id into a 409 — the loser got a 500 instead. The insert now runs inside a savepoint. Losing the id rolls back that statement alone, leaves the transaction usable, and the winner's row is read back — so both callers agree and neither sees a duplicate-key error. Absorbing the failure is conditional on the id actually being taken: an integrity error that leaves no row to find is something else, the user_id foreign key say, and is re-raised rather than swallowed. A savepoint rather than a dialect-specific ON CONFLICT: it keeps the ORM model as the single description of the row, needs no backend conditionals, and both supported dialects implement it. Verified as a real SAVEPOINT / ROLLBACK TO SAVEPOINT round trip, not a silent no-op. Tests: a new SQL concurrency module races repositories over one on-disk database with asyncio.gather — in-memory is a single shared connection and races nothing. It covers the repository (one owner, no IntegrityError), the service (exactly one caller refused, ownership unmoved afterwards), the API (409, not a 500), and that an unrelated integrity error still propagates. The API case needs two app instances, the way two replicas would be: one app serialises the requests and never reaches the race. The first three fail before this change. Deletes test_create_refuses_an_id_that_lost_the_race, which raced nothing and was a weaker copy of the test above it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
3529c3f to
ee9c003
Compare
Review feedback on #59. create_session() read the row and then inserted it, so two callers naming one id could both find nothing and both insert. The database always picked a winner — data was never at risk — but the loser's IntegrityError escaped the repository, past the ownership check in ConversationService.create() that turns a foreign id into a 409. A caller who asked for a taken name got a 500, and the logs recorded a crash for what is ordinary traffic. The insert now runs inside a savepoint, so losing the id rolls back that statement alone and leaves the transaction usable; the winner's row is then read back and the service compares owners as before — same owner is idempotent, a different one is refused. Absorbing the failure is conditional on the id actually being taken: an integrity error that leaves no row to find is something else, the user_id foreign key say, and is re-raised. A savepoint rather than a dialect-specific ON CONFLICT: the ORM model stays the single description of a row, there are no backend conditionals, and both supported dialects implement it. Verified as a real SAVEPOINT / ROLLBACK TO SAVEPOINT round trip, not a silent no-op. Tests: a SQL concurrency module races repositories over one on-disk database with asyncio.gather — in-memory is a single shared connection and races nothing. It covers the repository (one owner, no IntegrityError), the API (409, not a 500 — which is also the proof that the loser got ConversationAlreadyExists, the only route to that status), and that an unrelated integrity error still propagates. The API case needs two app instances, the way two replicas would be: one app serialises the requests and never reaches the race. Deletes test_create_refuses_an_id_that_lost_the_race, which raced nothing and was a weaker copy of the test above it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ee9c003 to
506d21d
Compare
|
You're right, and I reproduced it before fixing: two repositories over one on-disk SQLite with Worth stating what was and wasn't at stake, since it shaped the fix: the database always picked exactly one winner, so the data was never at risk. What was wrong is what the loser was told — a 500 for asking after a taken name, plus a logged crash for what is ordinary traffic. The insert now runs inside a savepoint: created = ConversationSessionRow(...)
try:
async with session.begin_nested():
session.add(created)
row = created
except IntegrityError:
row = await session.get(ConversationSessionRow, sid)
if row is None:
raiseThe savepoint is the part that matters. A plain I went with the savepoint over your Option A ( The catch is deliberately narrow, per your note about not swallowing unrelated failures: it is absorbed only if the id turns out to be taken. An integrity error that leaves no row to find — the Tests — a new SQL concurrency module, on-disk because an in-memory database is a single shared connection and races nothing:
I did not add a separate service-level assertion: 409 is reachable only through Also deleted On keeping caller-supplied |
|
Thanks for fixing the actual concurrent insert race. The savepoint approach is sound, and the new SQL concurrency tests now exercise the real failure mode with separate connections and concurrent requests. The However, I found another issue in The repository contract now says that when a session ID already exists, the existing session should be returned unchanged. existing = self._sessions.get(sid)
if existing is not None:
return existingBut the SQL implementation still updates fields on an existing session: elif any(
value is not None
for value in (system_name, config_path, title, metadata, expires_at)
):
row.system_name = ...
row.config_path = ...
row.title = ...
row.metadata_json = ...
row.expires_at = ...This is both a repository inconsistency and an authorization concern.
session = await self._repository.create_session(...)
if session.user_id != user_id:
raise ConversationAlreadyExists(session.session_id)So a caller can submit an existing The caller does not gain ownership, but the foreign session has already been mutated. This is especially relevant if multiple agent systems or configurations share the same conversation database. A rejected create request could still rebind an existing session to the current application’s system or config. It also means same-owner recreation is not truly idempotent in SQL, even though it is idempotent in memory. I think row = await session.get(ConversationSessionRow, sid)
if row is not None:
return _session(row)The existing-session update branch should be removed completely. Fields such as title, metadata, configuration, or expiration should only be changed through explicit update methods that perform their own authorization, such as the existing Please also extend the repository contract tests to run against both memory and SQL:
The concurrency issue is now fixed, but I would still hold off on merging until existing-session creation is side-effect free and both repository implementations follow the same contract. |
Review feedback on #59. SqlRepository.create_session() updated an existing row's system_name, config_path, title, metadata and expires_at, where MemoryRepository returned the row untouched. Two backends, two contracts — and the SQL one had a side effect a rejected request could reach. ConversationService.create() passes system_name and config_path on every call and only compares owners afterwards, so naming a live conversation rewrote it before the 409 came back. The caller gained no ownership, but a session belonging to someone else had been rebound to the caller's system and config. That is worst where several agent systems share one conversation database, which is exactly where a shared id is plausible. Creation fields describe a session being born, so a taken id now writes nothing at all and the port says so. Later changes keep going through the explicit operations — rename_session and the rest — which answer for their own authorization. Tests: the contract gains a case that creates a fully populated session and re-creates it with every field different, asserting the stored row is identical; it runs on both backends and fails on SQL before this change. An API test models the shared-database case: alice creates under system-a, bob names her id under system-b, and after the 409 her complete row is unchanged. The SQL module is renamed to match what it now covers — creation, not only concurrency. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Right on both counts — the divergence between the backends, and the side effect a rejected request could reach. Fixed in The part I had missed is that
row = await session.get(ConversationSessionRow, sid)
if row is not None:
return _session(row)The update branch is gone. The port states the rule rather than leaving it to each backend to infer — creation fields describe a session being born, so a taken id writes nothing at all, and later changes go through the explicit operations ( Tests, both of the cases you listed:
I renamed the SQL module from Nothing depended on the old update behaviour — |
Stack 2/3 · base:
feat/widget-context-usage(#58)Adds a conversation thread drawer to the widget: list past chats, start a new one, and switch between them. Thread identity is anonymous per-browser (
crypto.randomUUID()in localStorage, scoped per endpoint and per user) with an<agent-chat user="...">seam for host-app identity. Titles are auto-generated on the backend from the first user message (thread_title), keeping the FE a pure pipe.Backend adds
list_sessions/rename_sessionto the repository port (memory + SQL adapters) andGET /conversations?user_id. A turn's identity is taken from the stored session rather than from the browser: a caller that omitsuser_idruns as the session's owner, one that sends a differentuser_idgets a 403 (review feedback,0c6ed9f).Verified:
make lint+mypyclean, tests + widget typecheck/e2e green.🤖 Generated with Claude Code