feat(rkd): Allow / Deny buttons on permission prompts #9

Merged
dozken merged 2 commits from feat/1097-permission-buttons into main 2026-09-24 18:09:41 +02:00
Owner

Summary

Ports the permission relay to RKD's new components/v1 buttons (nanoya-biz/rkd#991, server side merged in rkd PR #1099). A channel prompt now carries Allow / Deny buttons; the owner clicks instead of reacting. This is the plugin third of nanoya-biz/rkd#1097.

Part of nanoya-biz/rkd#1097

What it does

  • Prompt with buttons. In a channel the prompt is posted with extras: [{kind: "components", blocks: [Allow, Deny]}] (action ids perm:allow:<request id> / perm:deny:<request id>). Reactions and yes / no keep working there too.
  • AppInteraction. rkd-ws.ts hands clicks to Bridge.handleInteraction and gains ack(interactionId, error?) (the AppAck client event). Only the owner's click decides. Anyone else gets AppAck with "Only can answer this", shown under the button, and nothing runs. The ack goes out before the decision, so a click never waits on Claude Code.
  • Stale clicks fail closed. A click on a prompt this machine no longer holds (already answered, expired after an hour, from before a restart) gets "This request is no longer open". An action id the plugin never posted gets "Claude Code doesn't know this button".
  • Outcome + disabled buttons. However it was answered (button, reaction, reply), a channel prompt is edited with ✅ Allowed / ❌ Denied and the same buttons with disabled: true.
  • DMs unchanged. DM messages carry no extras in v1, so a DM prompt keeps the reaction wording and a text-only edit.
  • Old servers. If the server refuses the extra (400, a server from before components/v1), the prompt is re-sent the old way with the reaction wording. It still reads API version 1, so the version check can't tell these apart.
  • AppCommand. The plugin registers no slash commands. If an operator gives the App one anyway, it is refused at once ("Claude Code has no /x; mention it instead") rather than left to the 10s timeout.

The install needs the interactions scope for clicks to reach the host. Without it the server refuses the click with its own reason, and reacting or replying still works. The README says so. Plugin 0.5.0.

Before / after

Both runs use the real Bridge, RkdApi and RkdSocket against a local RKD server built from rkd main (f4d0020e). Only Claude Code's side is faked: a small driver raises one permission_request when the owner's trigger arrives. BEFORE bundles this repo's origin/main (fee1014); AFTER bundles this branch. Each run has a fresh channel.

BEFORE — origin/main AFTER — this PR

A non-owner (mallory) clicks Allow. The click is refused under the button, the host logs ACK … Only dozken can answer this, and no permission notification is sent:

Recordings:

before.mp4 · after.mp4

Test plan

  • pnpm typecheck
  • pnpm test: 137 tests. New cases: a request answered only once when two answers race (two clicks, a click and a reaction, a repeated yes <id>), the buttons on the prompt, owner Allow / Deny with disabled buttons, refusing a non-owner, stale and unknown clicks, disabled buttons after a reaction, DM edits staying text-only, the 400 fallback, fast AppCommand refusal, and rkd-ws.test.ts for frame parsing and the AppAck shape
  • pnpm check:dist: dist/server.js rebuilt and matches src/
  • node scripts/smoke-handshake.mjs
  • End to end against a local RKD server (screenshots above)
  • Not run: test:integration against a real server with Claude Code in the loop

https://claude.ai/code/session_013PWtUXgidAXrd8w4A1pPKK

