Skip to content

Keep a handoff grant revocable after somebody hides the coworker it points at - #352

Merged
davidmckayv merged 2 commits into
CopilotKit:mainfrom
kevin9327:handoff-grant-to-hidden-coworker
Sep 4, 2026
Merged

davidmckayv merged 2 commits into
CopilotKit:mainfrom
kevin9327:handoff-grant-to-hidden-coworker

Conversation

@kevin9327

Copy link
Copy Markdown
Contributor

What this changes

The Handoff section joins two lists that are not filtered the same way, and the join silently
dropped live grants off the only screen that manages them.

  • reachable is botsReachableFrom (server/src/plugins/store.ts:2360) — a raw read of this
    coworker's bot grants. No visibility filter of any kind.
  • the roster is GET /api/agentsstore.list(actor, false)
    (server/src/agents/profile-store.ts:282-292), which drops every coworker the signed-in person
    has hidden. Hidden is a per-person display preference: one row per (user, agent) in
    agent_preferences (server/src/db/schema/coworker.ts:67-79), not a fact about the coworker.

So an administrator who tidied a coworker off their own roster stopped being shown the grants
pointing at it. others no longer contained it, so no Item and no Switch were drawn, no note
said why, and {granted} of {others.length} quietly dropped by one. Nothing on any other screen
manages these grants, so the grant could no longer be taken away at all.

And it was still in force. A hop is decided by mayAddress, which calls the same unfiltered
botsReachableFrom (server/src/index.ts:346-353), read per hop "so revoking a grant applies to the
next hop". Nobody's roster preference is consulted. The coworker went on being asked while the only
surface that could stop it had stopped listing it.

The panel's own comment, added with the dialogs in #317, already promised the opposite:

a stale grant may still be revoked — taking away is always allowed

The line above it — "Hidden Bots are already absent from this list" — was the assumption that made
that promise false.

The fix. The selection moves into app/src/lib/agents/handoff-roster.ts, a pure function, so the
list and the count are decided once and cannot disagree again:

  • a coworker on your roster is offered exactly as before;
  • a coworker you have hidden is offered only when a grant already points at it, and its row says
    · hidden from your roster so it is not an unexplained stranger in the list. Hiding is a
    preference about clutter and this screen has no business undoing it for a coworker with nothing to
    withdraw;
  • the count is over the rows actually drawn, which is the question somebody reading it is asking.

With no hidden grantee — the ordinary case — the rendered list and the count are byte-identical to
before.

Where it runs

  • New state that outlives a request? None. One pure function over data both queries already
    return.
  • What happens on the second replica? Identical. GET /api/agents?hidden=true is the same
    already-existing read the roster's own Hidden view uses, and it is scoped to the signed-in
    person by agent_preferences, which is in Postgres.
  • Anything serialised? Nothing. Revoking still goes through the existing
    setHandoffGrantMutationOptionsDELETE on the grant, whose write path is unchanged.
  • Anything fanned out to a browser? No.
  • New listener, port, or schedule? None. One extra GET /api/agents?hidden=true per open
    dialog, cached under agentKeys.list(true) and already invalidated by every agent write.

Boundary and audit

  • Every acting call still goes through the gateway: no acting path is touched. This changes only
    which grants are visible and revocable, never which are in force.
  • New refusals and new failures each write a row: none added. Revoking writes the same
    configuration.changed row it always did.
  • Nothing new is trusted from the client. Whether a grant exists is still the server's answer;
    the browser only decides which rows to draw for it.

Changelog

  • Hiding a coworker no longer hides the grants pointing at it, under Unreleased.

Proof

app/tests/handoff-roster.test.ts — 7 tests. Fail-before confirmed by replacing the function body
with the panel's previous selection verbatim and re-running:

(fail) still offers a granted coworker this person has hidden, so it can be revoked
  expect(received).toEqual(expected)
     "b",
  -  "c",
    ]

(fail) shows only the leftovers on a coworker that cannot hold a grant
     "b",
  -  "d",
    ]

 5 pass
 2 fail

and the count, asserted on its own against that same previous selection:

expect(r.granted).toBe(2);
Expected: 2
Received: 1

With the fix:

$ bun test app/tests/handoff-roster.test.ts
 7 pass
 0 fail
 15 expect() calls

Gates:

$ bun run format:check   Checked 517 files. No fixes applied.
$ bun run lint           Checked 520 files. No fixes applied.
$ bun run typecheck      app / server / worker: Exited with code 0

To see it on a deployment: as an administrator, grant coworker A the right to ask coworker B, then
open B and Hide it, then reopen A's Handoff section. On main B's switch is gone and the count has
dropped, while A can still hand work to B. With this change B is listed, marked hidden, and can be
switched off.

…oints at

The Handoff section joins two lists that are not filtered the same way. The
grants come from botsReachableFrom, a raw read of this coworker's `bot`
grants with no visibility filter of any kind. The roster comes from
GET /api/agents, which drops every coworker the signed-in person has
hidden - and hidden is a per-person display preference, one row per user in
agent_preferences, not a fact about the coworker.

So an administrator who tidied a coworker off their own roster stopped
being shown the grants pointing at it. No switch was drawn and no note
said why, the "N of M" above the list quietly dropped by one, and nothing
on any other screen manages these grants, so the grant could no longer be
taken away at all.

It was still in force. A hop is decided by mayAddress, which calls the same
unfiltered botsReachableFrom (server/src/index.ts:346-353), so the coworker
went on being asked while the only surface that could stop it had stopped
listing it. A boundary you cannot see is one you cannot withdraw, and the
comment right above this code already promised the opposite: "a stale grant
may still be revoked - taking away is always allowed".

The selection moves to lib/agents/handoff-roster.ts so the list and the
count cannot disagree again. A coworker on your roster is offered as
before. One you have hidden is offered only when a grant already points at
it, marked on the row as hidden from your roster: hiding is a preference
about clutter and this screen has no business undoing it for a coworker
with nothing to withdraw. The count is over the rows actually drawn, which
is the question somebody reading it is asking.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@davidmckayv davidmckayv 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.

Reviewed diff against current main; resolved CHANGELOG keep-both; validated locally (format + composed test run). CI green.

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