Skip to content

refactor(approvals): extract a shared tool-approval registry - #1141

Open
materemias wants to merge 1 commit into
siteboon:mainfrom
materemias:feat/shared-tool-approval-registry
Open

refactor(approvals): extract a shared tool-approval registry#1141
materemias wants to merge 1 commit into
siteboon:mainfrom
materemias:feat/shared-tool-approval-registry

Conversation

@materemias

@materemias materemias commented Aug 11, 2026

Copy link
Copy Markdown

Extracts the in-process tool-approval map out of the Claude SDK adapter into a provider-agnostic server/shared/tool-approval-registry.ts, so every provider can route interactive tool approvals through the same chat.permission-responseresolveToolApproval path.

This is a prerequisite for #1143 (adding omp as a provider), but it stands on its own: it is the piece that v1.37.0's runtime.permissions contract already implies, and it fixes one real hang.

What changes

  • New server/shared/tool-approval-registry.tsregisterApproval / unregisterApproval / resolveToolApproval / getPendingApprovalsForSession, plus the 30-minute expiry sweep whose timer is unref'd so it never keeps the process alive.
  • claude-runtime.provider.js imports those instead of owning a private Map, and re-exports resolveToolApproval / getPendingApprovalsForSession unchanged, so the WebSocket wiring and Claude's behavior are untouched.
  • eslint.config.js declares the new shared file as a backend-shared-type-contract boundary element, which is what keeps the barrel/shared-file discipline mechanical rather than conventional.

Bug fixed

The old expiry sweep deleted an expired approval without settling its resolver. A decision arriving after the 30-minute window therefore left the awaiting turn parked forever. The sweep now resolves the entry as { cancelled: true } before deleting it, so the turn always unblocks.

Tests

