Desktop: read a private chat's knowledge selection with the user's proof (KB chip, / palette, workflow capture) - #235
Merged
Conversation
…lection `GET /knowledge/active` naming a chat is on the reach gate's list (`session_reach.rs`), exactly like the POST beside it. The POST in `syncSelection` has always sent `userActionHeaders()`; the reads did not — the KnowledgeProvider hydrate and its recovery re-read, the `/` palette's knowledge-base rows, and the create-workflow modal. So the desktop's own daemon answered each of them as a public caller and refused every private chat, whatever model was bound. Measured on 2026-09-11 in a sandboxed dev instance on versa_azure (GPT-5.5): one turn ratcheted a new chat private, and its selection was set to primary `lab-notes` with `grant-drafts` hidden. - `GET /knowledge/active?session_id=…` without the proof: 403, "That chat is private, or there is no chat with that id. This request was made on a public model …" — for a chat bound to a private model. With `X-User-Action`: 200 and the chat's selection. - The console printed "Knowledge selection not hydrated: That chat is private, or there is no chat with that id." and the chip showed all three bases on: the renderer's cache, not the daemon's selection. - One click on another base in that stale chip wrote the stale set back; the daemon then had `grant-drafts` visible to the chat again. - The `/` palette offered `kb:Grant drafts` as "Knowledge base in this chat" and named no primary. With the proof on the reads, the same chat hydrates to the daemon's selection, the chip shows Grant drafts off, the same click leaves it hidden, and the palette offers Lab notes (as the primary) and Soul only. This reaches nothing the renderer could not already read: the selection is a strict subset of the transcript `getSession` reads with the same proof. And `userActionHeaders()` is the one place the surface is decided, so on a browser surface these reads state the host's model once SD-9's change to that helper lands (#229), with no further change here. The KnowledgeContext comment that called the refusal "a correct outcome" for "a private chat opened while a public model is bound" is corrected: the read was refused for every private chat, and what followed was not correct. Fail-before: all five new tests — the hydrate, the recovery re-read, the toggle that re-exposed a hidden base, the palette and the workflow capture — fail with the production change reverted. `src/test/reachGate.ts` is the one model of the gate they share.
…und model `selectionWarning.ts` and its test carried the premise the previous commit corrected in KnowledgeContext: that the desktop app was refused `GET /knowledge/active` for a private chat "while a public model is bound", as "a normal, correct outcome". It was refused for every private chat, because its read carried no proof. What the module does is still right — the refusal is prose addressed to a model, and a console's reader is a person — so only the reasoning changes. The pointer to the composer's pinned-model note goes with it: that note answers why a chosen model is not in effect, which this refusal never was about.
…tion is part of it The hydrate's comment called a chat's knowledge-base selection "a strict subset of what `getSession` already reads". It is not part of that response at all — the selection lives beside the session store, not in the transcript. The point the sentence was making is about reach: the same proof already reads the whole transcript, which discloses far more than which bases the chat uses.
Broccolito
added a commit
that referenced
this pull request
Sep 11, 2026
The palette's knowledge-base rows come from `GET /knowledge/active`, read with `throwOnError: false`, and a failed read left `data` undefined. The rows then read `hidden_kbs ?? []` and `primary_kb ?? active_kb ?? null`, so a failure was drawn as "nothing is hidden, nothing is primary": every base, the chat's hidden ones included, labelled "Knowledge base in this chat", and no primary. Now that the read carries the user's proof (#235), a failure is a genuine error: a surface that cannot prove the person, a dropped connection, an older daemon. None of those is a statement about the chat. `KnowledgeContext` already states the rule for `listBases`, and it holds here: a failed request is not an empty answer. `readKnowledgeSelection` is the one reader for a surface that shows or saves a chat's selection. It sends the proof, and resolves to `null` on any failure, rejection included, with one brief console line. It never rejects, which fixes a second failure: the palette loads its commands, skills and extensions in the same `Promise.all`, and a selection read that threw emptied all of them. With no selection, the palette still offers every base, because a reference names its base by id and an explicit id reaches a base whatever the chat's selection (`kb_id_or_primary` in the knowledge server). What goes is the claim. Each row reads "Knowledge base · <id>", with neither "in this chat" nor "Primary knowledge base". Tests: the refused read (the reach gate model with no proof), a transport failure, and a rejection. All three fail before this change; the rejection case failed with an empty palette.
Broccolito
added a commit
that referenced
this pull request
Sep 11, 2026
Main gained #235 (the proof on knowledge-selection reads) and #243 (a failed selection read is not an empty answer), both in KnowledgeContext. Where they and this branch did the same thing, one implementation is kept: - The selection request. #243 added `readKnowledgeSelection` (never rejects, null on failure) for the `/` palette and the create-workflow modal, and #235 put the proof inline on the provider's own reads. This branch had a third copy, `readSelection`. All three now go through ONE request, `fetchKnowledgeSelection` in knowledgeSelection.ts. It carries the proof and rejects when the daemon gave no selection. `readKnowledgeSelection` is now the never-rejecting wrapper over it, and its contract is unchanged. - The provider's re-reads. #235's proof on `rehydrateSelection` is subsumed: this branch replaced that function with `recoverFromFailedWrite` and `resyncSelection`, both built on the one request. - The hydrate's catch comment. #235's text is kept; this branch's shorter duplicate of it is dropped. - The tests. #235's 'a private chat' block and its `test/reachGate.ts` model are kept. This branch's duplicate hydrate-with-proof test and its local copy of the gate constants are dropped. Its write tests are kept under 'writes only what the daemon confirmed', built on the shared model.
Broccolito
added a commit
that referenced
this pull request
Sep 11, 2026
Seventeen PRs landed since this branch left 7c96d79. Seven files conflicted; every resolution keeps both sides' gates. - auth.rs: the served-operator standing (SD-10) and #231's failed-auth budget constants were added at the same spot. Both kept. - commands/agent.rs: #226 gave run() an exit_with_parent parameter. Taken, beside served_operator_capability(), which is unchanged. - routes/web_ui.rs: doc comment only. #226's paragraph (the token is not consumed, SD-9) and this branch's (the cookie's one narrowing reader, SD-10) both kept. - serve-decisions.md: #226's SD-9 and this branch's SD-10 were both appended after SD-8. Both kept, in number order. SD-10 gains a bullet: the standing follows the address, not a person, because SD-9 made the address reusable. - MentionPopover.tsx, CreateWorkflowFromSessionModal.tsx: #235/#243's readKnowledgeSelection() replaces this branch's inline getActive(). It sends the proof and returns null on failure, and its capture-nothing-on-failure path is kept. listBases() keeps the proof: under this branch the daemon omits a private base from a caller without it, which main did not. - KnowledgeContext.tsx: comments only. Both sides already sent the proof, and main's explanatory comments are taken. Also reconciled: browser-access.md no longer says the cookie does "nothing else" (it narrows listings, SD-10), and CLAUDE.md points at SD-1..SD-10. No route came in from main that names a chat or a knowledge base, and every gated call in the renderer files main changed still sends the proof.
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 was wrong
GET /knowledge/activenaming a chat is on the reach gate's list insession_reach.rs, exactly like thePOSTbeside it. The write (setActiveinKnowledgeContext.tsx) has always sentuserActionHeaders(). The reads did not. They are the KnowledgeProvider hydrate and its recovery re-read, the/palette's knowledge-base rows (MentionPopover.tsx), and the create-workflow modal. So the desktop's own daemon answered each read as a public caller and refused every private chat, whatever model was bound.Measured on the desktop (2026-09-11)
This ran in a sandboxed dev instance on
versa_azure(GPT-5.5). One turn ratcheted a new chat toprivate. Its selection was then set, with the proof, to primarylab-notesandgrant-draftshidden. That stands in for the agent'skb_set_active, the CLI, or another window changing it.GET /knowledge/active?session_id=…, no headersX-User-ActionKnowledge selection not hydrated: That chat is private, or there is no chat with that id.hidden_kbs: ["soul"], so Grant drafts is visible to the chat againhidden_kbs: ["grant-drafts","soul"]/kbpalettekb:Grant draftsas "Knowledge base in this chat"; names no primaryThe fourth row is the reason this is more than cosmetic. A set-only edit is computed from the set the renderer holds, so a refused read turns the next toggle into a silent overwrite of the chat's real selection.
Decision: these reads carry
userActionHeaders(), likegetSessionandreplygetSession, which discloses far more than which knowledge bases the chat uses. The write beside these reads already sends it.userActionHeaders(), and the gate's own module doc names it: the proof is how "the desktop app reaches a private chat it has open on a public model".userActionHeaders()is where the surface is resolved, so these reads pick up the browser half with no further change once SD-9's change to the helper lands (fix(serve): a new chat starts on the host's configured private model (QA F1) #229). I checked this by merging fix(serve): a new chat starts on the host's configured private model (QA F1) #229 into this branch in a throwaway worktree (not committed):X-Caller-Provider: versa_azureand settles, and fix(serve): a new chat starts on the host's configured private model (QA F1) #229's ownuserAction.surface.test.tsstill passes;Left alone on purpose:
refreshDefaultPrimary's machine-scopegetActive. It names no chat, and the gate is inert there ("Machine-wide selection requests name no chat and remain outside the session boundary").The comment
KnowledgeContext's catch arm called the refusal "a correct outcome" for "a private chat opened while a public model is bound". Neither half held:The comment now says so, and names where a refusal is right: a caller with neither the proof nor a private model, meaning a daemon started without a user-action key, or a browser tab not running a private model.
selectionWarning.tsand its test carried the same premise and are corrected in a separate docs commit. Their one-line console trimming is unchanged and still right.Changes
components/knowledge/KnowledgeContext.tsx: the proof on the hydrate and onrehydrateSelection; the corrected comment.components/MentionPopover.tsx,components/workflows/CreateWorkflowFromSessionModal.tsx: the proof on theirgetActive. Both keep theirPromise.allparallelism.test/reachGate.ts(new): one model of the gate as a renderer test meets it. A private chat is answered only with the proof, and a refusal is delivered as the generated client delivers this route'stext/plain403.KnowledgeContext.test.tsx: the hydrate, the recovery re-read, and the toggle that re-exposed a hidden base;MentionPopover.privateChat.test.tsx(new): the palette's rows and primary label;CreateWorkflowFromSessionModal.test.tsx: the capturedknowledge_bases(it saveddefault: souland all three bases).Verification
npm run test:run: 443 of 443 files, 4974 passed, 1 skipped.OllamaInlineCard.tsx:41inProviderCatalog.test.tsx) and a 30 safterAllinartifactCdnAssets.browser.test.ts. That file passes on its own, and the full run above is clean.act()warnings.npm run lint:checkpasses: typecheck, ESLint, themes, 332 contrast assertions, tokens.npx prettier --checkpasses on all nine changed files.Not in this PR
🤖 Generated with Claude Code