Repository navigation
Open the Bot's screen only when there is something to watch, and offer an unconnected app in the conversation - #798
Merged
Conversation
The live screen was announced before every call to a Bot's computer. Two kinds of call had nothing to show for it: one that touches files or runs a command, which never reaches the screen, and one a policy refuses, which is the Bot's intent rather than anything that happened. Asking a Bot a question it answered in words could therefore open its computer on a still page. Off-screen paths no longer announce themselves, as a list of exceptions rather than of permits: a browser path added later and never listed goes on announcing, so forgetting the list opens a pane rather than silently stopping one. A refusal now withdraws its announcement. Retraction is per announcement rather than per run, because a Bot that opens a page and then has a click refused made both calls in one run, and closing on the run would shut a pane showing the page the first call loaded. An action that was permitted and failed keeps its screen: the page may have moved. The route's effect moves into useAutoWatchScreen so the rules can be stated in tests without a router, a channel and a transcript around them.
Admin → Controls already decided whether anybody may drive a Bot's browser, use its files and shell, or reach the network, and the computer boundary enforced all three on every computer_* call. The app asked only the other question — whose computer it is — so a Bot was handed computer_navigate with cloudBrowser switched off, reached for it, was refused, and spent the turn saying so. computerToolsOffered mirrors the server's own split in decideFromSnapshot, which is by intent rather than by tool: files and commands need cloudComputer, everything else on the computer needs cloudBrowser, and opening a page needs cloudNetwork on top. Unresolved reads as allowed in both halves, since the server is the one that decides and this only declines to offer what it would certainly refuse. report_refusal stays on the ownership question alone: recording that a Bot declined something is audit bookkeeping rather than a computer action.
A Bot asked to use an app whose account the asker had not connected answered "Connect it in Settings and ask again" — a correct sentence, and a bad one to end on, in the middle of a conversation about an app the Bot held a tool for all along. The refusal now carries a connect prompt, declared once in shared and read from both sides for the reason shared/handoff-markers.ts sets out: a wording that lives in two files is a contract that drifts. The transcript turns it into a card naming the app, with a Connect button, and tool-activity keeps it out of the fold for the same reason a consent card is kept out — a question nobody can see is a conversation that has stopped without saying so. Connect opens the vendor's consent in a new tab rather than navigating this one: the trip is a full page load, so sending this tab would replace the conversation and land somebody on the connected-accounts page with their question behind them. The card watches this person's own connections while it waits and says connected only when the server lists one. The return trip is an unsigned redirect in a tab this one cannot read, so arriving back proves nothing. Four refusals carry it: never connected, brokered and user-OAuth alike, disconnected while a call was in flight, and access withdrawn. An app shared across the deployment deliberately does not — the person reading it cannot connect that account however clearly they are asked.
guidovizoso
requested review from
MikeRyanDev,
davidmckayv,
mxmzb and
tylerslaton
as code owners
October 16, 2026 17:45
A row is not access. Revoking a credential leaves mcp_user_credentials pointing at it, and connectionsFor read that table alone, so every caller asking "is this person connected" was answered "does this person have a row". Reconnecting settles nothing either: the upsert moves credential_id and updated_at and leaves connected_at where it was, so nothing visible changes when access comes back. The in-chat connect card is where that surfaced. Drawn over the "your access was withdrawn" refusal it said the app was connected and took away the button that is the only way to get access back. Reported rather than filtered, so a caller wanting every app somebody has ever connected still gets one and memory ingestion keeps the behaviour it had. The join is inner and total because credential_id is a real foreign key that does not cascade, which is also why there is no third state to report; a test pins that, since it is what the field's completeness rests on.
…ount
Three ways the card could press Connect and leave somebody with nothing.
A browser only lets a page open a tab while a click is still recent — about
five seconds in Chrome — and the tab was opened after the request that mints
the authorization url came back. On a slow deployment the activation had
expired, the tab was blocked, and the card said to finish in a tab that did
not exist. The blank tab is now claimed on the click itself and navigated
once the url arrives; noopener cannot be passed, since it makes window.open
answer null and null is a handle nothing can navigate later, so the opener
is severed on the handle instead. A tab blocked anyway leaves the url as a
link to click.
An app connected by typing a key has no page to visit: the route answers
{ fields } and the mutation unwraps nothing, so the button silently did
nothing. The card now says so and links to the page with the form.
And an account whose credential was withdrawn is no longer read as
connected, now that the connections list says which is which.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this changes
Asking a Bot whether it could use Notion, before any Notion tool was granted, opened its computer beside the conversation — on a still screen, for an action that never happened. Three things behind that, and the third is the reply the Bot should have given instead.
callComputerannounced the live screen before every request./files/list,/files/read,/files/writeand/exechappen on the computer rather than on its display, and announcing them opened a pane on whatever still page was already there. The off-screen set is a list of exceptions, not of permits: a browser path added later and never listed goes on announcing, so forgetting the list opens a pane rather than silently stopping one.useAutoWatchScreenso these rules can be stated in tests without a router, a channel and a transcript around them.useComputerAvailableonly ever asked whose computer it is. It never consulted the capability switches, so withcloudBrowseroff the model was still handedcomputer_navigate, reached for it, was refused, and spent the turn saying so.computerToolsOfferedmirrors the server's own split indecideFromSnapshot, which is by intent rather than by tool: files and commands needcloudComputer, everything else needscloudBrowser, opening a page needscloudNetworkon top. Unresolved reads as allowed in both halves — the server decides; this only declines to offer what it would certainly refuse.report_refusalstays on the ownership question alone, since recording that a Bot declined something is audit bookkeeping rather than a computer action.shared/connect-prompt.ts, declared once and read from both sides for the reasonshared/handoff-markers.tssets out), and the transcript draws a card naming the app with a Connect button.tool-activitykeeps it out of the fold for the same reason a consent card is kept out. Four refusals carry it: never connected (brokered and user-OAuth alike), disconnected mid-call, and access withdrawn. A Shared app's deployment account deliberately does not — the person reading cannot connect it however clearly they are asked.Connect opens the vendor in a new tab rather than navigating this one. A consent screen is a full page load, so sending this tab would replace the conversation and land somebody on the connected-accounts page with their question behind them. This tab stays put and watches this person's own connections, and says connected only when the server lists one — never because somebody came back, since the return trip is an unsigned redirect in a tab this one cannot read. That also meant leaving the sealed OAuth
returnTostate alone, which is a closed two-name set for reasons documented at length inplugins/oauth.ts.Not covered: Gmail still has no native catalogue entry, so "what emails did I get" reaches the card through the Composio broker rather than a first-party entry.
Verified
bun run typecheck(all five workspaces) clean;bun run buildsucceeds.plugin-store.integration,plugin-user-credential.integration,plugin-connect-route,plugin-oauth,shared-use-store.integration,shared-brokered-store.integration,composio-adapter,composio-connections: 534 pass, 0 fail.server/testssuite run in parallel showsFailed query:failures against the sharedopenbot_testdatabase;composio-connections.test.tsfails there and passes alone. That is contention on a database other local sessions share, not these changes.Where it runs
GET /api/admin/enterprise/meandGET /api/plugins/connectionsboth answer from Postgres for whoever is asking, and the refusals are built per call from the same rows the boundary already read./connectroute, unchanged./api/plugins/connectionsevery two seconds, and only while a consent is open in another tab; otherwise it is an ordinary cached read.Boundary and audit
PluginStore.callToolor past the computer boundary — the capability gate withholds tools in front of a decision the server still makes for itself.serverIdin the card comes from the server's own refusal, and pressing Connect re-resolves it server-side as it always did.Changelog
Unreleased: "The Bot's screen opens only when there is something to watch" and "An app you have not connected is offered where you are reading".Proof
The screen-opening rules are pinned by tests rather than by a screenshot, since what changed is when a pane appears:
app/tests/computer-screen-announcement.test.ts— a browser action announces; a failed one still announces because the page may have moved; a refused one withdraws; a refusal does not withdraw a sibling's announcement in the same run;/files/*and/execnever announce; an unrecognised path still does.app/tests/auto-watch-screen.test.tsx— opens on activity, does not take a pane opened for something else, does not reopen what is being watched, closes only on the announcement that opened it, and honours a dismissal for the rest of the run.app/tests/computer-tools-offered.test.ts— each switch withdraws exactly its own tools, and unresolved offers everything.app/tests/connect-app-card.test.tsx— asserts the card never navigates this tab, and that "connected" follows the server listing the account rather than the person returning.I have not re-run the original conversation against a live deployment; the local instance I checked has no activity since 14 Oct.
Review follow-ups
Three P2 findings on the connect card, all confirmed against the code and fixed:
POST /connectanswers{ fields }for those, so the mutation unwrapped nothing and the card returned silently. It now says the app is connected with a key and links to the page holding the form.connectionsForselected from the join table alone, so a revoked credential still listed a connection — the card drew "connected" over the very refusal saying access was withdrawn, removing the only way back. Root-caused on the server: the listing now reportsrevoked, additively, so the apps-ever-connected readers and memory ingestion keep their behaviour. The join is inner and total becausecredential_idis a real non-cascading foreign key, which is also why there is no "credential is gone" state; a test pins that, since the field's completeness rests on it.window.openran after the network call, by which time Chrome's transient activation (~5s) could have expired. The blank tab is claimed on the click itself and navigated when the url arrives.noopenercan't be passed — it makeswindow.openanswer null, and null is a handle nothing can navigate later — so the opener is severed on the handle instead. A tab blocked anyway leaves the url as a link to click.Card tests went from 4 to 9, covering the claim-on-click ordering, the blocked-tab link, the key-entry link and its target, tab cleanup on failure, and the revoked row. The test harness now mounts a real memory router, since the card links to a connector's settings page.