diff --git a/.memory/dictation-secure-input.md b/.memory/dictation-secure-input.md new file mode 100644 index 000000000..452a0cc80 --- /dev/null +++ b/.memory/dictation-secure-input.md @@ -0,0 +1,25 @@ +# Dictation Secure Input warning — 2026-09-27 + +Branch `feature/dictation-secure-input`; plan `docs/plans/dictation-secure-input-plan.md`. + +- `pasteTranscript` (main/services/dictation-paste.ts) takes an injectable + `isSecureInputActive`. Order: Accessibility check → Secure Input probe → atomic + paste. Active → clipboard + `{ outcome: "copied", reason: "secure-input" }`. + Probe errors preserve the transcript and skip synthetic paste. +- Live detector uses documented Carbon `IsSecureEventInputEnabled()` through + JXA. The prior investigation used the incorrect `IsSecureEventInput` symbol. + Verified the documented API is exported in the installed SDK and callable. +- The atomic transaction checks the target AXValue after the keystroke; absent + evidence of insertion, it reports copied and retains the transcript. This + covers Secure Input activation during the focus-check delay. +- The detector reports only the boolean condition; the UI does not attribute it to an app. +- `DictationCopiedReason` lives in `renderer/shared/dictation.ts`. The pill's copied + result renders through `renderer/pill/pill-copied-notice.tsx`. The coordinator + holds a secure-input result for `WARNING_HIDE_DELAY_MS` (4 s). +- No Remote protocol, iOS, or Android impact: the pill state is desktop-only IPC. + +Review validation: 15 focused paste/pill tests pass, including a process-owned enable/disable cycle, live Carbon probe, and AppleScript compilation. CI test inventory now registers the pill test. + +The JXA probe explicitly binds `IsSecureEventInputEnabled` as a no-argument boolean function, avoiding reliance on OS BridgeSupport metadata. The plan index and PR description now match the Carbon detector and conservative copy fallback. + +Independent review: reading the original AXValue is optional, so text controls without an accessible value still receive a guarded paste attempt. An unconfirmed result or transport error says “Check the field — transcript copied.” It never instructs a second paste after a possibly successful attempt; the prior clipboard is restored only after confirmed delivery. diff --git a/docs/plans/README.md b/docs/plans/README.md index a715de143..520924aeb 100644 --- a/docs/plans/README.md +++ b/docs/plans/README.md @@ -19,6 +19,7 @@ This directory is the source of truth for Aiden's implementation plans. The engi | [Timed ask-user waits](timed-ask-user-plan.md) | Implemented for review | Optional `timeoutSeconds` on `ask_user_question`; unattended (Remote) runs always expire within 5 min and resolve with an explicit best-judgement result. Late desktop answers become a Send/Queue follow-up offer; Remote `expiresAt` carries the real deadline; iOS/Android say when a question expired. PR CI pending. | | [Rich link previews for Chats and Bots](rich-link-previews-plan.md) | Implemented for review | Shared regular Chat and Bot transcripts now show provider-aware inline icons and bounded hover/focus cards. User HTTP(S) text is autolinked, assistant streaming and persisted Markdown share the opt-in renderer, and URL-derived previews perform no network requests. | | [Simulator Devices](simulator-devices-plan.md) | Partial | Environment **Simulator** tab plus `device_*` agent tools, ported from T3 Code (MIT, `1c127066`). iOS only; consent-gated pinned `expo-device-hub@0.12.0` + `agent-device@0.21.12`; token-authenticated loopback proxy. Phases 0–3 done (spike, flagged tab shell, main-process toolchain/host/proxy/service/IPC, live stream viewer with controls and screenshot-to-chat; `test:devices` 96 pass; fake-hub Electron E2E; real-Mac acceptance passed). Phase 4 (agent `device_*` tools), Phase 5 (simulators on paired Macs over Aiden Remote; [plan](simulator-devices-phase-5-peers.md)) and Phase 6 (procedural 3D device frames; [plan](simulator-devices-phase-6-3d.md)) done. Phase 7 done: Settings → **Simulator** (consent switches, pinned/installed helper versions, prune, remove installed tools) and [`docs/devices.md`](../devices.md); onboarding skipped while the flag is off. Agent guidance now prefers `device_*`/`agent-device` for the watched device but allows shell `xcrun simctl`/`xcodebuild`/`adb` for builds, installs, logs, port forwarding, and diagnostics (T3 #13908). Remaining: real-Mac acceptance for Phases 4–6 before the flag defaults on; SSH hosts are a later follow-up. | +| [Dictation Secure Input warning](dictation-secure-input-plan.md) | Implemented for review | Before pasting, dictation probes macOS Secure Event Input through the documented Carbon API; when active it keeps the transcript on the clipboard and the pill explains why with a ⌘V hint. PR CI pending. | | [MCP numeric schema formats](mcp-numeric-schema-formats-plan.md) | Implemented for review | Desktop and CLI MCP tool schemas drop schemars numeric formats (`uint32`, `int8`, `double`, ...) at every nested position and keep exact width ranges as `minimum`/`maximum`. Raw schemas remain the drift/grant identity. Source: pi-mcp-adapter #651. | | [Scoped MCP resources](mcp-scoped-resources-plan.md) | Implemented | Per-server resource inventory/templates/read; MCP93/Bots448/scheduled151/onboarding56 and both independent reviews pass. PR CI pending. | | [Transcript polish: sticky headers, preparing stage, turn footers](transcript-polish-sticky-headers-plan.md) | Implemented for review | Opened Thinking disclosures and activity/compaction trails keep a sticky header under the toolbar and flow at full height; pending tool calls read `Preparing ` on desktop, iOS and Android; settled responses show a duration/model/token footer from new content-free `turnStats`. Mobile footer and live streaming footer are follow-ups. | diff --git a/docs/plans/dictation-secure-input-plan.md b/docs/plans/dictation-secure-input-plan.md new file mode 100644 index 000000000..a93d2e975 --- /dev/null +++ b/docs/plans/dictation-secure-input-plan.md @@ -0,0 +1,52 @@ +# Dictation Secure Input Warning + +Status: Implemented for review (`feature/dictation-secure-input`). + +Source: Handy parity tracker, P0 — "dictation paste silently fails while macOS +Secure Event Input is active". + +## Problem + +macOS Secure Event Input (password fields, a terminal's Secure Keyboard Entry, +some password managers) drops synthetic keystrokes from other processes. The +atomic dictation paste (`main/services/dictation-paste.ts`) still reported +`pasted`, so the pill said "Pasted" while nothing arrived. + +## Detection + +- The documented Carbon `IsSecureEventInputEnabled()` API is exported and callable + through JXA. The original investigation used the incorrect `IsSecureEventInput` + symbol and fell back to an undocumented session dictionary key. +- Detection now uses Carbon with a bounded timeout. A live test enables and disables + a process-owned Secure Input claim, verifying the probe follows both transitions. +- Clipboard restoration requires AXValue evidence of insertion, keeping the transcript + copied if Secure Input changes during focus revalidation or delivery is uncertain. + +## Behavior + +1. Accessibility missing → existing "allow Accessibility" copy (probe skipped). +2. Secure Input active → transcript written to the clipboard, no keystroke, and a + `copied` result with reason `secure-input`. +3. Probe failure or unexpected output → logged; transcript stays copied. +4. Clipboard restoration requires AXValue evidence of insertion; uncertain + delivery leaves the transcript available for manual paste. + +The pill shows a warning-tone shield icon, "Secure Input blocked paste", and +"Transcript copied — press ⌘V to paste." A screen-reader-only sentence explains +the cause. The result stays visible for 4 s instead of 1.2 s. + +## Tests + +- `main/services/dictation-paste.test.ts`: injectable detector (active, failing, + precedence), probe output mapping, live darwin probe. +- `main/services/dictation-coordinator.test.ts`: reason reaches the pill and the + hide delay outlasts a normal paste. +- `renderer/pill/pill-copied-notice.test.tsx`: rendered warning and fallbacks. + +## Follow-ups + +- Physical acceptance on supported macOS releases remains useful; the atomic + transaction now preserves transcripts when insertion cannot be confirmed. +- Optionally surface the condition in Settings → Dictation diagnostics. + +Review refinement: a missing/non-string AXValue does not prevent a paste attempt after focus validation. If delivery cannot be confirmed (including text normalization), the pill asks the user to check the field and preserves the transcript rather than instructing a second paste. diff --git a/main/services/dictation-coordinator.test.ts b/main/services/dictation-coordinator.test.ts index 87d58c32b..98bcba58e 100644 --- a/main/services/dictation-coordinator.test.ts +++ b/main/services/dictation-coordinator.test.ts @@ -282,6 +282,40 @@ test("cleanup failures still paste the original transcript", async () => { ); }); +test("a Secure Input copy result reaches the pill and lingers longer than a paste", async () => { + async function deliver(result: Awaited>) { + const delays: number[] = []; + const subject = harness({ + paste: async () => result, + setTimer: (_callback, delayMs) => { + delays.push(delayMs); + return dormantTimer(); + }, + }); + await subject.coordinator.ready(); + await subject.coordinator.press(); + await subject.coordinator.press(); + const before = delays.length; + await subject.coordinator.result("hello there", subject.coordinator.currentOperationId!); + assert.ok(delays.length > before, "delivery schedules the pill hide"); + return { events: subject.events, hideDelay: delays[delays.length - 1]! }; + } + + const secure = await deliver({ + outcome: "copied", + reason: "secure-input", + message: "Transcript copied — press ⌘V to paste.", + }); + const pasted = await deliver({ outcome: "pasted" }); + assert.deepEqual(secure.events[secure.events.length - 1], { + state: "copied", + operationId: secure.events[0]!.operationId, + reason: "secure-input", + message: "Transcript copied — press ⌘V to paste.", + }); + assert.ok(secure.hideDelay > pasted.hideDelay); +}); + test("hold release during cold startup is latched and stops after ready", async () => { const shown = deferred(); const subject = harness({ diff --git a/main/services/dictation-coordinator.ts b/main/services/dictation-coordinator.ts index fa3b5ea8e..4adeb784b 100644 --- a/main/services/dictation-coordinator.ts +++ b/main/services/dictation-coordinator.ts @@ -30,6 +30,7 @@ export interface DictationCoordinatorDeps { const RESULT_HIDE_DELAY_MS = 1_200; const ERROR_HIDE_DELAY_MS = 2_000; +export const WARNING_HIDE_DELAY_MS = 4_000; const MAX_TRANSCRIPT_LENGTH = 100_000; export const HOLD_RELEASE_GRACE_MS = 50; // Cloud renderers fail within 45 seconds. Parakeet owns a 120-second process @@ -354,15 +355,19 @@ export class DictationCoordinator { } const pasteResult = await this.deps.paste(transcript); const outcome = typeof pasteResult === "string" ? pasteResult : pasteResult.outcome; + const reason = typeof pasteResult === "string" ? undefined : pasteResult.reason; this.stage = "idle"; this.operationId = null; this.deps.broadcast({ state: outcome, operationId, - reason: typeof pasteResult === "string" ? undefined : pasteResult.reason, + reason, message: typeof pasteResult === "string" ? undefined : pasteResult.message, }); - this.scheduleHide(RESULT_HIDE_DELAY_MS); + // A Secure Input warning explains a manual next step; keep it readable. + this.scheduleHide( + reason === "secure-input" ? WARNING_HIDE_DELAY_MS : RESULT_HIDE_DELAY_MS, + ); } catch (error) { this.stage = "idle"; this.operationId = null; diff --git a/main/services/dictation-paste.test.ts b/main/services/dictation-paste.test.ts index 34b586958..3b9b171a4 100644 --- a/main/services/dictation-paste.test.ts +++ b/main/services/dictation-paste.test.ts @@ -7,7 +7,10 @@ import test from "node:test"; import { promisify } from "node:util"; import { ATOMIC_PASTE_SCRIPT, + detectMacSecureInput, pasteTranscript, + runJxa, + SECURE_INPUT_PROBE_SCRIPT, type PasteDeps, } from "./dictation-paste.js"; @@ -21,6 +24,7 @@ function harness(overrides: Partial = {}) { clipboard = text; }, isAccessibilityTrusted: () => true, + isSecureInputActive: async () => false, pasteWithPreservedClipboard: async (text) => { pastedText = text; return true; @@ -38,6 +42,14 @@ test("native paste transaction preserves all pasteboard representations and rech assert.match(ATOMIC_PASTE_SCRIPT, /quietWindow/); assert.match(ATOMIC_PASTE_SCRIPT, /is not transcriptText then return "pasted"/); assert.match(ATOMIC_PASTE_SCRIPT, /set the clipboard to previousClipboard/); + assert.match( + ATOMIC_PASTE_SCRIPT, + /deliveredValue is originalValue or deliveredValue does not contain transcriptText then return "copied"/, + ); + assert.ok( + ATOMIC_PASTE_SCRIPT.indexOf("deliveredValue is originalValue") < + ATOMIC_PASTE_SCRIPT.indexOf("set the clipboard to previousClipboard"), + ); }); test( @@ -95,10 +107,25 @@ test("focus changes degrade to the clipboard result returned by the native trans assert.deepEqual(await pasteTranscript("hello world", subject.deps), { outcome: "copied", reason: "paste-unavailable", - message: "Copied — the original text field was no longer focused.", + message: "Check the field — transcript copied.", }); }); +test("an unconfirmed paste never tells the user to insert the transcript again", async () => { + let textMayAlreadyBeInserted = false; + const subject = harness({ + pasteWithPreservedClipboard: async () => { + textMayAlreadyBeInserted = true; + return false; + }, + }); + const result = await pasteTranscript("Smart quotes may transform this text", subject.deps); + assert.equal(textMayAlreadyBeInserted, true); + assert.equal(result.outcome, "copied"); + assert.equal(result.message, "Check the field — transcript copied."); + assert.doesNotMatch(result.message ?? "", /press|⌘V|couldn.t paste/i); +}); + test("paste failures leave the transcript on the clipboard instead of throwing", async () => { const subject = harness({ pasteWithPreservedClipboard: async () => { @@ -108,7 +135,90 @@ test("paste failures leave the transcript on the clipboard instead of throwing", assert.deepEqual(await pasteTranscript("hello world", subject.deps), { outcome: "copied", reason: "paste-unavailable", - message: "Copied — Aiden couldn’t paste into the focused app.", + message: "Check the field — transcript copied.", }); assert.equal(subject.clipboard(), "hello world"); }); + +test("active Secure Input keeps the transcript on the clipboard without sending a keystroke", async () => { + let attempts = 0; + const subject = harness({ + isSecureInputActive: async () => true, + pasteWithPreservedClipboard: async () => { + attempts += 1; + return true; + }, + }); + const result = await pasteTranscript("my secret note", subject.deps); + assert.equal(result.outcome, "copied"); + assert.equal(result.reason, "secure-input"); + assert.match(result.message ?? "", /⌘V/); + assert.equal(subject.clipboard(), "my secret note"); + assert.equal(attempts, 0); +}); + +test("missing Accessibility access takes precedence over Secure Input detection", async () => { + let probes = 0; + const subject = harness({ + isAccessibilityTrusted: () => false, + isSecureInputActive: async () => { + probes += 1; + return true; + }, + }); + const result = await pasteTranscript("hello world", subject.deps); + assert.equal(result.reason, "accessibility-required"); + assert.equal(probes, 0); +}); + +test("a failed Secure Input probe preserves the transcript without attempting paste", async () => { + const logged: string[] = []; + const subject = harness({ + isSecureInputActive: async () => { + throw new Error("osascript timed out"); + }, + log: (message) => logged.push(message), + }); + assert.equal((await pasteTranscript("hello world", subject.deps)).outcome, "copied"); + assert.equal(subject.pastedText(), ""); + assert.equal(subject.clipboard(), "hello world"); + assert.equal(logged.length, 1); +}); + +test("Secure Input detection maps probe output and rejects unrecognized output", async () => { + const probe = (output: string) => async () => `${output}\n`; + assert.equal(await detectMacSecureInput(probe("secure")), true); + assert.equal(await detectMacSecureInput(probe("clear")), false); + await assert.rejects(detectMacSecureInput(probe("execution error: -2700"))); + await assert.rejects( + detectMacSecureInput(async () => { + throw new Error("spawn failed"); + }), + ); +}); + +test( + "Secure Input probe runs against the documented Carbon API", + { skip: process.platform !== "darwin" }, + async () => { + assert.match(await runJxa(SECURE_INPUT_PROBE_SCRIPT), /^(secure|clear)$/); + }, +); + +test( + "documented Secure Input probe follows a process-owned enable/disable cycle", + { skip: process.platform !== "darwin" }, + async () => { + const result = await runJxa(`ObjC.import("Carbon"); +var before = Boolean($.IsSecureEventInputEnabled()); +var status = $.EnableSecureEventInput(); +if (status !== 0) throw new Error("Could not enable Secure Input for test"); +var enabled; +try { enabled = Boolean($.IsSecureEventInputEnabled()); } +finally { $.DisableSecureEventInput(); } +JSON.stringify({before: before, enabled: enabled, after: Boolean($.IsSecureEventInputEnabled())});`); + const state = JSON.parse(result) as { before: boolean; enabled: boolean; after: boolean }; + assert.equal(state.enabled, true); + assert.equal(state.after, state.before); + }, +); diff --git a/main/services/dictation-paste.ts b/main/services/dictation-paste.ts index 244d49307..69494cceb 100644 --- a/main/services/dictation-paste.ts +++ b/main/services/dictation-paste.ts @@ -3,30 +3,44 @@ // and restores only when the transcript is still on the clipboard. import { execFile } from "node:child_process"; +import type { DictationCopiedReason } from "../../renderer/shared/dictation.js"; export type PasteOutcome = "pasted" | "copied"; export interface PasteDeliveryResult { outcome: PasteOutcome; - reason?: "accessibility-required" | "paste-unavailable"; + reason?: DictationCopiedReason; message?: string; } export interface PasteDeps { writeClipboard: (text: string) => void; isAccessibilityTrusted: () => boolean; + /** + * macOS Secure Event Input (password fields, a terminal's Secure Keyboard + * Entry, some password managers) silently drops synthetic keystrokes, so a + * paste would look successful while nothing arrives. Resolves true while + * any process in the console session holds it. + */ + isSecureInputActive: () => Promise; pasteWithPreservedClipboard: (text: string) => Promise; log?: (message: string, error?: unknown) => void; } +export const SECURE_INPUT_COPIED_MESSAGE = "Transcript copied — press ⌘V to paste."; + export const ATOMIC_PASTE_SCRIPT = `on run argv set transcriptText to item 1 of argv set previousClipboard to the clipboard as record + set originalValue to missing value tell application "System Events" try set targetProcess to first process whose frontmost is true set targetPid to unix id of targetProcess set targetElement to value of attribute "AXFocusedUIElement" of targetProcess set targetRole to role of targetElement as text + try + set originalValue to value of attribute "AXValue" of targetElement as text + end try on error set the clipboard to transcriptText return "copied" @@ -68,6 +82,15 @@ export const ATOMIC_PASTE_SCRIPT = `on run argv return "pasted" end try if elapsed is greater than or equal to quietWindow then + if originalValue is missing value then return "copied" + try + tell application "System Events" + set deliveredValue to value of attribute "AXValue" of targetElement as text + end tell + if deliveredValue is originalValue or deliveredValue does not contain transcriptText then return "copied" + on error + return "copied" + end try try if (the clipboard as text) is transcriptText then set the clipboard to previousClipboard end try @@ -77,6 +100,36 @@ export const ATOMIC_PASTE_SCRIPT = `on run argv return "pasted" end run`; +/** Query the documented Carbon API rather than an undocumented session key. */ +export const SECURE_INPUT_PROBE_SCRIPT = `ObjC.import("Carbon"); +ObjC.bindFunction("IsSecureEventInputEnabled", ["bool", []]); +$.IsSecureEventInputEnabled() ? "secure" : "clear";`; + +export type RunJxa = (script: string) => Promise; + +/** Run a JavaScript for Automation snippet with a short timeout. */ +export function runJxa(script: string): Promise { + return new Promise((resolve, reject) => { + execFile( + "/usr/bin/osascript", + ["-l", "JavaScript", "-e", script], + { timeout: 2_000 }, + (error, stdout) => { + if (error) reject(error); + else resolve(stdout.trim()); + }, + ); + }); +} + +/** Detect macOS Secure Event Input. Rejects when the probe output is unrecognized. */ +export async function detectMacSecureInput(run: RunJxa = runJxa): Promise { + const output = (await run(SECURE_INPUT_PROBE_SCRIPT)).trim(); + if (output === "secure") return true; + if (output === "clear") return false; + throw new Error(`Unexpected Secure Input probe output: ${output.slice(0, 80)}`); +} + /** Run an AppleScript handler with data passed as argv, never interpolated. */ export function runOsascript(script: string, args: string[] = []): Promise { return new Promise((resolve, reject) => { @@ -97,13 +150,11 @@ export async function runAtomicMacPaste(text: string): Promise { } /** - * Deliver a finished transcript. Without Accessibility access or after any - * failure, the transcript remains available on the clipboard. + * Deliver a finished transcript. Without Accessibility access, while macOS + * Secure Input would swallow the keystroke, or after any failure, the + * transcript remains available on the clipboard. */ -export async function pasteTranscript( - text: string, - deps: PasteDeps, -): Promise { +export async function pasteTranscript(text: string, deps: PasteDeps): Promise { if (!deps.isAccessibilityTrusted()) { deps.writeClipboard(text); return { @@ -112,13 +163,29 @@ export async function pasteTranscript( message: "Copied — allow Accessibility to paste automatically.", }; } + let secureInput = false; + try { + secureInput = await deps.isSecureInputActive(); + } catch (error) { + deps.log?.("Secure Input detection failed; transcript left on the clipboard.", error); + deps.writeClipboard(text); + return { outcome: "copied", reason: "paste-unavailable", message: SECURE_INPUT_COPIED_MESSAGE }; + } + if (secureInput) { + deps.writeClipboard(text); + return { + outcome: "copied", + reason: "secure-input", + message: SECURE_INPUT_COPIED_MESSAGE, + }; + } try { return (await deps.pasteWithPreservedClipboard(text)) ? { outcome: "pasted" } : { outcome: "copied", reason: "paste-unavailable", - message: "Copied — the original text field was no longer focused.", + message: "Check the field — transcript copied.", }; } catch (error) { deps.writeClipboard(text); @@ -126,7 +193,7 @@ export async function pasteTranscript( return { outcome: "copied", reason: "paste-unavailable", - message: "Copied — Aiden couldn’t paste into the focused app.", + message: "Check the field — transcript copied.", }; } } diff --git a/main/services/dictation.ts b/main/services/dictation.ts index 2160392c2..fc05b8b3e 100644 --- a/main/services/dictation.ts +++ b/main/services/dictation.ts @@ -12,7 +12,12 @@ import { shouldAcceptDictationPress } from "./dictation-hotkey.js"; import { watchMacKeyUntilUp } from "./dictation-key-state.js"; import { acceleratorPrimaryMacKeyCode } from "./dictation-keycode.js"; import { dictationPlatformBehavior } from "./dictation-platform.js"; -import { pasteTranscript, runAtomicMacPaste, type PasteDeps } from "./dictation-paste.js"; +import { + detectMacSecureInput, + pasteTranscript, + runAtomicMacPaste, + type PasteDeps, +} from "./dictation-paste.js"; import { DictationCoordinator } from "./dictation-coordinator.js"; import { activeLinuxDictationHoldShortcut, initLinuxDictationSessionLost, subscribeLinuxDictationRelease } from "./shortcut.js"; @@ -28,6 +33,9 @@ function livePasteDeps(): PasteDeps { isAccessibilityTrusted: () => behavior.accessibilityPaste && systemPreferences.isTrustedAccessibilityClient(false), + isSecureInputActive: behavior.accessibilityPaste + ? () => detectMacSecureInput() + : async () => false, pasteWithPreservedClipboard: behavior.accessibilityPaste ? runAtomicMacPaste : async () => false, diff --git a/package.json b/package.json index 84da9ab7d..a7b9f6613 100644 --- a/package.json +++ b/package.json @@ -125,7 +125,7 @@ "test:telegram": "tsx --test main/services/telegram/telegram-profile-mutation-fence.test.ts main/services/telegram/telegram-controls.test.ts main/services/telegram/telegram-inbound.test.ts main/services/telegram/telegram-outbound.test.ts main/services/telegram/telegram-queue.test.ts main/services/telegram/telegram-markdown.test.ts main/services/telegram/telegram-bot-api.test.ts main/services/telegram/telegram-turn.test.ts main/services/telegram/telegram-session.test.ts main/services/telegram/telegram-service-core.test.ts main/services/telegram/telegram-workspace-core.test.ts main/services/telegram/telegram-activity.test.ts main/services/telegram/telegram-profile-config.test.ts main/services/telegram/telegram-extension-registry.test.ts main/services/telegram/telegram-thread-store.test.ts main/services/telegram/telegram-ownership.test.ts main/services/telegram/telegram-agent-tools.test.ts main/services/telegram/telegram-bot-binding-store.test.ts main/services/telegram/telegram-bot-chat-lifecycle.test.ts main/services/telegram/telegram-bot-binding-reconciliation.test.ts main/services/telegram/telegram-bot-binding-validation.test.ts main/services/telegram/telegram-bot-binding-authority.test.ts renderer/lib/telegram-workspace-options.test.ts", "test:bots": "tsx --test main/services/telegram/telegram-bot-chat-lifecycle.test.ts main/services/telegram/telegram-bot-binding-reconciliation.test.ts main/services/telegram/telegram-bot-binding-validation.test.ts main/services/telegram/telegram-bot-binding-authority.test.ts main/services/bot-avatar-generator-core.test.ts main/services/bot-avatar-operation-registry.test.ts main/services/bot-avatar-store.test.ts main/services/bot-store-core.test.ts main/services/bot-chat-store.test.ts main/services/bot-mutation-gate.test.ts main/services/bot-inbox-projection.test.ts main/services/bot-system-prompt.test.ts main/services/bot-generation-preparation.test.ts main/services/bot-inbound-attachment-home.test.ts main/services/bot-file-tool-router.test.ts main/services/bot-tool-authority.test.ts main/services/bot-capability-store-core.test.ts main/services/bot-capability-store.test.ts main/services/bot-capability-state-checkpoint.test.ts main/services/bot-capability-keychain-anchor.test.ts main/services/bot-capability-secret-service-anchor.test.ts main/services/bot-capability-lease.test.ts main/services/bot-runtime-inventory-lease.test.ts main/services/bot-runtime-inventory-publication.test.ts main/services/bot-runtime-authority.test.ts main/services/bot-capability-catalog-core.test.ts main/services/bot-capability-bindings.test.ts main/services/bot-capability-key-store.test.ts main/services/bot-capability-migration-seal.test.ts main/services/bot-capability-incarnation-store.test.ts main/services/bot-capability-inventory-ports.test.ts main/services/bot-capability-production-shape.test.ts main/services/bot-mcp-inventory.test.ts main/services/bot-skill-inventory.test.ts main/services/bot-skill-content-watcher.test.ts main/services/bot-managed-workspace-core.test.ts main/services/bot-lifecycle-journal-core.test.ts main/services/bot-application-service.test.ts main/handlers/bot-params.test.ts main/handlers/bots.contract.test.ts renderer/main/bots-view.test.tsx renderer/lib/model-picker-data.test.ts renderer/lib/command-system-core.test.ts renderer/shared/bot-capabilities.test.ts", "test:bots:coverage": "node scripts/run-registered-tests-with-coverage.mjs test:bots", - "test:voice": "tsx --test main/services/dictation-hold-settings.test.ts main/services/linux-dictation-portal.test.ts main/services/transcription-core.test.ts main/services/gemini-live-transcription-core.test.ts main/services/dictation-coordinator.test.ts main/services/dictation-paste.test.ts main/services/parakeet-protocol.test.ts main/services/parakeet-process-core.test.ts main/services/parakeet-transcription-lane.test.ts renderer/shared/voice-models.test.ts renderer/shared/gemini-usage-scope.test.ts renderer/components/settings/gemini-voice-setup.test.tsx renderer/lib/accessibility-permission-core.test.ts renderer/lib/accessibility-refresh.test.ts renderer/lib/dictation-operation-gate.test.ts renderer/lib/gemini-recorded-retry.test.ts renderer/lib/live-pcm-capture.test.ts renderer/lib/voice-recorder-core.test.ts renderer/lib/wav-audio.test.ts", + "test:voice": "tsx --test main/services/dictation-hold-settings.test.ts main/services/linux-dictation-portal.test.ts main/services/transcription-core.test.ts main/services/gemini-live-transcription-core.test.ts main/services/dictation-coordinator.test.ts main/services/dictation-paste.test.ts main/services/parakeet-protocol.test.ts main/services/parakeet-process-core.test.ts main/services/parakeet-transcription-lane.test.ts renderer/shared/voice-models.test.ts renderer/shared/gemini-usage-scope.test.ts renderer/components/settings/gemini-voice-setup.test.tsx renderer/lib/accessibility-permission-core.test.ts renderer/lib/accessibility-refresh.test.ts renderer/lib/dictation-operation-gate.test.ts renderer/lib/gemini-recorded-retry.test.ts renderer/lib/live-pcm-capture.test.ts renderer/lib/voice-recorder-core.test.ts renderer/lib/wav-audio.test.ts renderer/pill/pill-copied-notice.test.tsx", "test:tts": "tsx --test main/services/tts/authority-contract.test.ts renderer/components/settings/tts-settings.test.tsx main/services/tts/audio-store.test.ts renderer/lib/tts-client.test.ts renderer/lib/tts-player.test.ts main/services/tts/speech-text.test.ts main/services/tts/source.test.ts main/services/tts/gemini-wire.test.ts main/services/tts/service.test.ts main/services/tts/gemini-provider.loopback.test.ts renderer/components/message-bubble.test.tsx renderer/shared/tts.test.ts", "test:diagnostics": "tsx --test main/services/diagnostics-contract.test.ts main/services/diagnostic-health.test.ts main/services/diagnostic-journal.test.ts main/services/diagnostic-support.test.ts main/services/dev-log.test.ts main/services/process-diagnostics.test.ts main/services/renderer-crash-recovery.test.ts main/services/renderer-diagnostic-rate.test.ts main/services/subagents/subagent-runtime-diagnostics.test.ts renderer/components/settings/diagnostics-settings.test.tsx && node --test scripts/diagnostic-policy.test.mjs", "diagnostics:failure-receipt": "node scripts/write-diagnostic-failure-receipt.mjs", diff --git a/renderer/pill/pill-app.tsx b/renderer/pill/pill-app.tsx index 42aa2aea9..bca2750cc 100644 --- a/renderer/pill/pill-app.tsx +++ b/renderer/pill/pill-app.tsx @@ -5,7 +5,8 @@ // it on the clipboard. import * as React from "react"; -import { Check, ClipboardCopy, Loader2, X } from "lucide-react"; +import { Check, Loader2, X } from "lucide-react"; +import { PillCopiedNotice, type PillCopiedNoticeProps } from "./pill-copied-notice"; import { dictationApi, onNotification, settingsApi } from "../lib/ipc"; import type { DictationStatePayload } from "../shared/dictation"; import { @@ -70,7 +71,7 @@ export function PillApp() { const [phase, setPhase] = React.useState("idle"); const [errorMessage, setErrorMessage] = React.useState(""); const [recordingHint, setRecordingHint] = React.useState(""); - const [copiedMessage, setCopiedMessage] = React.useState("Copied to clipboard"); + const [copiedNotice, setCopiedNotice] = React.useState({}); const [elapsed, setElapsed] = React.useState(0); const [liveTranscript, setLiveTranscript] = React.useState({ committed: "", @@ -419,12 +420,7 @@ export function PillApp() { break; case "copied": if (soundsEnabledRef.current) void playDictationCue("success"); - setCopiedMessage( - payload.message ?? - (payload.reason === "accessibility-required" - ? "Copied — allow Accessibility to paste" - : "Copied to clipboard"), - ); + setCopiedNotice({ reason: payload.reason, message: payload.message }); setPhase("copied"); break; case "error": @@ -573,10 +569,7 @@ export function PillApp() { Pasted ) : phase === "copied" ? ( - <> - - {copiedMessage} - + ) : ( {errorMessage} diff --git a/renderer/pill/pill-copied-notice.test.tsx b/renderer/pill/pill-copied-notice.test.tsx new file mode 100644 index 000000000..b97b22974 --- /dev/null +++ b/renderer/pill/pill-copied-notice.test.tsx @@ -0,0 +1,55 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { renderToStaticMarkup } from "react-dom/server"; +import { PillCopiedNotice } from "./pill-copied-notice.js"; + +function visibleText(markup: string): string { + return markup + .replace(/.*?<\/span>/g, "") + .replace(/<[^>]+>/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +test("Secure Input copy explains why paste was blocked and what to do next", () => { + const markup = renderToStaticMarkup( + , + ); + const text = visibleText(markup); + assert.match(text, /Secure Input blocked paste/); + assert.match(text, /press ⌘V to paste/); + // Assistive technology hears the cause, not just the symptom. + assert.match(markup, /Secure Input is on[^<]*password field/); +}); + +test("Secure Input copy still explains itself when the main process omits a message", () => { + const text = visibleText(renderToStaticMarkup()); + assert.match(text, /Secure Input blocked paste/); + assert.match(text, /⌘V/); +}); + +test("other copy results keep their plain message without a Secure Input warning", () => { + const accessibility = visibleText( + renderToStaticMarkup(), + ); + assert.equal(accessibility, "Copied — allow Accessibility to paste"); + + const custom = visibleText( + renderToStaticMarkup( + , + ), + ); + assert.equal(custom, "Copied — the original text field was no longer focused."); + assert.equal(visibleText(renderToStaticMarkup()), "Copied to clipboard"); +}); + +test("unconfirmed delivery asks the user to inspect the field without pasting twice", () => { + const text = visibleText(renderToStaticMarkup( + , + )); + assert.equal(text, "Check the field — transcript copied."); + assert.doesNotMatch(text, /⌘V|press|blocked|couldn.t paste/i); +}); diff --git a/renderer/pill/pill-copied-notice.tsx b/renderer/pill/pill-copied-notice.tsx new file mode 100644 index 000000000..2b34df3d5 --- /dev/null +++ b/renderer/pill/pill-copied-notice.tsx @@ -0,0 +1,44 @@ +// Result shown in the dictation pill when a transcript was left on the +// clipboard instead of being pasted automatically. + +import { ClipboardCopy, ShieldAlert } from "lucide-react"; +import type { DictationCopiedReason } from "../shared/dictation"; + +export interface PillCopiedNoticeProps { + reason?: DictationCopiedReason; + message?: string; +} + +const SECURE_INPUT_EXPLANATION = + "macOS Secure Input is on — usually a password field or a terminal's Secure Keyboard Entry — so automatic paste would be blocked."; + +function copiedMessage({ reason, message }: PillCopiedNoticeProps): string { + if (message) return message; + if (reason === "accessibility-required") return "Copied — allow Accessibility to paste"; + if (reason === "secure-input") return "Transcript copied — press ⌘V to paste."; + return "Copied to clipboard"; +} + +export function PillCopiedNotice(props: PillCopiedNoticeProps) { + const detail = copiedMessage(props); + if (props.reason === "secure-input") { + // Warning, not error: the transcript is safe on the clipboard. Status color + // stays in the icon and label per the design guide. + return ( + <> +