server/shared/tests/tool-approval-registry.test.ts — 15 tests, no network, no paid calls:

  • register → list → resolve → unregister round trip, and per-session filtering
  • legacy underscore metadata the Claude runtime attaches is still read
  • a missing request id, a non-function resolver, and an unnamed tool degrade safely (UnknownTool)
  • allow and deny decisions reach the waiting resolver; an unknown request is reported, not thrown
  • unregisterApproval drops the entry without settling its resolver (the runtime's own cleanup path stays idempotent)
  • a stalled approval expires on its own timer and cancels the waiter it left hanging (the fix above, driven by mocked timers)
  • the sweep timer never keeps the event loop alive
  • a non-Date receivedAt is pinned to a real timestamp and still expires, and an approval received at the Unix epoch expires like any other
  • resolveToolApproval settles a request exactly once, stops replaying it to a resubscribing client, and survives a resolver that throws

Every load-bearing assertion is mutation-tested — reverting a fix, or removing the unref, fails exactly the test that covers it and nothing else.

The last four cases came out of this PR's automated review, which found two real bugs in the new registry (both amended into the commit rather than stacked on top, since there is no released behaviour to bisect): receivedAt was not guaranteed to be a Date, and instanceof Date then failing in the sweep made the entry read as unknown age and therefore immortal — the exact hang the sweep exists to prevent; and resolveToolApproval neither consumed the entry nor guarded the resolver, so a duplicate or late chat.permission-response could deliver a second decision to a resolver that already settled.

Verification

  • npm run typecheck — clean
  • npm run lint — 0 errors
  • npm run build — passes
  • server tests — 277/277 pass, run serialized:
    npx tsx --tsconfig server/tsconfig.json --test --test-concurrency=1 "server/**/*.test.ts" "server/**/*.test.js"

One caveat, pre-existing on main: with the default parallel test workers, server/modules/agent/tests/agent.routes.test.ts dies with Node's Unable to deserialize cloned data due to invalid or unsupported version. It reproduces identically on a pristine main checkout (262/263 there), so I ran the suite serialized rather than papering over it here.


🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added centralized tool-approval tracking for consistent handling across supported providers.
    • Pending approval requests can be restored when reconnecting to an active session.
    • Approval requests now retain session and provider context for improved visibility.
  • Bug Fixes

    • Resolved approvals are reliably cleaned up after acceptance, denial, cancellation, or expiration.
    • Expired requests are automatically removed to prevent stale approval prompts.
  • Tests

    • Added comprehensive coverage for approval lifecycle, expiration, cancellation, cleanup, and session-based retrieval.

Copilot AI lite review requested due to automatic review settings August 11, 2026 21:32
@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The PR adds a shared tool-approval registry with session listing, decision resolution, expiry cancellation, and timer cleanup. The Claude provider now delegates approval state management to this registry. Tests cover lifecycle, metadata, filtering, timestamp handling, and resolver behavior.

Changes

Tool approval registry

Layer / File(s) Summary
Registry lifecycle and approval contracts
server/shared/tool-approval-registry.ts
The registry stores approval metadata and resolvers. It supports registration, resolution, unregistration, session-scoped listing, expiry cancellation, timestamp normalization, and an unreferenced sweep timer.
Claude provider integration
server/modules/providers/list/claude/claude-runtime.provider.js
The provider registers approval waiters with session, provider, and metadata. It delegates cleanup and pending-approval lookup to the shared registry.
Registry validation and boundary wiring
server/shared/tests/tool-approval-registry.test.ts, eslint.config.js
Tests cover validation, metadata fallback, decisions, expiry, timestamp handling, timer cleanup, exactly-once settlement, and session filtering. The registry is classified as a backend shared utility.

Sequence Diagram(s)

sequenceDiagram
  participant ClaudeProvider
  participant ApprovalRegistry
  participant ApprovalResolver
  participant SessionClient

  ClaudeProvider->>ApprovalRegistry: Register pending approval
  SessionClient->>ApprovalRegistry: List approvals for session
  ClaudeProvider->>ApprovalRegistry: Resolve approval decision
  ApprovalRegistry->>ApprovalResolver: Deliver decision or cancellation
  ApprovalRegistry-->>SessionClient: Return pending approval views
Loading

Possibly related PRs

Suggested reviewers: viper151

Poem

A rabbit records each approval in line,
With sessions and tools kept tidy and fine.
Decisions hop through, allow or deny,
Old waits are cancelled as minutes pass by.
The timer unrefs, then rests in the hay.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: moving tool-approval handling into a shared registry.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR extracts the pending tool-approval registry into a shared backend utility (server/shared/tool-approval-registry.ts) so provider runtimes can route interactive approvals through a single chat.permission-response → resolveToolApproval path, and fixes a hang by ensuring expired approvals cancel the waiting resolver.

Changes:

  • Added a provider-agnostic approval registry with registration, lookup, resolution, and an unref’d expiry sweep interval.
  • Updated the Claude runtime to use the shared registry rather than a runtime-local Map.
  • Added unit tests covering resolution behavior, expiry cancellation, and the interval unref behavior; updated ESLint boundary config for the new shared file.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

File Description
server/shared/tool-approval-registry.ts New shared registry for pending tool approvals, including expiry sweeping + unref’d timer.
server/shared/tests/tool-approval-registry.test.ts Unit tests for approval lifecycle, expiry cancellation, and interval unref behavior.
server/modules/providers/list/claude/claude-runtime.provider.js Switch Claude runtime from private map to shared approval registry.
eslint.config.js Marks the new shared registry file as an explicit backend boundary element.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread server/shared/tool-approval-registry.ts Outdated
sessionId,
provider,
meta,
receivedAt: meta.receivedAt || meta._receivedAt || new Date(),

@materemias materemias Aug 11, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 241a35b — coerced at registration.

You're right that the declared Date buys nothing here: meta arrives from claude-runtime.provider.js, which is still JavaScript, so a string genuinely reaches this line. The consequence was worse than the broken view contract — instanceof Date failing in the sweep made the entry read as unknown age and therefore immortal, which is exactly the hang the sweep exists to prevent.

Registration now pins a real timestamp, rejecting an unparseable Date too (new Date('nope') is an instance whose getTime() is NaN):

function coerceReceivedAt(value: unknown): Date {
  const time = value instanceof Date ? value.getTime() : Number.NaN;
  return Number.isFinite(time) ? (value as Date) : new Date();
}

Covered by "a non-Date receivedAt is pinned to a real timestamp and still expires", which asserts the replayed view carries a real Date and that the entry still expires.

Comment thread server/shared/tool-approval-registry.ts Outdated
Comment on lines +141 to +143
entry.resolver(decision);
return true;
}

@materemias materemias Aug 11, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 241a35b — both points.

resolveToolApproval now consumes the entry before settling it, and swallows a throwing resolver the way the sweep does:

unregisterApproval(requestId);
try { entry.resolver(decision); } catch { /* resolver already settled */ }
return true;

Consuming first is the ordering that matters: a duplicate or late chat.permission-response, or an abort racing the user's click, would otherwise hand a second decision to a resolver that already settled. Both providers' own cleanup stays correct because deleting an absent key is a no-op — Claude unregisters in its finally, and the omp runtime unregisters inside its resolver.

Two tests: "resolveToolApproval settles a request exactly once" (second call returns false, one outcome delivered, and the approval stops being replayed to a resubscribing client) and "a resolver that throws still consumes its approval".

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
server/shared/tool-approval-registry.ts (1)

108-114: 🩺 Stability & Availability | 🔵 Trivial | 🏗️ Heavy lift

Define a collision-safe approval identity.