## Summary Ports the permission relay to RKD's new `components/v1` buttons (nanoya-biz/rkd#991, server side merged in rkd PR #1099). A channel prompt now carries **Allow** / **Deny** buttons; the owner clicks instead of reacting. This is the plugin third of nanoya-biz/rkd#1097. Part of nanoya-biz/rkd#1097 ## What it does - **Prompt with buttons.** In a channel the prompt is posted with `extras: [{kind: "components", blocks: [Allow, Deny]}]` (action ids `perm:allow:<request id>` / `perm:deny:<request id>`). Reactions and yes / no keep working there too. - **`AppInteraction`.** `rkd-ws.ts` hands clicks to `Bridge.handleInteraction` and gains `ack(interactionId, error?)` (the `AppAck` client event). Only the owner's click decides. Anyone else gets `AppAck` with "Only <owner> can answer this", shown under the button, and nothing runs. The ack goes out before the decision, so a click never waits on Claude Code. - **Stale clicks fail closed.** A click on a prompt this machine no longer holds (already answered, expired after an hour, from before a restart) gets "This request is no longer open". An action id the plugin never posted gets "Claude Code doesn't know this button". - **Outcome + disabled buttons.** However it was answered (button, reaction, reply), a channel prompt is edited with `✅ Allowed` / `❌ Denied` and the same buttons with `disabled: true`. - **DMs unchanged.** DM messages carry no extras in v1, so a DM prompt keeps the reaction wording and a text-only edit. - **Old servers.** If the server refuses the extra (400, a server from before components/v1), the prompt is re-sent the old way with the reaction wording. It still reads API version 1, so the version check can't tell these apart. - **`AppCommand`.** The plugin registers no slash commands. If an operator gives the App one anyway, it is refused at once ("Claude Code has no /x; mention it instead") rather than left to the 10s timeout. The install needs the `interactions` scope for clicks to reach the host. Without it the server refuses the click with its own reason, and reacting or replying still works. The README says so. Plugin 0.5.0. ## Before / after Both runs use the real `Bridge`, `RkdApi` and `RkdSocket` against a local RKD server built from rkd `main` (f4d0020e). Only Claude Code's side is faked: a small driver raises one `permission_request` when the owner's trigger arrives. BEFORE bundles this repo's `origin/main` (fee1014); AFTER bundles this branch. Each run has a fresh channel. | BEFORE — origin/main | AFTER — this PR | | --- | --- | | ![](https://git.rkd.nanoya.biz/attachments/693bcac6-4c55-48b2-b31d-30c8467238fb) | ![](https://git.rkd.nanoya.biz/attachments/e95e3baf-0754-4fb2-a111-380f78fabc96) | | ![](https://git.rkd.nanoya.biz/attachments/ad3f2e9b-e75f-4aa9-ba0f-64af09326a0c) | ![](https://git.rkd.nanoya.biz/attachments/718f5cdd-41a0-4f77-abcd-231910661b58) | A non-owner (mallory) clicks Allow. The click is refused under the button, the host logs `ACK … Only dozken can answer this`, and no permission notification is sent: ![](https://git.rkd.nanoya.biz/attachments/f632f951-ec2d-4698-aa36-ec692ff4c4bb) Recordings: <video src="https://git.rkd.nanoya.biz/attachments/72396c93-0509-4f98-bd38-a40be0f3eaf3" controls width="480"></video> <video src="https://git.rkd.nanoya.biz/attachments/48abe2f1-82e7-420b-9997-4a7a43e24c9d" controls width="480"></video> [before.mp4](https://git.rkd.nanoya.biz/attachments/72396c93-0509-4f98-bd38-a40be0f3eaf3) · [after.mp4](https://git.rkd.nanoya.biz/attachments/48abe2f1-82e7-420b-9997-4a7a43e24c9d) ## Test plan - [x] `pnpm typecheck` - [x] `pnpm test`: 137 tests. New cases: a request answered only once when two answers race (two clicks, a click and a reaction, a repeated `yes <id>`), the buttons on the prompt, owner Allow / Deny with disabled buttons, refusing a non-owner, stale and unknown clicks, disabled buttons after a reaction, DM edits staying text-only, the 400 fallback, fast `AppCommand` refusal, and `rkd-ws.test.ts` for frame parsing and the `AppAck` shape - [x] `pnpm check:dist`: `dist/server.js` rebuilt and matches `src/` - [x] `node scripts/smoke-handshake.mjs` - [x] End to end against a local RKD server (screenshots above) - [ ] Not run: `test:integration` against a real server with Claude Code in the loop https://claude.ai/code/session_013PWtUXgidAXrd8w4A1pPKK
feat(rkd): Allow / Deny buttons on permission prompts
All checks were successful
CI / rkd-plugin (pull_request) Successful in 33s
244a747a3d
A channel prompt now carries a components/v1 extra with Allow and Deny
buttons (RKD #991). The click arrives as AppInteraction: only the
owner's click decides; anyone else, and a click on a prompt this
machine no longer holds, gets an AppAck with the reason, so a stale
click fails closed instead of timing out. The ack goes out before the
decision. Once answered, by button, reaction or reply, the prompt is
edited with the outcome and both buttons disabled.

DMs carry no extras in v1, so a DM prompt keeps the reaction wording,
and a server that refuses the extra (400) gets the reaction prompt
too. The plugin registers no slash commands; an AppCommand is refused
at once rather than left to the 10s timeout.

Part of nanoya-biz/rkd#1097.

Claude-Session: https://claude.ai/code/session_013PWtUXgidAXrd8w4A1pPKK
fix(rkd): answer a permission request only once
All checks were successful
CI / rkd-plugin (pull_request) Successful in 31s
1a7fea5e5d
decide() awaited the notification before taking the prompt out of the
open set, so two answers landing together (a click on two devices, a
click and a reaction) both passed the open check and Claude Code heard
the request answered twice, possibly as allow and deny. A coded
`yes <id>` / `no <id>` could also re-answer a request already decided.

The request is now claimed synchronously, before the first await, and
remembered as answered for the prompt TTL; a second answer is dropped
and a second click gets "This request is no longer open".

Part of nanoya-biz/rkd#1097.

Claude-Session: https://claude.ai/code/session_013PWtUXgidAXrd8w4A1pPKK
dozken merged commit 1219108c02 into main 2026-09-24 18:09:41 +02:00
dozken deleted branch feat/1097-permission-buttons 2026-09-24 18:09:41 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
rkd/claude-plugins!9
No description provided.