fix(rpc): client capabilities are per connection; a session answers with its attached connections'
Part of sisyphuslabs/omo-desktop-app#4042 (fix plan item (b)).
Stacked on #3814 and intentionally still based on fix/rpc-socket-hardening while #3814 is open. Commit a5c8937c7f merges main at 5827be99e1 into this published branch without rewriting history, preserving #4009's classic RPC behavior. Until #3814 merges, the PR diff against its stacked base also includes those newer main commits. After #3814 merges, retarget this PR to main.
Problem
A session binding's client capabilities were fixed by its first opener. A session opened by a socket that never sent set_client_info degraded every question for its whole life, even after the connection answering it declared question. The first version of this PR (reviewed at ba24ae38ce) let the newest declaration overwrite the session's whole set. That let a later connection downgrade the answerer, and left question on a session whose remaining client could not render it.
Design (lead ruling: capabilities are per connection)
- Declarations stay per connection. A connection that never declared counts as the host default. A
set_client_infowithoutcapabilities(a resize) keeps the declaration. - A session answers with the union of the declarations of the connections attached to it now. The union is recomputed on open,
attach_session,release_attachment, disconnect, and every declaration (connection-level or session-scoped). With nobody attached, a retained session keeps its set, so its pending questions are replayed to the client that reattaches. - Records are routed per connection.
questionrequests,extension_eventrecords and the custom-unsupported notice go only to connections that declared the matching capability. The pending-question replay on attach follows the same rule. A connection that never declared receives them exactly as today, so old clients are unchanged (the #3814 lane's condition). - Question routing. With A (declared
question) and B (declared without it) attached, the question goes natively to A, and B never sees the frame. With nobody left who declaredquestion, the session degrades toselect/input, targeted to the prompting connection as today. - A question left without an answerer is cancelled. If
questionleaves the session's set while a structured question is pending, that question is cancelled through the question bridge's own cancel, once (question_resolvedwith outcomecancelled). I chose this over re-asking it degraded to the remaining clients: the bridge already settles a question this way when its session tears down, and a re-ask would make the extension's onequestioncall produce two dialogs. extension_events. The handler subscribes to extension events while the union hasextension_eventsand re-subscribes when that changes. The events reach only the connections that declared it, plus undeclared ones as today.- Custom-unsupported fallback. The notice is built when the union has
custom_unsupported, and reaches only the declarers, plus undeclared connections as today. - #3811 is unchanged. The role check stays first in both
set_client_infopaths. A refused declaration changes nothing, and a connection that is not attached (an observer) is never part of a session's union.
Code
session-router-capabilities.ts(new): the union, recompute and declaration logic.session-client-capabilities.ts(new): the set one session handler answers with; the extension-event re-subscribe and the question cancel live here, soconnection-handler.tsonly changes at its call sites.connection-handler.ts: reads that set and exposessetSessionCapabilities. Inset_client_infoit no longer overwrites the session's set; it records the declaration throughclientInfo.session-binding.ts,session-worker-{protocol,client}.ts,session-worker.ts:RpcSessionBinding.setCapabilities. Workers get a host-to-isolatesession_capabilitiesmessage, following theprompt_surface/browser_engine/permission_presetpattern. It is applied to the isolate's one binding, so an isolate cannot change another session's set.session-event-fanout.ts,session-event-writer.ts: the capability filter intargets. Only a connection that declared is filtered.- Router call sites:
session-router-{open,routing,attachments}.tsandsession-attachment-commands.ts(attachSession, which recomputes after the attach is recorded).reclaim_sessiongets no recompute and no new await, because the reclaim closes the session. - Review LOW 4 and LOW 5 are gone with the design: the router no longer sends itself an internal
set_client_info, so no reply id is reserved. A declaration a session could not take answersclient_capabilities_not_applied. LOW 6 is accepted: a connection-levelset_client_infonow waits for one capability update per attached session whose set changes.
Evidence
RED: the new regression file, run at the previous PR head (2b8350d1f3 on 1209c4fe05). Seven new cases fail and the four kept cases pass:
FAIL ... in-process / worker > a later connection without question does not downgrade the answerer ... expected 'select' to be 'question'
FAIL ... in-process / worker > the answerer leaving mid-question cancels it once ... (no dialog reached the remaining connection)
FAIL ... a width-only set_client_info keeps the connection's declared question expected 'select' to be 'question'
FAIL ... a connection that never declared keeps the host default ... expected 'question' to be 'select'
FAIL ... a client command whose id the router once used internally still gets its own reply (reply never arrived)
Tests 7 failed | 4 passed (11)
GREEN at 784fc91d09, with the workspace rebuilt from that source so socket-host suites run the current dist. The 4987d7bf19 head has an identical code diff (git patch-id fe4138e1aca7 for both); only the CHANGELOG context differs.
- The new file has 11 cases (three scenarios on both runtimes; 11/11 pass on 4987d7bf19 too):
- native to A with B attached, where B never gets the frame and A's answer settles it once;
- mid-question detach, which cancels once and then degrades for B;
- width-only resize;
- an undeclared old client, end to end: it gets the native frame while a declarer is attached, and the degraded dialog after the declarer leaves;
- id collision;
- observer;
- refused role.
- Run together with the RPC set: issue-3811 x3, rpc-second-session-inherits-capabilities, rpc-unknown-capability-ignored, issue-2951-held-question, rpc-attach-* (including the #3814 gate), rpc-attachment-gate-hardening, rpc-attachment-caller-holds, rpc-reclaim-session, rpc-inprocess-host, rpc-worker-routing (an undeclared late attachment still gets the question and its replay), rpc-socket-host-ui, rpc-socket-ownership, rpc-extension-ui-response-reply, rpc-extension-events, rpc-socket-host-events, rpc-worker-requests, rpc-close-ordering, rpc-retain-on-disconnect, and rpc-multi-session{,-isolation,-input,-events}.
Test Files 28 passed (28)
Tests 161 passed (161)
tsc --noEmit -p tsconfig.json: exit 0.biome checkon the 17 changed TS files: clean.node scripts/check-pr-changelog.mjs --base bdcfbcb78d:changelog-gate: PASS.
Not covered here
- Plan items (a), (c) and (d) of #4042 are separate PRs.
Integration and review fold (a5c8937c7f)
Final design
- Classic
--mode rpckeeps #4009 exactly: after the connection-role check, a declaration replacesSessionClientCapabilitiesonly whenclientInfois absent; width-only updates retain the set, withdrawingquestioncancels pending questions once, andextension_eventsre-subscribes through the existing helper. - Shared hosts keep #4000's router-owned model: declarations belong to connections, a session answers with the union of its attached declarations, and an undeclared connection uses the host default.
- Native
question,extension_event, andcustom_unsupportedrecords are filtered to attached declarers; mirror subscriptions remain intentionally unfiltered read-only observers. - Worker sessions receive the host-to-isolate
session_capabilitiesmessage. A failed apply returnsclient_capabilities_not_appliedfor connection-level and session-scoped calls and restores the prior declaration, delivery filter, and session capabilities.
Review fold
- Updated
docs/rpc.mdwith the per-connection rules, undeclared-host-default behavior, mirror exception, withdrawal semantics, worker message, and failure response. - Added coverage for failed applies and rollback, extension-event unsubscribe/re-subscribe,
custom_unsupported, mirror delivery, and all eight capability scenarios on both in-process and worker runtimes.
Coordination
- #3814 remains open, so the base remains
fix/rpc-socket-hardeningby coordination with its owner. The published branch was advanced from4987d7bf19toa5c8937c7fec3cf0b4369a94feea3372ac315dc3by a normal push, never a force push. - The merge has parents
fbbf5e86a5(the review fold) and5827be99e1(main containing #4009).
Verification at a5c8937c7f
bun install --frozen-lockfile: 435 installs across 519 packages, no changes.bun run build: passed.- RPC/capability regression selection: 300 files passed, 3 skipped; 1,750 tests passed, 12 skipped.
NODE_OPTIONS=--max-old-space-size=8192 tsc --noEmit: passed (the larger heap avoids main's known default-heap OOM).- Biome on the changed task delta: 76 files checked, no fixes.
- Commit hooks: full repository Biome/check chain and browser smoke passed.
node scripts/check-pr-changelog.mjs --base 3eb4b84dbf7e4572bf226a7b51905ddbd75bbe3f: passed.- Remote read-back:
refs/heads/fix/4042-session-capabilities-follow-connection=a5c8937c7fec3cf0b4369a94feea3372ac315dc3.