Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .memory/dictation-secure-input.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions docs/plans/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <tool>` 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. |
Expand Down
52 changes: 52 additions & 0 deletions docs/plans/dictation-secure-input-plan.md
Original file line number Diff line number Diff line change
@@ -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.
34 changes: 34 additions & 0 deletions main/services/dictation-coordinator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReturnType<DictationCoordinatorDeps["paste"]>>) {
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<boolean>();
const subject = harness({
Expand Down
9 changes: 7 additions & 2 deletions main/services/dictation-coordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
114 changes: 112 additions & 2 deletions main/services/dictation-paste.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -21,6 +24,7 @@ function harness(overrides: Partial<PasteDeps> = {}) {
clipboard = text;
},
isAccessibilityTrusted: () => true,
isSecureInputActive: async () => false,
pasteWithPreservedClipboard: async (text) => {
pastedText = text;
return true;
Expand All @@ -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"),
);
Comment on lines +45 to +52

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2 · logic] Change-detector test asserts verbatim buggy AppleScript implementation
Confidence: 0.85

The test uses assert.match to lock the exact string of the premature abort in ATOMIC_PASTE_SCRIPT rather than testing behavior, violating AGENTS.md test guidelines and failing if the race condition is fixed.

Repair: Remove the source-matching assertions and replace them with behavioral tests verifying paste outcomes and clipboard preservation.

Comment on lines +45 to +52

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2 · logic] Source-grep test assertions lock defective control flow and violate repository test rules
Confidence: 0.88

Growing assert.match assertions against ATOMIC_PASTE_SCRIPT violates the AGENTS.md rule against source-grepping tests and locks the defective control flow where deliveredValue is originalValue immediately exits.

Repair: Remove the string-matching assertions against ATOMIC_PASTE_SCRIPT and rely on behavioral tests for clipboard preservation and delivery outcome.

});

test(
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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);
},
);
Loading
Loading