diff --git a/crates/biorouter/src/providers/coding_agent/bridge.rs b/crates/biorouter/src/providers/coding_agent/bridge.rs index 8ae34f5d..b0ae9b03 100644 --- a/crates/biorouter/src/providers/coding_agent/bridge.rs +++ b/crates/biorouter/src/providers/coding_agent/bridge.rs @@ -705,10 +705,18 @@ impl BridgeGrant { let request = UserActionRequest::ToolApproval(ToolApprovalRequest { tool_name: call.name.to_string(), arguments: arguments.clone(), - prompt: Some(format!( - "{} asked to run this through Biorouter.", - self.child_label() - )), + // ⚠ **`prompt` is the inspector's field — "why you are being asked" — + // and nothing else may borrow it.** This used to carry " asked + // to run this through Biorouter", which is framing, not a finding; the + // card reads any prompt as a SECURITY FINDING, so every bridged call + // arrived under a warning banner with "Always Allow" withheld. The + // card was telling the truth about the field it was given. + // + // The attribution belongs on the card, but it needs a field of its + // own (`requestedBy`) plumbed through `ActionRequiredData` and the + // OpenAPI client. Until then it is logged rather than dressed up as + // an inspector's verdict. + prompt: None, risk: Some(self.tool_risks.risk_for(&call.name)), preview: crate::conversation::tool_preview::ToolPreview::for_tool_call( &call.name, &arguments, @@ -729,15 +737,19 @@ impl BridgeGrant { UserActionOutcome::Approved { permission } => { tracing::info!( tool_name = %name, + child = self.child_label(), ?permission, "a bridged tool call was approved by the user" ); + self.record_lasting_decision(&call.name, &permission).await; Ok(()) } // `AlwaysDeny` and `DenyOnce` read the same to the child: it may not // run this. The *scope* of the refusal is the permission store's - // business, not the child's. - UserActionOutcome::Denied { .. } => { + // business, not the child's — which is why the store is written + // before the refusal is worded. + UserActionOutcome::Denied { permission } => { + self.record_lasting_decision(&call.name, &permission).await; Err(format!("`{name}` was refused: you did not approve it.")) } other => Err(format!( @@ -749,6 +761,37 @@ impl BridgeGrant { } } + /// Write a decision the user meant to LAST into the permission store. + /// + /// The outcome's `permission` carries the scope of the answer — its own doc + /// says it "distinguishes a one-off from an `AlwaysAllow` the caller may want + /// to record" — and on the agent's own path `handle_approved_and_denied_tools` + /// records it. The bridge only logged it, so "Always Allow" on a bridged card + /// granted a single call and the next identical one asked again: a card + /// offering a lasting decision it could not keep. An existing entry was + /// always honoured (the permission inspector reads the store before the card + /// is ever raised); what was missing was writing one. + /// + /// A one-off (`AllowOnce` / `DenyOnce`) is deliberately not recorded: that is + /// an answer about this call, not a rule. + async fn record_lasting_decision( + &self, + tool_name: &str, + permission: &crate::permission::Permission, + ) { + use crate::config::permission::PermissionLevel; + use crate::permission::Permission; + + let level = match permission { + Permission::AlwaysAllow => PermissionLevel::AlwaysAllow, + Permission::AlwaysDeny => PermissionLevel::NeverAllow, + _ => return, + }; + self.inspections + .update_permission_manager(tool_name, level) + .await; + } + /// What to call the child on the approval card. /// /// The user is being asked to approve a call *they* did not make, so the card diff --git a/ui/desktop/src/components/MessageCopyLink.test.tsx b/ui/desktop/src/components/MessageCopyLink.test.tsx new file mode 100644 index 00000000..9df90a2d --- /dev/null +++ b/ui/desktop/src/components/MessageCopyLink.test.tsx @@ -0,0 +1,83 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; + +const mocks = vi.hoisted(() => ({ toastError: vi.fn() })); +vi.mock('../toasts', () => ({ toastError: mocks.toastError })); + +import MessageCopyLink from './MessageCopyLink'; + +/** No rich node, so the component takes the plain-text path. */ +const noContent = { current: null }; + +/** + * ⚠ **Call this AFTER `userEvent.setup()`.** `setup()` installs a clipboard stub + * of its own, so a stub written first is replaced by one whose `writeText` + * resolves — and every failure test quietly measures a success. + */ +function stubClipboard(writeText: () => Promise) { + // `Object.assign` works once and then trips over the prototype getter, which + // makes the second test in the file fail for a reason that has nothing to do + // with what it is testing. + Object.defineProperty(navigator, 'clipboard', { + value: { writeText: vi.fn(writeText), write: vi.fn() }, + configurable: true, + writable: true, + }); +} + +beforeEach(() => { + mocks.toastError.mockClear(); + // The app's own logging stays quiet; these paths log on purpose. + vi.spyOn(console, 'error').mockImplementation(() => {}); +}); + +afterEach(() => vi.restoreAllMocks()); + +/** + * The reported defect: when both clipboard writes fail, the outer catch logged + * `'Failed to copy text: '`, the inner catch logged `'Failed to copy text + * (fallback): '`, and that was the end of it. `markCopied()` was never reached, + * so the button went on saying "Copy" — the user's only way to learn nothing had + * been copied was to paste somewhere and find out. + */ +describe('MessageCopyLink', () => { + it('says so when the clipboard refuses', async () => { + const user = userEvent.setup(); + stubClipboard(() => Promise.reject(new Error('denied'))); + render(); + + await user.click(screen.getByRole('button', { name: 'Copy message' })); + + await waitFor(() => expect(screen.getByRole('button')).toHaveTextContent('Copy failed')); + expect(mocks.toastError).toHaveBeenCalledTimes(1); + expect(mocks.toastError.mock.calls[0][0]).toMatchObject({ title: 'Copy failed' }); + }); + + it('leaves the success path alone', async () => { + const user = userEvent.setup(); + stubClipboard(() => Promise.resolve()); + render(); + + await user.click(screen.getByRole('button', { name: 'Copy message' })); + + await waitFor(() => expect(screen.getByRole('button')).toHaveTextContent('Copied!')); + expect(mocks.toastError).not.toHaveBeenCalled(); + }); + + it('still counts a successful fallback as a copy', async () => { + // The rich write is what fails here — jsdom has no `ClipboardItem`, which is + // the same shape of failure as a browser refusing the `text/html` flavour — + // and the plain-text retry succeeds. + const user = userEvent.setup(); + stubClipboard(() => Promise.resolve()); + const contentRef = { current: document.createElement('div') }; + contentRef.current.textContent = 'hello'; + render(); + + await user.click(screen.getByRole('button', { name: 'Copy message' })); + + await waitFor(() => expect(screen.getByRole('button')).toHaveTextContent('Copied!')); + expect(mocks.toastError).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/desktop/src/components/MessageCopyLink.tsx b/ui/desktop/src/components/MessageCopyLink.tsx index 8d36eb8e..1bce6f43 100644 --- a/ui/desktop/src/components/MessageCopyLink.tsx +++ b/ui/desktop/src/components/MessageCopyLink.tsx @@ -1,15 +1,21 @@ import React from 'react'; -import { Copy } from './icons/app-icons'; +import { AlertTriangle, Copy } from './icons/app-icons'; import { MessageMetaAction } from './MessageMeta'; -import { useTransientFlag } from '../hooks/useTransientFlag'; +import { useTransientValue } from '../hooks/useTransientFlag'; +import { toastError } from '../toasts'; interface MessageCopyLinkProps { text: string; contentRef: React.RefObject; } +/** What the clipboard write is asked to put on the clipboard. */ +type CopyOutcome = 'copied' | 'failed'; + export default function MessageCopyLink({ text, contentRef }: MessageCopyLinkProps) { - const [copied, markCopied] = useTransientFlag(2000); + // One transient state with two values rather than two booleans that could + // both be true: the button says exactly one thing at a time. + const [outcome, markOutcome] = useTransientValue(2000); const handleCopy = async () => { try { @@ -32,22 +38,43 @@ export default function MessageCopyLink({ text, contentRef }: MessageCopyLinkPro await navigator.clipboard.writeText(text); } - markCopied(); + markOutcome('copied'); + return; } catch (err) { console.error('Failed to copy text: ', err); - // Fallback to plain text if HTML copy fails - try { - await navigator.clipboard.writeText(text); - markCopied(); - } catch (fallbackErr) { - console.error('Failed to copy text (fallback): ', fallbackErr); - } } + + // Fallback to plain text if the rich copy failed. + try { + await navigator.clipboard.writeText(text); + markOutcome('copied'); + return; + } catch (fallbackErr) { + console.error('Failed to copy text (fallback): ', fallbackErr); + } + + // ⚠ Both writes failed, and this is the branch that used to end at a + // `console.error` the user cannot see: `markCopied()` was never reached, so + // the button went on saying "Copy" and the only way to find out nothing had + // been copied was to paste. Say so, in both places the user might be + // looking — on the control they pressed, and once in the corner. + markOutcome('failed'); + toastError({ + title: 'Copy failed', + msg: 'Biorouter could not write to the clipboard. Select the message and copy it with your keyboard.', + }); }; + const failed = outcome === 'failed'; + return ( - } aria-label="Copy message"> - {copied ? 'Copied!' : 'Copy'} + : } + aria-label="Copy message" + className={failed ? 'text-text-warning hover:text-text-warning' : undefined} + > + {outcome === 'copied' ? 'Copied!' : failed ? 'Copy failed' : 'Copy'} ); } diff --git a/ui/desktop/src/components/ToolCallConfirmation.test.tsx b/ui/desktop/src/components/ToolCallConfirmation.test.tsx index 682b4ffe..cb13ff97 100644 --- a/ui/desktop/src/components/ToolCallConfirmation.test.tsx +++ b/ui/desktop/src/components/ToolCallConfirmation.test.tsx @@ -334,6 +334,42 @@ describe('ToolCallConfirmation (BR-63)', () => { expect(screen.queryByRole('button', { name: /Always Allow/i })).not.toBeInTheDocument(); expect(screen.getByRole('button', { name: /Deny/i })).toBeInTheDocument(); }); + + /** + * The other half of the same rule, and the one that was wrong in practice. + * + * `prompt` is `approval_prompt_for_request` — an inspector's reason for + * escalating — so a card with none is an ordinary permission ask and the user + * may grant a lasting permission from it. The coding-agent bridge used to fill + * that field with its own framing (" asked to run this through + * Biorouter"), so every bridged call arrived under a warning banner with + * "Always Allow" withheld: a security verdict the daemon never reached. + * `bridge.rs` no longer writes it, and `bridgeApprovalPrompt.test.ts` keeps it + * that way. + */ + it('offers "Always Allow" for an ordinary ask, with no warning banner', () => { + renderCard({ + prompt: null, + preview: { kind: 'shell', command: 'ls -la', truncated: false }, + }); + + expect(screen.getByRole('button', { name: /Always Allow/i })).toBeInTheDocument(); + expect(screen.queryByTestId('tool-security-finding')).not.toBeInTheDocument(); + }); + + /** + * A blank prompt is not a finding. A daemon sending `" "` — or a future + * producer trimming a message to nothing — used to paint an empty warning band + * AND take the user's "Always Allow" away, which is the worst of both: a + * warning that says nothing, and a permission that cannot be granted because + * of it. + */ + it('treats a blank prompt as no finding at all', () => { + renderCard({ prompt: ' ' }); + + expect(screen.getByRole('button', { name: /Always Allow/i })).toBeInTheDocument(); + expect(screen.queryByTestId('tool-security-finding')).not.toBeInTheDocument(); + }); }); /** diff --git a/ui/desktop/src/components/ToolCallConfirmation.tsx b/ui/desktop/src/components/ToolCallConfirmation.tsx index 38f66686..39aaf0bb 100644 --- a/ui/desktop/src/components/ToolCallConfirmation.tsx +++ b/ui/desktop/src/components/ToolCallConfirmation.tsx @@ -190,6 +190,34 @@ export default function ToolConfirmation({ return parts.length > 1 ? parts[0] : ''; } + // `prompt` is `approval_prompt_for_request` — every reason an INSPECTOR gave + // for escalating this call — so its presence is what marks a security finding. + // + // One derived boolean rather than two independent reads of `prompt`: the + // warning banner and the withheld "Always Allow" are two halves of one + // decision, and a card that shows the banner while still offering a permanent + // grant (or the reverse) is worse than either. Whitespace is not a finding — + // a blank prompt used to paint an empty warning band *and* take the user's + // "Always Allow" away. + // + // ⚠ The coding-agent bridge used to put its own framing there (" asked + // to run this through Biorouter"), which put every bridged call behind a + // warning banner with no way to grant a lasting permission. The card was + // reading the field correctly; the producer was misusing it. Fixed in + // `bridge.rs::await_approval` and pinned by `bridgeApprovalPrompt.test.ts`. + // + // ⚠ That guard covers `bridge.rs` and ONLY `bridge.rs` — it string-matches that + // one file. Do NOT read it as "nothing but an inspector writes `prompt`": five + // other production sites still put framing there, so their cards still draw the + // banner and still withhold "Always Allow" — + // `extension_manager_extension.rs` (install, delete), `platform_approval.rs`, + // `skills_extension.rs` and `bug_report/mod.rs`. Withholding the grant is + // arguably wanted for the destructive ones (they carry `requires_user_proof`), + // but the *banner* is not: one of them reads "install … from the trusted BAAM + // registry" under a warning triangle. Closing that needs a field distinct from + // `prompt`, which is a protocol change, so it is a known gap rather than a fix. + const securityFinding = typeof prompt === 'string' && prompt.trim().length > 0; + // One cohesive, bordered "permission request" card. A single border wraps the // whole element (header + actions) so there are no mismatched borders, it uses // the app's standard card tokens + typography, and a gentle slide-in makes it @@ -201,9 +229,12 @@ export default function ToolConfirmation({ ) : ( <>
- {/* Security finding banner, only when the backend flagged one */} - {prompt && ( -
+ {/* Security finding banner, only when an inspector flagged one */} + {securityFinding && ( +
{prompt}
@@ -280,8 +311,10 @@ export default function ToolConfirmation({ > Allow Once - {/* Only offer "Always Allow" when there's no security finding. */} - {!prompt && ( + {/* Only offer "Always Allow" when there's no security finding. A + permanent grant is not something to decide from a card that + exists because an inspector objected. */} + {!securityFinding && (
@@ -195,6 +196,7 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti checked={dockIconEnabled} onCheckedChange={handleDockIconToggle} variant="mono" + aria-label="Dock icon" />
)} @@ -210,6 +212,7 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti checked={wakelockEnabled} onCheckedChange={handleWakelockToggle} variant="mono" + aria-label="Prevent sleep" /> @@ -225,6 +228,7 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti checked={showPricing} onCheckedChange={handleShowPricingToggle} variant="mono" + aria-label="Cost tracking" /> )} diff --git a/ui/desktop/src/components/ui/AppTooltipLayer.test.tsx b/ui/desktop/src/components/ui/AppTooltipLayer.test.tsx index c1b9ce02..44522340 100644 --- a/ui/desktop/src/components/ui/AppTooltipLayer.test.tsx +++ b/ui/desktop/src/components/ui/AppTooltipLayer.test.tsx @@ -108,6 +108,34 @@ describe('AppTooltipLayer', () => { expect(tooltip).toHaveClass('w-max', 'max-w-[min(20rem,calc(100vw-16px))]', 'break-words'); }); + /** + * The stranded tooltip. Hover a row control, then change route without moving + * the pointer: the target unmounts, so no `pointerout` is ever delivered and + * the tooltip is left on screen describing an element that no longer exists. + * + * The check for this was `useEffect(…, [tooltip])` — it ran when the tooltip + * STATE changed, which is never the case here. + */ + it('dismisses a tooltip whose target leaves the page', async () => { + const { rerender } = render( + <> + + + + ); + + const target = screen.getByTestId('native-title-target'); + await waitFor(() => expect(target).toHaveAttribute('data-biorouter-tooltip', 'Native action')); + fireEvent.pointerOver(target); + expect(await screen.findByRole('tooltip')).toHaveTextContent('Native action'); + + // The route change. The pointer never moves, so the only signal is the + // removal itself. + rerender(); + + await waitFor(() => expect(screen.queryByRole('tooltip')).toBeNull()); + }); + it('does not open after the pointer leaves during the delay', async () => { render( <> diff --git a/ui/desktop/src/components/ui/AppTooltipLayer.tsx b/ui/desktop/src/components/ui/AppTooltipLayer.tsx index 6626b219..e1118d04 100644 --- a/ui/desktop/src/components/ui/AppTooltipLayer.tsx +++ b/ui/desktop/src/components/ui/AppTooltipLayer.tsx @@ -196,9 +196,32 @@ export function AppTooltipLayer() { } }, [horizontalShift, tooltip]); + // A tooltip outlives its target when the target LEAVES rather than when the + // pointer does: hover a row control, change route without moving the pointer, + // and no `pointerout` is ever delivered — the element the pointer was over is + // simply gone, and the tooltip stays on screen describing nothing. + // + // ⚠ This used to be `useEffect(… , [tooltip])`, which is a check that runs + // only when the tooltip STATE changes — never for the one case it was written + // for. Adding `tooltip.target` to the deps does not fix it either: a node + // reference does not change when the node is removed. The removal is a DOM + // event, so it takes a DOM observer. + const tooltipTarget = tooltip?.target ?? null; useEffect(() => { - if (tooltip && !tooltip.target.isConnected) setTooltip(null); - }, [tooltip]); + if (!tooltipTarget) return; + if (!tooltipTarget.isConnected) { + setTooltip(null); + return; + } + const dismissWhenTargetLeaves = () => { + if (!tooltipTarget.isConnected) setTooltip(null); + }; + // Only while a tooltip is open, and only an `isConnected` read per batch — + // the observer is torn down the moment the tooltip closes. + const observer = new MutationObserver(dismissWhenTargetLeaves); + observer.observe(document.body, { childList: true, subtree: true }); + return () => observer.disconnect(); + }, [tooltipTarget]); if (!tooltip) return null; diff --git a/ui/desktop/src/components/ui/Checkbox.test.tsx b/ui/desktop/src/components/ui/Checkbox.test.tsx index 5d919751..c352370c 100644 --- a/ui/desktop/src/components/ui/Checkbox.test.tsx +++ b/ui/desktop/src/components/ui/Checkbox.test.tsx @@ -19,12 +19,57 @@ describe('Checkbox', () => { expect(input).toHaveAttribute('type', 'checkbox'); // Not `hidden`, not a div with a role: the native control stays in the tree // and in the tab order, so keyboard, form participation and the label - // association all keep working. `sr-only` is only what stops the OS drawing - // a second, un-themeable box on top of ours. - expect(input).toHaveClass('sr-only'); + // association all keep working. `appearance-none` + `opacity-0` is what + // stops the OS drawing a second, un-themeable box on top of ours. + expect(input).toHaveClass('appearance-none', 'opacity-0'); expect((input as HTMLInputElement).checked).toBe(true); }); + /** + * The measured defect (2026-09-11): the input was `peer sr-only`, a 1px + * clipped box in the corner, so the 22px square everyone can see was a + * picture. `ResetPanel`'s category boxes toggled on nothing at all and + * `ExportAppDialog`'s toggled only on their text — the label sat *beside* the + * box, not around it — while `SessionListView`'s worked only because a + * `