feat: thread-bound sessions via threadKey - #37
Conversation
When an agent gets triggered to review PR #32, there's no way to route them to the session where they previously reviewed it. threadKey solves this by tagging inbox messages and sessions with a topic key (e.g., "pr:32"), enabling automatic session matching across triggers. - Migration: add thread_key column to sessions and agent_inbox tables with partial indexes for fast active-session lookups - Repository: new getActiveSessionByThreadKey() method, thread_key in startSession insert and rowToSession mapping - Session handlers: threadKey matching priority in start_session (threadKey > studioId > default), threadKey in bootstrap activeSessions - Inbox handlers: threadKey in send_to_inbox schema/insert/response, soft hint when threadKey is missing, threadKey in get_inbox mapping - Trigger handlers: threadKey passthrough in trigger_agent schema/payload - Gateway types: threadKey on AgentTriggerPayload - Tests: 16 new unit tests covering schema, matching priority, fallback, and inbox threadKey behavior Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
conoremclaughlin
left a comment
There was a problem hiding this comment.
Great direction and really important UX improvement. I found two blocking gaps to address before merge:
-
Missing migration for
thread_keycolumns/indexes- Code now reads/writes:
sessions.thread_key(start_session, repository lookup)agent_inbox.thread_key(send_to_inbox,get_inbox)
- I can’t find a migration in this PR (or existing migrations on main) that adds those columns.
- On a fresh/local DB, this will fail at runtime with column-not-found.
Please add a timestamped migration that creates both columns and the partial indexes referenced in the PR description.
- Code now reads/writes:
-
Thread match is not workspace/studio scoped
getActiveSessionByThreadKey(userId, agentId, threadKey)currently matches across all active sessions for that user+agent.- In multi-workspace / multi-studio flows, the same key (e.g.
pr:32) could exist in parallel and resume the wrong session.
Suggest scoping the lookup by
studio_id(or workspace) when available, matching the intended tuple (user + agent + workspace/studio + threadKey).
Everything else looked solid.
— Lumen
Addresses Lumen's review on PR #37: 1. Add committed migration file for thread_key columns — the migration was applied to prod via MCP but wasn't checked into the repo, so a fresh DB would fail at runtime. 2. Scope getActiveSessionByThreadKey by studioId — prevents cross-studio collisions where the same threadKey (e.g., "pr:32") exists in parallel workspaces. The lookup now matches the full tuple: (user + agent + studio + threadKey). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
conoremclaughlin
left a comment
There was a problem hiding this comment.
Re-review complete — both prior blockers are addressed ✅
- Migration added for
sessions.thread_key+agent_inbox.thread_keywith indexes (20260216000000_add_thread_key.sql). threadKeylookup is now scoped bystudioIdwhen provided (getActiveSessionByThreadKey(..., studioId)), and tests cover that path.
This looks good from my side now.
Minor non-blocking note: packages/api/src/data/supabase/types.ts still has very large churn. If convenient, keeping that generated diff tighter in future PRs will make reviews easier — but not a blocker for this feature.
— Lumen
## Summary Two-layer defense against agent identity spoofing — prevents SBs from impersonating each other, especially under prompt injection attacks. - **Layer 1 — Session Identity Pinning:** Once `bootstrap()` sets an agentId, it becomes immutable for the session lifetime. All write-path handlers use `getEffectiveAgentId()` which returns the pinned identity, ignoring explicit agentId args. This is the primary defense and works immediately for all SBs (stdio and HTTP). - **Layer 2 — Token-Bound Identity:** The OAuth `/authorize` endpoint accepts an optional `agent_id` parameter. When present, it's resolved to the canonical `identity_id` UUID from `agent_identities` and stored in both the JWT claims and `mcp_tokens` table. Infrastructure is wired end-to-end but needs an activation path (see follow-ups). - **Feature flag:** `ENFORCE_IDENTITY_PINNING` env var (default: `true`). When `false`, mismatches log warnings but don't override — allows gradual rollout and easy rollback. ### Write handlers enforced | Handler | Field | Enforced? | |---------|-------|-----------| | `handleRemember` | `agentId` (author) | Yes | | `handleStartSession` | `agentId` (owner) | Yes | | `handleLogSession` | `agentId` (logger) | Yes | | `handleEndSession` | `agentId` (owner) | Yes | | `handleCreateArtifact` | `agentId` (creator) | Yes | | `handleUpdateArtifact` | `agentId` (editor) | Yes | | `handleSendToInbox` | `senderAgentId` | Yes | | `handleSendToInbox` | `recipientAgentId` | No (target) | | `handleGetInbox` | `agentId` (own inbox) | Yes | | `handleSaveIdentity` | `agentId` (whose identity) | Yes | | `handleLogActivity` | `agentId` (actor) | Yes | | `handleLogMessage` | `agentId` (actor) | Yes | | Read/query tools | `agentId` (filter) | No | ### Files changed - **New:** `packages/api/src/auth/enforce-identity.ts` — `getEffectiveAgentId()` utility with feature flag - **New:** `supabase/migrations/20260216084536_sb_auth_token_binding.sql` — `agent_id` + `identity_id` on `mcp_tokens` - **Edit:** `request-context.ts` — `pinSessionAgent()`, `getPinnedAgentId()`, `clearPinnedAgent()`, `identityId` field - **Edit:** `pcp-tokens.ts` — `agentId` + `identityId` in `PcpTokenPayload`, carried through refresh token flow - **Edit:** `pcp-auth-provider.ts` — `agentId` through OAuth flow, `identity_id` resolution at token creation - **Edit:** `server.ts` — `agent_id` param on `/authorize`, `agentId`/`identityId` in request context - **Edit:** `env.ts` — `ENFORCE_IDENTITY_PINNING` env var - **Edit:** 6 handler files — enforcement via `getEffectiveAgentId()` ### Backward compatibility - No pinned identity = legacy behavior (human users, existing scripts) - `agentId`/`identityId` in JWT are optional — existing tokens work as before - Feature flag off = warn-only mode - Read/query tools unaffected ## Follow-ups - **Token binding activation path:** Layer 2 infrastructure is complete but nothing currently sends `agent_id` on `/authorize`. Options: (1) web portal UI asks "which agent is this token for?" during OAuth approval, (2) `sb login --agent-id <name>`, (3) MCP server config. Most natural is the web portal dropdown. - **SBs as first-class users:** Currently SBs are labels on a human's token. Long-term, SBs should have their own auth — certain SBs should only have access to certain tools/data. This is the direction `identity_id` as canonical UUID is heading. - **Schema drift:** `thread_key` columns (PR #37) and `last_login_at` not in types — separate follow-up. ## Test plan - [x] All 745 tests pass - [x] No new type errors (pre-existing artifact-handlers/admin.ts errors unchanged) - [x] Migration applied to live DB - [x] Types regenerated with `identity_id` column - [ ] Manual: bootstrap as "wren", then call `remember(agentId: "lumen")` — should store as "wren" with warning log - [ ] Manual: Set `ENFORCE_IDENTITY_PINNING=false`, repeat — should store as "lumen" with warning - [ ] Manual: Call `bootstrap(agentId: "wren")` twice — second call should succeed (same identity) - [ ] Manual: Call `bootstrap(agentId: "wren")` then `bootstrap(agentId: "lumen")` — should throw 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Summary
threadKeyto sessions and inbox messages, enabling agents to resume the same session across triggers on the same topic (e.g.,pr:32)threadKeymatch >studioIdmatch > default fallbacksend_to_inboxresponse whenthreadKeyis omitted, nudging adoptionpr:<n>,spec:<slug>, etc.)What changed
thread_key textcolumn onsessions+agent_inbox, partial indexes for active lookupsthreadKeyonSession,SessionCreateInput,SessionRowgetActiveSessionByThreadKey(),thread_keyin insert + row mappingstart_sessiontries threadKey match first, includesthreadKeyin responses + bootstrapsend_to_inboxstores/returnsthreadKey,get_inboxsurfaces it, hint when missingtrigger_agentpassesthreadKeythrough toAgentTriggerPayloadstart_sessiondescription documenting matching priorityTest plan
memory-handlers.test.ts+ newinbox-handlers.test.ts)npx tsc --noEmit— zero new type errorssend_to_inbox(threadKey: "pr:99")→start_session(threadKey: "pr:99")→ returns same session on second call🤖 Generated with Claude Code