Only Claude currently registers approvals and generates UUID request IDs. Before another provider uses this registry, namespace requestId by provider or enforce and test process-wide uniqueness. Otherwise, pendingApprovals.set(requestId, ...) replaces an existing resolver, while the runtime dispatches responses by requestId to every provider.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/shared/tool-approval-registry.ts` around lines 108 - 114, Update the
approval registration around pendingApprovals.set in the tool-approval registry
to use a collision-safe identity that includes provider context, or enforce
process-wide uniqueness for requestId before insertion. Ensure lookup and
response dispatch use the same identity so registering one approval cannot
replace another provider’s resolver, and add coverage for cross-provider
requestId collisions.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@server/shared/tool-approval-registry.ts`:
- Around line 44-53: Update sweepExpiredApprovals to validate receivedAt as a
finite timestamp rather than using truthiness, so Date(0) is treated as valid
and expired approvals are resolved and removed. Preserve the existing
cancellation and deletion behavior for all valid timestamps.

---

Nitpick comments:
In `@server/shared/tool-approval-registry.ts`:
- Around line 108-114: Update the approval registration around
pendingApprovals.set in the tool-approval registry to use a collision-safe
identity that includes provider context, or enforce process-wide uniqueness for
requestId before insertion. Ensure lookup and response dispatch use the same
identity so registering one approval cannot replace another provider’s resolver,
and add coverage for cross-provider requestId collisions.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: a94ac109-a6b4-4007-9155-6cf0d31864a0

📥 Commits

Reviewing files that changed from the base of the PR and between 0f67810 and 8c49d08.

📒 Files selected for processing (4)
  • eslint.config.js
  • server/modules/providers/list/claude/claude-runtime.provider.js
  • server/shared/tests/tool-approval-registry.test.ts
  • server/shared/tool-approval-registry.ts

Comment thread server/shared/tool-approval-registry.ts
@materemias
materemias force-pushed the feat/shared-tool-approval-registry branch from 8c49d08 to cc314dc Compare August 11, 2026 22:28
@materemias

materemias commented Aug 11, 2026

Copy link
Copy Markdown
Author

Worked through the automated review. Two real bugs, both fixed in 241a35b (amended into the single commit rather than stacked on top, since the registry is new in this PR — there is no released behaviour to bisect):

  • receivedAt was not guaranteed to be a Date. meta crosses a JS boundary, so a string reached the field and instanceof Date then failed in the sweep — which made the entry read as unknown age and therefore immortal, the exact hang the sweep exists to prevent. Now coerced at registration, unparseable Dates included.
  • resolveToolApproval neither consumed the entry nor guarded the resolver. A duplicate or late chat.permission-response, or an abort racing the user's click, could deliver a second decision to a resolver that already settled. It now unregisters before settling and swallows a throwing resolver.

Tests went 11 → 15. Each new assertion is mutation-checked: reverting the coercion fails exactly the two age tests, reverting the consume fails exactly the two resolution tests, and nothing else moves.

Declining the requestId collision nitpick. Both runtimes mint ids with crypto.randomUUID() (claude-runtime.provider.js, and the omp runtime in #1076), so a cross-provider collision needs two v4 UUIDs to match — not a risk worth namespacing the key for, and namespacing would mean the websocket layer could no longer dispatch a decision by the requestId it was given. The adjacent hazard that was real is duplicate resolution of the same id, which the second fix above closes.

Verification on this branch: typecheck, lint and build clean, 277/277 server tests serialized. Note npm test under the default parallel workers fails server/modules/agent/tests/agent.routes.test.ts with Unable to deserialize cloned data — that reproduces unchanged on a pristine main (262/263), so the numbers above come from --test-concurrency=1.

The in-process map of pending tool approvals lived inside
claude-runtime.provider.js, so every other provider that wants interactive
approvals would have to grow its own copy of the same
`chat.permission-response` -> resolver plumbing. Move it to
server/shared/tool-approval-registry.ts and let the Claude runtime import
register/unregister/resolve/listPending from there.

The Claude runtime keeps its own per-request timeout, abort handling and
metadata, and still re-exports resolveToolApproval and
getPendingApprovalsForSession, so the WebSocket wiring and its behavior are
unchanged. Pending rows now also carry the owning provider, which is additive:
every consumer types the list as `unknown[]` and forwards it verbatim.

Two things the extracted registry fixes while it is being written down:

- An expiry sweep now settles the entry as cancelled before deleting it. A
  decision arriving after the 30-minute window previously left the awaiting
  turn hanging on a resolver nothing would ever call.
- Sweeping only on registration left the last stalled approval pending
  forever, because nothing else ran the sweep. An unref'd interval bounds that
  wait and stops itself once no approvals are outstanding, so it never holds
  the event loop open.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants