Repository navigation
fix(desktop-app): stop team-id mechanics from blocking the composer - #1425
Conversation
A transient team hop and a deliberate team switch replace the committed token and principal in steps while they hold the native commit owner. refreshAccessCatalog read those two fields with no owner and published its result with no check, so a build could borrow the hop's token, its principal, or one of each, and a build overtaken by a switch, a logout or a login published the old session's catalog as the current one. The session is now read under the owner, the catalog is built outside it, and it is published under the owner after checking that the session is still the same one. Nothing else waits for a catalog build. The token is not compared: a hop restore mints a new home token without advancing the generation, and a catalog built across a hop still belongs to the session. Behaviour that changes for callers: a catalog request now rejects with stale_auth_epoch when the user logged out or changed during its build, or when the session changed twice. A build overtaken by the same user's session change is discarded and rebuilt once. Before this change those requests resolved with a catalog of a session that no longer existed. Orphans checked: refreshAccessCatalog's locals moved to buildAccessCatalogForSession; removed: none. Refs #1409 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The composer refused to send unless the cached catalog's team id matched the session and listed the selected agent. Two of its states had no exit: a settled catalog stamped with another team stayed "Checking agent access for this team…" until something else refetched it, and a signed out state did the same. The server authorizes every send, so the check only blocked users on stale client data. The gate, its notices, the retry button and the resolver are removed. The renderer keeps no opinion on whether a send is authorized. This undoes review fix R7-L1 (02341fd, 10803b4) and its follow-ups 8244067 and 25f5da7, which came in through PR #1084. Orphans checked: useAuthContext and useAgentsDataController in ComposerPanel, agentCatalogAvailability and its two test files, and the produceSerializedAccessCatalogMalformedResponse test helper; removed: all of them. Button, useId and composer-attachment-notice stay in use. Refs #1409 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ist it The selection effect dropped any agent the catalog did not list, unless the catalog's team id had just changed. The catalog is direct grants plus the current team's grants, so the current team decided which agent could stay selected: an agent kept through a switch was dropped by the next catalog change, and a tab or notification of an unlisted agent could not be opened at all. The catalog no longer deselects. A selection ends when the user changes it or when the server denies it through the existing hold path: the chat-list read on selection and team change, the send, and the re-check of a held agent. The effect stays only for a selection restored from a workspace snapshot without a server check. An agent removed on the server now stays selected until a read or a send is denied. This replaces the selection rule of 36e4d9e from PR #1084. Orphans checked: accessCatalogScopeRef, agentsData.agentNames in the effect; removed: accessCatalogScopeRef. authenticatedPrincipalIdentity keeps its other callers. Refs #1409 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…talog is empty ChatPage and AgentsPage rendered no workspace when the catalog was empty, even with an agent selected, so a kept selection had a tab and no composer whenever the current team's catalog was empty. A selected agent now gets the workspace. Both ChatPage guards change together: with only the empty-state guard changed, a composer mount refreshes the catalog, the loading guard swaps the workspace for the skeleton, and the workspace mounts and refreshes again. With no selection both pages behave as before. Orphans checked: the ChatPage and AgentsPage empty and loading branches; removed: none. Refs #1409 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The test that a late composer mount does not take focus from the compact navigation trigger emptied the catalog to unmount the composer. A selected agent now keeps its workspace when the catalog is empty, so that no longer unmounts it. The test now leaves Chat for Files and comes back, which is a real path to a new composer. Its assertions are unchanged, and it still fails with the composer's compact navigation focus check removed. Orphans checked: appQueryClient and desktopQueryKeys in the test file; removed: both. Refs #1409 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The public boundary scanner rejected six literals under a token key that did not say they are synthetic. The tokens the test's switch fixture issues are now named synthetic-session-<team>. The assertions compare the same tokens as before. Refs #1409 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…g a catalog build refreshAccessCatalog refuses to publish when the session generation or the team changed during the build. Every existing test that moves the team also advances the generation or ends the session, so the team comparison had no test of its own. A session with no team does: its first hop has nothing to restore and does not advance the generation, so after the hops the team is team-a and the generation is unchanged. The new test holds a build of the no-team session in its user-agents request, runs the hops through listPendingWorkflowApprovals, and checks that the build is discarded and the catalog is rebuilt and published for team-a. With the team comparison removed, the test fails with a catalog that has no team. The setup gains a homeWithoutTeam option; without it every existing test runs as before. Refs #1409 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The selection effect in useAppController clears a selection that names a held agent. Its remaining job is a selection restored from a workspace snapshot, which restoreWorkspaceNavigation writes with no server check. No test restored a snapshot, so disabling the branch left every test green. The new test opens agent-x's Members route, captures the snapshot, moves to agent-y, and in one row has the server deny agent-x's chat list so it is held. Restoring the snapshot brings back the Agents page and the Members route in both rows. The held row ends with no selection; the control row ends with agent-x selected, which shows the snapshot names it. With the effect's condition disabled, the held row fails with agent-x selected and the control row stays green. Refs #1409 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A send writes its user turn to the local chat store before the POST, so that a Host wake, a tab switch or a restart cannot erase the only copy. The store had no operation that takes a message back: upsertMessages and replaceMessages merge with what is stored, and saveMessages is not exposed. A turn the server rejected therefore stayed stored, came back when the chat reloaded, and was doubled by a retry. ChatStore.removeMessages removes local user turns the server never answered, under the chat's serializer. Each requested message that is stored must be a user turn with no server turn number and no task id, with no assistant message lacking a server turn number stored after it. Numbered server turns after it are what a reconcile normally stores and do not count; a synchronous reply or a legacy reply does. If any requested message fails the rule, the call throws and removes nothing. Unknown ids are ignored, a deleted or missing chat returns 0 and is not created, and the indexed message count is not lowered. The channel chat:removeMessages checks the sender, requires agentRef and chatId, and accepts 1 to 16 message ids of at most 128 characters through the new sanitizeChatMessageIds, which rejects anything else instead of slicing it. It uses the store bound to the signed-in user. The preload entry, the renderer type, the useChatStore wrapper and both renderer test bridges gain removeMessages. The only production caller arrives in the next commit: withdrawUndeliveredTurn in useAgentChatController.ts. Refs #1409 Refs #1410 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A send clears the composer, writes its user turn and marks it waiting before the server has accepted anything. Its authorization exits undid none of that: the text and attachments were lost, the turn stayed in the transcript and a resend showed it twice. A 401 was also treated as a doubt about the agent and deselected it. A send the server rejects now ends in one of two ways. - Generic rejection (a 401, or a 403 whose follow-up read is not denied): the agent stays selected, nothing is shown, and the text and attachments go back to the composer. - Loss of access (host_access_revoked on the send, or a 403 whose follow-up read is denied): the agent is revoked or held as before. A text-only send finds its text in the draft when the agent is selectable again; a send with attachments keeps them, with its text, behind Recover input. The payload returns all or nothing: only when the draft of the send's chat is free and the composer still holds what the send cleared, on the visible chat. Otherwise the retained send keeps it for Recover input. The composer state and the hold are settled synchronously, as before; only the withdrawal of the turn awaits. The rejected turn is withdrawn from the visible list and from the store through removeMessages. Its id is fenced, so a reconcile or an older-page backfill that read the transcript earlier does not write it back; the hydrate filters both where it merges and after the chat create it awaits before writing. After the removal the auth scope is checked again. A refused removal keeps the turn, marks its activity as an error, logs a warning without the message text and shows an error toast. The two exits taken when another path holds the agent (before the POST and after it fails) keep the input the same way. Failures that are not a rejection (network, 5xx, 404, 413, 422) keep today's error. Recover input and the rejected send share one function that puts a payload back in the composer. The crossChat 401 row now expects no hold, and the deep-link test expects the text and the document back in the composer after a send 403. Depends on the previous commit (`removeMessages`). Reverting that commit requires reverting this one first. Orphans checked: holdHostAccess, isHttp403, classifyErrorKind's auth branch, the "Access issue while sending" text; removed: none. Refs #1409 Refs #1410 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The comment in test (b) said that an equal second catalog would keep the same query object so that no selection effect runs again. The selection no longer depends on the catalog. What the different catalog still provides is an observable change: the query keeps the same data object for an equal result, so nothing that reads the catalog would see a change, and the assertion could not tell the refetched catalog from the first one. The comment now says that. No behaviour changes. Refs #1409 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…oes not list The comment on the Authorize buttons in AgentWorkspace said the only gap in this agent's mapped rows is transient, while the catalog is mid-hydration. A selected agent can now stay selected while the catalog does not list it. The mapped rows come from the catalog only, so such an agent shows the global preview rows when that preview is available, and no rows otherwise, for as long as the catalog omits it. The comment now names both cases. No behaviour changes. Refs #1409 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Simplifications from a KISS and YAGNI review of the rejected-send change. No behaviour the user can see changes. - S1: restoreComposerPayload no longer reads the draft again. Both callers reach it only with a free draft, and it writes the text only when there is one. - S2: sendStillAuthorized is built from sendAuthScopeIsCurrent and sendChatWasDeleted, which move up so that "same scope" and "chat deleted" are defined once. - S3: returnUndeliveredSend uses sendChatId directly instead of a `?? null` alias of a value that is already string | null. - S4: ChatStore.removeMessages derives the removed count from the filtered list instead of keeping a counter in the loop. - C1: the comment on restoreComposerPayload no longer describes a false return. - C2: the comment before the composer write and the hold states the ordering instead of its history. - O1: restoreComposerPayload returns nothing. setComposerDraft returns true on every path, so its false branch, the third operand of the put-back condition and Recover input's "This composer no longer owns the draft" toast could not be reached. - O2: a withdrawn turn's activity entry is no longer removed. The only reader looks it up for a rendered message, the withdrawn turn is not rendered, and the map is pruned on task terminals and cleared on a hold or a scope reset. Orphans checked: restoreComposerPayload's boolean result, the "no longer owns the draft" toast in Recover input, the activity-entry removal in withdrawUndeliveredTurn, getComposerDraft, setComposerDraft and setActivityByAgentMessage in useAgentChatController.ts; removed: the boolean result, the Recover toast and the activity-entry removal. The other helpers keep their callers. The same toast in ChatThread.tsx is outside this change. Refs #1409 Refs #1410 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two guards of the rejected-send path had no test that fails without them: - U1: restoreComposerPayload restores documents only when the send carried some. handleRestoreComposerFiles replaces the composer's documents, so without the guard a text-only send rejected after the user attached a document would erase that document. The new test fails with the guard removed (no document left in the composer). - U2: the put-back requires the composer's attachment revision to be the one the send's own clear left. The new test changes the attachments while the POST is pending; without the condition the payload goes into the changed composer and nothing is retained, and the test fails. The arrange code of the file is also shared, with no test removed and the same 34 titles before the two new tests: - T1: sendHeldAtPost starts a send whose POST stays pending. - T2: holdByDeniedRead holds the agent through a denied transcript read. - T3: the two T-A5 tests that differ only in the POST's failure are one it.each. - T4: the first T-A12 test ends with the shared assertion helper, plus its own check of the visible order. - T5: expectInputReturnedAfterReselect checks the draft and the withdrawn turn after access returns. Refs #1409 Refs #1410 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…path Five guards of the rejected-send path had no test that fails without them. Each new test builds the interleaving or state its guard handles: - an older page loaded while the removal of a rejected turn is pending does not write the turn back (the withdrawn-id filter of the backfill); - a turn withdrawn while its chat is hidden does not come back from the remembered transcript when a later local read fails; - a 401 whose removal is refused after a logout reports nothing in the new scope; - a logout during the follow-up read of a 403 leaves the draft store and the turn alone; - a rejected send that returns its input to the composer releases its retained snapshot. Each test was run green on this code and red with its guard removed, with every other test of the file green. No production code changes. Two describe titles drop the ids of an earlier plan. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…veMessages Two checks of the removal path had no test that fails without them: - the store returns 0 and keeps the stored snapshot when the chat is deleted in scope while its files are still on disk (a deletion whose cleanup did not finish); - the IPC handler rejects an untrusted sender before the store is called; the same payload from the trusted sender removes the turn. Each test was run green on this code and red with its check removed, with every other test of the file green. No production code changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cover input A retained failure whose POST failed records the thrown error as its message, and the composer alert fell back to that message. So a send that waits behind Recover input showed the raw IPC error (for example "Error invoking remote method 'rpc:invokeHostMessage': Error: 403 Forbidden: ...") as its alert, and an ordinary failed send turned its sentence into the raw error as soon as the user typed, attached something or returned to the chat. The sentence and hint of a failed send now come from one function, describeSendFailure in lib/format.ts, which the generic catch and the alert of a retained failure both use. A retained failure whose reason is post_failed shows the sentence and hint for its kind; the raw error stays under "Technical details". Other reasons keep the text they recorded (the Host's own error text, the files sentences). The hint of an access failure no longer mentions a team. It read "Authentication/permission issue. Re-authenticate or switch team/access scope." and now reads "You may not have access to this agent anymore. Sign in again or ask an admin for access." No other send-failure text mentioned a team. Orphans checked: the inline mapping of the generic catch (the sentences, the size hint, isRequestEntityTooLarge, friendlyMessage) and the controller's import of errorRecoveryHint. Removed: all of them; no literal is left in the controller. errorRecoveryHint is still used by describeSendFailure and its test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The generic catch of sendAgentMessage marks the turn of a failed send with preserveLocal and stores it. That block becomes a local function, keepUndeliveredTurn, with the same body; the generic catch calls it where the block was. No behaviour change and no test change. The next commit adds its second caller: the held exit of the catch, for a send that was posted and failed without a rejection. Until then the function has one caller; that is the cost of keeping the two later fixes of this controller on lines that do not touch, so each of them reverts on its own. Orphans checked: the inline durable write of the generic catch. Removed: the block and its comment; nothing else referred to them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… a rejection When another path held the agent while a send was pending, the catch withdrew the send's turn and returned its input for any error. For a POST that failed without a rejection (a network error, a timeout, a 404, a 5xx) the Host may have received the message, so returning the text to the draft invited a second send of the same message. The held exit of the catch now decides by whether the POST was issued. A flag, set immediately before invokeHostMessage is called, tells a send that was never posted (for example its activity subscription failed first) from one that was: - never posted: withdrawn, and the input returned, as before, with the kind of the error that ended it; - posted and answered with a 401 or 403: withdrawn, and the input returned, as before, with kind auth; - posted and failed in any other way: the turn stays, marked preserveLocal and stored through the shared durable write, and the input is retained with the failure, behind Recover input and Retry. This is what the same failure does for an agent that is not held. The same rule applies when the Host accepted the POST and a later local write threw while the agent was held: the POST was issued and the error is not a 401 or 403, so the turn is kept. The comment on the held helpers drops the id of an earlier plan and the claim that nothing new is shown. Tests: a held send whose POST fails with a network error, a 504 or a 404 keeps its turn and offers the input back (the network row leaves the table of returned inputs); a kept held send with a reference shows the network sentence; a held send whose activity stream fails before the POST, with a network error or a 403, is withdrawn and returns its text, or with a reference waits behind Recover input under the network sentence. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A send registers an activity entry for its agent and opens the agent's activity stream before its POST. The entry was released only by a withdrawal, a synchronous reply and the generic catch; a started task takes it over. Every other exit returned without releasing it. That was safe while those exits were caused only by a scope change or a hold, which release the entries themselves. A chat deleted while the send was pending is a third cause that releases nothing, so the stream stayed open after a 401, a 403 whose read is not denied, any other POST error, an accepted POST, or a deletion before the POST. One function, releaseSendActivity, now releases the entry. The finally of the send runs it on every exit that did not hand the entry to a task; the hand-off is marked as the last statement of the try, so a throw anywhere before it still releases the entry, as the generic catch did. The release is idempotent, so running it after a withdrawal or a hold changes nothing. Tests: a send whose chat is deleted while it is pending closes the agent's activity stream after a 401, a 403 whose read is not denied, a network error, an accepted POST, and a deletion before the POST; a send accepted as a task keeps the stream open. Orphans checked: the inline releases of the synchronous reply, of the generic catch and of withdrawUndeliveredTurn. Removed: the first two; the third is replaced by a call to releaseSendActivity. maybeStopAgentActivityStream keeps its other callers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The removal of a rejected turn checks what is stored; it cannot ask the server. The docstring of ChatStore.removeMessages, the comment of the chat:removeMessages handler and its renderer declaration now say that: a turn with no stored sign of a server answer is removed, a stored message that fails the rule makes the call throw, and a deleted or missing chat removes nothing and returns 0. The examples of an unnumbered assistant message after the turn now include an error reply and the reply of an earlier task stored before its turn number is known. A send's turn is withdrawn, and its input returned, when the server rejected the send, and also when the send ended before its POST while its agent was on hold. The comments of the retained-send store, of the withdrawn ids, of the composer put-back and of the optimistic write said only the first. Comments only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…-and-catalog-hop-isolation * origin/dev: (76 commits) fix(deps): raise the sharp override to 0.35.5 for Next 16.4.6 docs: describe which control-ui proxy requests the timeout bounds fix(control-ui): default the control-api proxy timeout to 300 s ci: gate React bundled inside next, lockfile advisories and Next.js images fix(deps): upgrade Next.js 16.3.5 -> 16.4.6 in control-ui and profile-ui docs: warn against applying the mcp-host policies file on its own docs: describe what a fresh install without deploy/base gets in mcp-host docs: create only deny-all-mcp-host on a fresh install without deploy/base docs: cover fresh installs without deploy/base and every path git quotes test(ci): probe a jwt-key-policy test file in both modes of the version check test(e2e): read the WRC window from the core in the overlap bound test docs: say how to fix the version check's stayed-at failure test(ci): let the version check's pin accept job hardening fix(ci): keep a version inside the version check's annotation fix(ci): read changed paths unquoted in the version check fix(ci): give each service one rule in both modes of the version check chore: keep dev's versions for services this PR does not change test(deploy): state the version pin's limit and check 4's fallback negative docs(deploy): limit the no-records note to passes that reach the policy test(e2e): pin the WRC recovery check as a statement of its own ...
…cords The comment on the retained-failure alert and the describeSendFailure docstring said that reason post_failed records the thrown error of a failed POST. It also records the error of opening the activity stream or of a later step of the send, and the fixed sentence of a send that its agent's hold stopped before it was handed to the main process. postIssued marks that the send was handed to the main process, not that its POST was made. Main can still refuse it before the POST, for example on request validation or while minting the RPC token, and the send is then treated as posted: kept, not withdrawn. The comments now say so, and the comments that described a withdrawal as ending "before its POST" now say "before it was handed to the main process". Five sibling texts still said "rejected" where a held send that ends before it is handed to main is also covered. They now say "withdrawn" or "undelivered", including the warning logged when the store refuses a withdrawal; the two tests that match that warning follow it. The comment of the best-effort catch in keepUndeliveredTurn said the in-memory view still shows the turn. That is false when the chat is not visible, for example after a hold deselected the agent. The catch itself is unchanged. Comments and one log message only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two chat store tests shared one title prefix; each now has its own title. A rejected-send test title regains the space it lost after its colon. The logout test claimed that an empty draft of the sending chat witnessed the logout clearing the draft store, but that draft was already empty before the logout. It now writes a draft for another chat before the logout and expects it to be gone afterwards. With the logout removed from the test, that assertion fails. Only a deleted chat pinned the release of a send's activity entry in the shared finally. Two rows now pin it for a send that starts no task: a POST that fails with a network error while the agent is not held, which keeps the turn, and a synchronous reply. Each first witnesses that the send opened the stream and took its path, then expects the stream to close. With the release removed, both rows fail, together with the deleted-chat rows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The tests this branch added to the chat store, the rejected-send journey, the team catalog selection and the Agents and Chat pages started their titles with scenario ids from a working plan. The titles now keep only the behaviour they describe. Tests that predate the branch keep their titles. Every title stays unique within its file. Titles only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The rewritten comment of the best-effort catch in keepUndeliveredTurn said that, when the chat is not visible, the input survives only in the retained snapshot. The store still holds the optimistic copy written before the send, without the mark, and the generic failure path also sets the failed send. The comment now says that the mark is lost and the input is kept in the retained snapshot, without claiming that nothing else holds it. Comment only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The access catalog session isolation tests, added on this branch, started their titles with scenario ids from a working plan, and two comments named other tests by those ids. The titles now keep only the behaviour they describe, and the comments point to the tests below them. Every title stays unique within the file. Titles and comments only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Evenfire Reviewer — Round 1Reviewed head: Decision: Approve with follow-ups The composer no longer blocks on a team-stamped catalog, a catalog built across a team hop or a team switch is published only for the session it belongs to, and a send the server rejects returns its input instead of losing it. I found no merge-blocking defect. Two statements in the description do not match the head, and T2 has not been run. Must fix
Verified
Validation gaps
PR descriptionUpdate required. Pre-existingPre-existing: none identified during the scoped review. Next actionRun T2 once on this head, and correct the two statements in the description.
Full review archive: — Review performed by Evenfire Reviewer |
…-and-catalog-hop-isolation * origin/dev: test(desktop-app): pin explicit profile origin precedence test: remove redundant preview assertion for r4-l4 test: assert restore ownership outcome for r4-l3 test: cover profile origin switching for r4-l2 fix: accept loopback profile aliases for r4-l1 fix: pair explicit profile origin for r4-m1 fix(desktop-app): codeql-2004 mark synthetic test token fix(desktop-app): codeql-2004 remove unused useEffect import fix: confirm external GFS links for J-I1269-N2 fix(desktop-app): preserve configured local profile ports r1-m1 fix(format): j-i1269-f1 omit ignore file from source targets test(desktop-app): assert generation-scoped preview permission refresh test(desktop-app): j-i1269-n2 cover cold-start delivery fix(desktop-app): r9-m1 preserve restored session generation fix(desktop-app): j-i1269-n1 use active deployment for links
…isolation * commit 'e611ebfcb': (34 commits) docs(mcp-host): reflow R2-L2 grant-ceiling docstring test(mcp-host): make R2-L9 epoch guard match a UUID and name the cap pin test(mcp-host): cover R2-L4 bounded labels on both decoders and total usage fix(mcp-host): log R2-L3 non-Error throws by type only in the reasoning port fix(mcp-host): harden R2-L9 Responses test pins and drop its dead tokenField test(mcp-host): require R2-L3 alias payload comparison to see a request docs(mcp-host): state R2-L2 why the corpus refuses regeneration in CI test(mcp-host): pin R2-L1 string branch to the profile's own endpoint fix(mcp-host): raise R2-L6 openai floor to the first SDK with Responses test(mcp-host): pin R2-L5 whitespace-only summary fallback in compaction docs(mcp-host): correct R2-L2 grant-ceiling wire field for Responses fix(mcp-host): bound R2-L4 provider status and code of a failed response fix(mcp-host): log R2-L3 context-breakdown failures through the Error summary test(mcp-host): correct R2-L4 stale family comments and corpus test title test(mcp-host): harden R2-L3 alias guard against hidden members and divergent args test(mcp-host): fail R2-L2 chat corpus regeneration outside CI too fix(mcp-host): refuse R2-L1 ready-built client on azure and compatible profiles fix(mcp-host): use fake-marked synthetic API keys in workflow Responses tests fix(mcp-host): record only the SDK missing-credential sentence in the chat corpus docs: state R1-L2 official OpenAI runs on Responses, not Chat Completions ...
…og-hop-isolation' into fix/1409-composer-gate-and-catalog-hop-isolation * origin/fix/1409-composer-gate-and-catalog-hop-isolation:
|
@claude Please run a closing review and audit of this PR at head Scope. Review the 1. Access catalog (
|
|
Claude finished @alfredolopez80's task in 9m 26s —— View job Closing review & audit — head
|
jozer-rami
left a comment
There was a problem hiding this comment.
Approving at fc4cef0ca.
Verified
- The catalog is read and published for one committed session. A team hop holds the commit owner from switch to restore, and the publish re-checks user, generation and team. No deadlock path.
- The server authorizes every send. The RPC token mint checks team membership and the host grant, and rpc-proxy runs the host-access check on each request. Removing the composer gate opens no authorization gap.
chat:removeMessagesadds no capability beyond the existing chat handlers.- The withdrawn-turn filter covers every write to the store.
- The touched test files pass locally on Node 24.22.1: 586 of 586. I ran 34 mutations of the production changes, and the tests caught every one.
Non-blocking follow-ups
- Silent rejection (#1410). A 401, or a 403 whose follow-up read is allowed, returns the text to the composer with no message (
useAgentChatController.ts:4166-4199). On an expired session this lasts until the entity-change stream ends the session. Showing the access sentence while still returning the input would close it. - External logout.
onExternalSessionEnded(useAppController.ts:183-190) resets navigation but not the data controllers.ChatPage(ChatPage.tsx:20-28) can then auto-select the previous user's agent from the cached catalog for the next user. The selection stays until the first denied read, and only the agent name is exposed. The stale cache predates this PR. This PR removes the same-team deselect and the send gate that used to cover it. The body lists "the session identity changes" as ending a selection, which this case does not meet. - Empty chat after a rejected first send. When a new chat's first send is rejected, an empty local chat stays in the sidebar, titled with the withdrawn text. The index keeps
messageCount: 1. - Refused withdrawal (rare). If an earlier task's late reply from durable recovery (
useAgentChatController.ts:2855-2888) is stored after the optimistic turn,removeMessagesrefuses (chatStore.ts:2266-2276). The text is already back in the composer, so the turn shows twice. Ignoring a later assistant message whosetask_idbelongs to another stored user turn would fix it. - PR body. The Connectors page (
McpServersPage.tsx:165-166) can also show the rawstale_auth_epochtext, not only the Members tab. The opening line still says "Draft."
T2 on the final head is still owed, as the body says.
A reconcile replace and the removal of a withdrawn turn share the chat queue. Pin both orders: whichever runs first, the withdrawn turn is not written back. A replace on its own keeps a stored local turn its window omits, which the test asserts first as the witness that the result depends on the removal. Without the queue around removeMessages, this test and the upsert ordering test fail. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fixes #1409. Refs #1410.
Review rounds 1 to 3 and a closing audit are done; their findings are fixed in the commits below. T2 has not run on the final head; see "Not verified".
Why
The Desktop composer could stay blocked on "Checking agent access for this team…" with no way out. The team id is being retired (#334, then #594, then #1222). This PR stops team-id mechanics from affecting normal use. With the gate gone, a send the server rejects became the path a user meets, so this PR also stops such a send from losing the user's input.
What changes, one commit per root cause
fix(desktop-app): build the access catalog for one committed sessionfix(desktop-app): remove the composer's team catalog gate02341fd84,10803b41a) and follow-ups8244067e0,25f5da7a3from #1084fix(desktop-app): keep the selected agent when the catalog does not list it• the server denies the agent, on a read, a rename, a running task's status check, or a send it answers as a loss of access;
• the user selects something else;
• the session identity changes;
• a restored workspace snapshot replaces it.
Replaces the selection rule of
36e4d9ef2fix(desktop-app): show the workspace for a selected agent when the catalog is emptytest(desktop-app): remount the composer by navigating in the focus testfeat(desktop-app): remove unanswered local turns from the chat storeremoveMessagesin the chat store, limited to local user turns with no stored sign of a server answer, with its IPC channelfix(desktop-app): return a rejected send to the composerfix(desktop-app): show the failure sentence for a send kept behind Recover inputfix(desktop-app): keep a held send that was posted and failed without a rejectionfix(desktop-app): release a send's activity entry on every exitfinallyreleases the send's activity entry unless a started task took it overThese commits change no behaviour:
chat:removeMessages;The branch also merges
devup toe611ebfcb(no conflicts).Behaviour changes to review
stale_auth_epochwhen the user logged out or changed during its build, or when the session changed twice.Not authenticatedwhen a logout lands between a discarded build and the rebuild's session read (read in the code, not run).getConnectorsand the plugin SDK'scontexts.readcapability use the same entry and inherit these rejections. The SDK'smcp.readcapability uses the same entry, but its existing catch turns any catalog failure into an empty agent list, so it does not surface them.host_access_revokedor with a 403 whose follow-up read is also denied. A send rejected in any other way (a 401, or a 403 whose follow-up read is not denied) keeps the agent selected.getMe(only when no user is cached) and bind the chat store, so a logout can wait for it.stale_auth_epochmessage. That display predates this PR.Validation (Node 24.22.1)
scripts/minikube/t0.shat the head18691a395, which runs thedesktop-apptestsT0_EXIT=0,T0_AFFECTED_CHECKS=PASS, 477 files, 5878 tests. A first run on a saturated host timed out inApp.gfsColdStart.r15.test.tsx, adevfile this PR does not touch; that file passes 21/21 on its own, and the rerun passedremoveMessages, the new test and the upsert ordering test turn redtscmain and UI, prettier on the touched files,style-rules, public boundary scanner, atc3fa9a974style-ruleshas 1 warning in a file this PR does not touchd02fcb9f1, access catalog testsd02fcb9f1, selection and page testsf7318e80cd01c1db4e. One mutation could not fail, because the check it removed had no observable effect; the check was removed insteadfinallyrelease now turns 7 tests redd01c1db4e): its tests on the code at33114161418691a395Not verified
cd16755b6stopped in T1 on one control-api test: 1054 of 1055 passed. That test races its own COMMIT (control-api: passwordResetLostCommit real-Postgres test reads before the COMMIT is applied #1442) and this PR does not touchcontrol-api. The rerun was refused becausedevhad moved.d02fcb9f1and27776e8, not executed against a server. Remove Team Token PR2: Runtime actions use validated access paths #594 changes the session model, so the premise must be re-read when it merges.rpc_host_access_deniedin 30 days on clerum-dev (host_missing) and none on clerum, against 254 and 599 sends.Known limits
AppServicemethods (43 public, 3 private) callrequireSessionToken()outside the commit owner, counted on the syntax tree ofdesktop-app/src/appService.tsatd01c1db4e. They are out of scope here.sandboxUiSessionEpochinappService.tssays a hop bumpssessionGeneration. The code does not, and this PR does not edit that comment.classifyErrorKindlabels any message containing "401" or "403" as an access failure.🤖 Generated with Claude Code