Repository navigation
One-click authorize; bind agent identity to the OAuth grant - #7
Conversation
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
rapbattle | a830380 | Sep 01 2026, 09:41 PM |
The consent screen asked for an agent name and voice before authorization completed, and inserted an agents row as a side effect. Nothing ever read the props that row was announced through, so the client was never told its id: agents called register_agent and got a second identity, and every consent leaked a dead row. Consent is now a single Authorize button that writes nothing. The grant carries an opaque subject, kept stable per browser by an rb_subject cookie so re-consenting does not strand an agent that already registered. register_agent binds the new row to that subject and is idempotent. That makes identity knowable server-side, which closes a hole: tools read agent_id straight from the arguments and only checked the row existed, so any token could act as any agent whose UUID it knew — and UUIDs are readable from get_leaderboard, list_battles, list_intros and get_battle. resolveCaller now resolves the caller from the token and treats a supplied agent_id as an assertion to verify, not an identity to trust. agent_id is optional everywhere as a result. Agents predating this keep working: a grant carrying agentId and no subject adopts its row once under legacy:<id>. Rows with a NULL owner_subject — legacy agents and the Rift house agent — stay unclaimable and read-only, so a leaked UUID cannot be used to seize one. The /admin seed path is untouched. Reading the API surface's props requires it to be a WorkerEntrypoint, so apiHandler became one; defaultHandler and the unauthenticated consent screen are unchanged. Refs #3
Rebasing onto #6 merged cleanly by line, and was wrong. The transport call site still passed `undefined` where the entrypoint now has the grant's props in scope, so resolveCaller saw no subject and every tool call over JSON-RPC came back "Unauthenticated. Reconnect over MCP OAuth." Clients would have connected and then failed at everything, and it typechecked, so nothing would have flagged it. Confirmed against a local instance both ways: with `undefined` set_voice returns the auth error, with `props` it resolves the caller from the token and succeeds.
7a2c9c9 to
a830380
Compare
|
Rebased onto The rebase merged cleanly by line and was still wrong. #6 added the JSON-RPC transport call site, and this PR turns the handler into a const props = (this.ctx as ...).props; // identity, right here
...
return handleMcpPost(request, env, undefined, origin); // and discarded
Confirmed both directions against a local instance:
Also verified end-to-end through the transport with no Worth knowing for #8, which is still queued: it does not touch |
Addresses #3 — "We should just authorize the account. We can't pick a name and voice unless the authorization goes through."
The problem
The consent screen asked for an agent name and voice before authorization completed, and inserted an
agentsrow as a side effect. But nothing ever read the props that row was announced through —src/index.tsdiscarded the execution context, andhandleToolCalldeclared the id as_agentIdand never used it.So the client was never told the id that had just been created for it. Agents called
register_agent, got a second identity, and every consent leaked a dead row.What changed
Consent is now a single Authorize button that writes nothing. The grant carries an opaque subject, kept stable per browser by an
rb_subjectcookie so re-consenting does not strand an agent that already registered.register_agentbinds the new row to that subject and is idempotent.This also closes a security hole
Making identity knowable server-side let me fix something worse that was sitting underneath: identity was a caller-supplied string. Every tool read
agent_idstraight from the arguments and only checked the row existed — so any token holder could act as any agent whose UUID they knew. And UUIDs are freely readable fromget_leaderboard,list_battles,list_introsandget_battle.resolveCallernow resolves the caller from the token and treats a suppliedagent_idas an assertion to verify, not an identity to trust. Because the server can now tell who is calling,agent_idbecame optional on every tool.Verified with two independent tokens — token A attempting to act as agent B across
set_voice,submit_intro,submit_feedback,challenge_agentandget_my_engagement_status:All refused, and B's data confirmed unchanged afterwards.
Two things to be aware of before merging
1. Already-connected agents keep working. Live tokens carry
agentIdand nosubject; they adopt their existing row once under alegacy:<id>subject. Without that fallback every connected client would break on its next tool call. MarkedTODO remove after 2026-11-30.2. Legacy agents become read-only relics. Rows with a
NULL owner_subject— pre-existing agents and the Rift house agent — are deliberately unclaimable. Auto-adopting them by name would reopen the same hole, since their UUIDs leak from the read-only tools. Their score, verses and intros persist and still render on/,/leaderboardand/stage, and they remain validopponent_id/callee_idtargets; their operators re-authorize and re-register. Worth aSELECT count(*) FROM agentsagainst remote D1 before deploying to size the blast radius./admin/seed-riftis untouched and still works — every Rift mutation is raw SQL keyed onname = 'Rift'and never goes throughresolveCaller.Verification
Against a local
wrangler devinstance:already_registered: true, count unchangedagent_idomitted entirelyagent_idonce401tsc --noEmitholds at the same 4 pre-existing errors (#5) — none new.Other changes
WorkerEntrypointconversion for the api handler (the only way to read grant props);owner_subjectcolumn added via the existingensureSchemapattern plusschema.sql, with a unique index; onboarding and/connectcopy updated soregister_agentis step one.Note on the other open PR
#6 (the transport fix) is independent and can merge first — it is the actual blocker. Both touch
src/index.tsaround the apiHandler, so whichever lands second needs a small mechanical rebase.🤖 Generated with Claude Code