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
66 changes: 66 additions & 0 deletions ai/daemons/wake/localWakeAdapters.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -588,6 +588,57 @@ async function deliverWebhook({digest, effects, meta, signal}) {
if (!response.ok) throw new Error(`local webhook adapter failed with HTTP ${response.status}`);
}

/**
* @summary Builds the dialog-gate probe argv: one read-only System Events query that asks
* whether the target seat's focused UI element is a text-entry field.
*
* A pending interactive prompt (selection dialog, permission sheet) moves focus off the composer's
* text area; injecting keystrokes then feeds the wake TO the dialog, which submits it as the
* operator's answer — the destroyed-intent failure this gate exists to prevent. Reading before
* writing is the smallest delta that honors the temporal half of the verified-process invariant.
*
* Failure semantics are deliberately split:
* - readable role that is NOT a text field → `interactive dialog pending` error → the caller DEFERS;
* - unreadable state (`missing value`) or any probe throw that does not name a dialog → the caller
* FAILS OPEN and delivers, because silent non-delivery is the dead-realm failure mode and the
* mailbox stays authoritative.
*
* The `-- interactiveDialogProbe` comment is load-bearing twice over: it marks the emitted argv for
* red-capable fixtures, and it names the gate for receiver logs triaging a deferred wake.
*
* @param {Object} config
* @param {String} config.appName Canonical harness app name.
* @param {Number|null} config.instancePid Resolved seat pid when addressType is pid/userDataDir.
* @returns {String[]} `osascript` argv fragments.
* @private
*/
function buildDialogGateArgs({appName, instancePid}) {
const escapedAppName = escapeAppleScript(appName);
const targetPid = instancePid ? String(instancePid) : '';

return [
'-e', ` set targetAppName to "${escapedAppName}"`,
'-e', ' set targetBundleId to ""',
'-e', ' try',
'-e', ` set targetBundleId to id of application "${escapedAppName}"`,
'-e', ' end try',
'-e', ` set targetProcessId to "${targetPid}"`,
'-e', ' -- interactiveDialogProbe: readable non-text focus means a prompt owns the input path',
'-e', ' tell application "System Events"',
...resolveTargetProcessLines(' '),
'-e', ' tell targetProcess',
'-e', ' set focusedRole to missing value',
'-e', ' try',
'-e', ' set focusedRole to role of focused element of window 1',
'-e', ' end try',
'-e', ' if focusedRole is not missing value and focusedRole is not in {"AXTextArea", "AXTextField"} then',
'-e', ' error "interactive dialog pending at phase before input"',
'-e', ' end if',
'-e', ' end tell',
'-e', ' end tell'
];
}

/**
* @summary Delivers through the existing draft-preserving, frontmost-verified macOS path.
* @private
Expand Down Expand Up @@ -623,6 +674,21 @@ async function deliverOsascript({digest, effects, meta, record}) {
instancePid = target.pid;
}

// Dialog gate — read before writing. A readable non-text focus means a pending
// interactive prompt owns the input path: defer (the receiver parks and reschedules under a
// bound) rather than type the wake into the operator's dialog. Any probe failure that does not
// name a dialog fails open into normal delivery.
try {
await effects.spawnAsync('osascript', buildDialogGateArgs({appName, instancePid}));
} catch (error) {
if (/interactive dialog pending/.test(String(error?.message || ''))) {
return {outcome: 'deferred', outcomeReason: 'interactive-dialog-pending'};
}
effects.log.warn?.(
`[Wake Receiver] dialog gate could not probe ${record.subscriptionId}; delivering fail-open`
);
}

const args = buildOsascriptArgs({
appName,
digest,
Expand Down
50 changes: 49 additions & 1 deletion ai/daemons/wake/receiver.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,15 @@ import {evaluateContextGate} from './contextGatePolicy.mjs';
import {dispatchLocalWake, probeSessionContext} from './localWakeAdapters.mjs';

const DEFAULT_MAX_BODY_BYTES = 256 * 1024;

/**
* Dialog-gate bound: how many times a wake deferred for a pending interactive prompt may
* re-park before it exhausts into an observable failure. Dialogs are transient (seconds to
* minutes), so the default covers a pending question across many drain cycles; a seat parked on a
* dialog for good surfaces as `dialog-defer-bound-exhausted` rather than parking silently.
* @type {Number}
*/
const DIALOG_DEFER_BOUND = 20;
/**
* Coalesces the burst of filesystem events one atomic publish produces into a single reload.
* @type {Number}
Expand Down Expand Up @@ -315,10 +324,49 @@ export function createWakeReceiver({
outcomeReason = String(result.outcomeReason);
}

if (!['delivered', 'skipped', 'failed', 'unknown'].includes(outcome)) {
if (!['delivered', 'skipped', 'failed', 'unknown', 'deferred'].includes(outcome)) {
outcomeReason = `invalid-adapter-outcome:${String(outcome)}`;
outcome = 'failed';
}

// The dialog gate: an adapter that read a pending interactive prompt on
// the target seat DEFERS instead of typing the wake into the operator's dialog.
// Same park-and-reschedule contract as the context gate above, but bounded —
// a dialog is a transient state, so the wake retries on subsequent drains until
// either the seat returns to composer state (deliver, envelope identity intact)
// or the bound exhausts into an OBSERVABLE failure. A defer that can park
// forever is the silent non-delivery failure mode wearing a polite name.
if (outcome === 'deferred') {
const deferCount = (dispatching.deferCount || 0) + 1;

if (deferCount > DIALOG_DEFER_BOUND) {
const exhausted = `dialog-defer-bound-exhausted:${outcomeReason || 'unknown'}`;

await state.transition(record.recordKey, 'dispatching', 'failed', {
outcomeReason : exhausted,
deferCount,
dispatchFinishedAt: new Date().toISOString()
});
logger.error?.(
`[Wake Receiver] dialog gate EXHAUSTED ${record.subscriptionId} after ` +
`${deferCount} deferrals (${outcomeReason}); surfacing as failed — ` +
'the mailbox stays authoritative'
);
continue;
}

await state.transition(record.recordKey, 'dispatching', 'pending', {
deferCount,
deferredAt : new Date().toISOString(),
deferReason: outcomeReason || 'interactive-dialog-pending'
});
logger.warn?.(
`[Wake Receiver] dialog gate DEFERRED ${record.subscriptionId}: a pending ` +
`interactive prompt holds the seat's input path; the wake waits for ` +
`composer state (defer #${deferCount}/${DIALOG_DEFER_BOUND})`
);
continue;
}
} catch (error) {
outcomeReason = error?.code || error?.name || 'adapter-error';
outcome = 'failed';
Expand Down
16 changes: 12 additions & 4 deletions test/playwright/unit/ai/daemons/wake/localWakeAdapters.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -311,7 +311,8 @@ test.describe.serial('ai/daemons/wake/localWakeAdapters', () => {
});

test('a post-submit draft-restore focus race is delivered and never retried', async () => {
let attempts = 0;
let probeAttempts = 0;
let deliveryAttempts = 0;
const uiRecord = record('osascript', {
route: {
agentIdentity,
Expand All @@ -323,12 +324,19 @@ test.describe.serial('ai/daemons/wake/localWakeAdapters', () => {
expect(await dispatchLocalWake(uiRecord, {
platform : 'darwin',
getDefaultTarget: async () => ({status: 'resolved', pid: 4321, instanceCount: 1, bundleName: 'Claude'}),
spawnAsync : async () => {
attempts++;
// The dialog-gate probe is read-only and runs once before delivery; its own
// failure must fail OPEN without consuming the delivery retry budget.
spawnAsync : async (command, args) => {
if (args.some(a => String(a).includes('interactiveDialogProbe'))) {
probeAttempts++;
throw new Error('AX tree unavailable for dialog probe');
}
deliveryAttempts++;
throw new Error('Target app lost frontmost status before user input restore paste (-2700)');
}
})).toBe('delivered');
expect(attempts).toBe(1);
expect(deliveryAttempts).toBe(1);
expect(probeAttempts).toBe(1);
});

test('a terminal osascript failure reports the captured stderr, in the log and on the outcome (#16259)', async () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
import {test, expect} from '@playwright/test';

import {dispatchLocalWake} from '../../../../../../ai/daemons/wake/localWakeAdapters.mjs';
import {createWakeReceiver} from '../../../../../../ai/daemons/wake/receiver.mjs';
import {WakeReceiverState, getWakeRecordKey} from '../../../../../../ai/daemons/wake/receiverState.mjs';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';

/**
* A wake arriving while the target seat holds a pending interactive prompt must not be
* typed into that prompt. Red-capable pair: pre-fix, the delivery argv executed and the envelope
* became the dialog's answer; post-fix, the adapter classifies the dialog-pending state as a
* DEFERRED outcome and the receiver parks-and-reschedules it under a bounded count.
*/

const DIALOG_PENDING_MESSAGE = 'interactive dialog pending at phase before input';

const baseRecord = subscriptionId => ({
subscriptionId,
envelope: {
payload : {totalEvents: 1, latestMessage: {subject: 'probe', priority: 'normal'}},
identity: '@neo-preview'
},
route: {
agentIdentity : '@neo-preview',
harnessTargetMetadata: {
adapter : 'osascript',
appName : 'Claude',
addressType : 'pid',
instanceAddress: '/Users/tobiasuhlig/Library/Application Support/Claude'
},
adapterConfig: {attemptTimeoutMs: 10_000}
}
});

const baseEffects = overrides => ({
platform : 'darwin',
log : {log() {}, warn() {}, error() {}},
homedir : os.homedir,
fs,
fetch : globalThis.fetch,
getDefaultTarget : async () => ({status: 'ok', pid: 4242}),
resolveGuiInstancePid: async () => 4242,
spawnAsync : async () => '',
...overrides
});

test.describe('#17629 — wake vs pending interactive dialog', () => {

test('adapter DEFERS instead of typing when the accessibility probe reports a prompt structure', async () => {
const seen = [];
const result = await dispatchLocalWake(baseRecord('WAKE_SUB:dialog-defer-a'), baseEffects({
spawnAsync: async (command, args) => {
seen.push({command, args});
// The final phase executes the paste+submit keystroke block; its presence proves
// typing happened. Pre-fix this line IS reached; post-fix it must never be.
if (args.some(a => String(a).includes('keystroke "v"'))) {
return '';
}
if (args.some(a => String(a).includes('interactiveDialogProbe'))) {
throw new Error(DIALOG_PENDING_MESSAGE);
}
return '';
}
}));

expect(result).toEqual({
outcome : 'deferred',
outcomeReason: 'interactive-dialog-pending'
});
const typed = seen.filter(s => s.args.some(a => String(a).includes('keystroke "v"')));
expect(typed).toHaveLength(0);
});

test('a composer-state probe passes through and delivery proceeds unchanged', async () => {
let pasteReached = false;
const result = await dispatchLocalWake(baseRecord('WAKE_SUB:dialog-defer-b'), baseEffects({
spawnAsync: async (command, args) => {
if (args.some(a => String(a).includes('interactiveDialogProbe'))) return 'composer';
if (args.some(a => String(a).includes('keystroke "v"'))) pasteReached = true;
return '';
}
}));

expect(result).toBe('delivered');
expect(pasteReached).toBe(true);
});

test('probe failure fails open (delivers) rather than withholding coordination', async () => {
let pasteReached = false;
const result = await dispatchLocalWake(baseRecord('WAKE_SUB:dialog-defer-c'), baseEffects({
spawnAsync: async (command, args) => {
if (args.some(a => String(a).includes('interactiveDialogProbe'))) {
throw new Error('AX probe unavailable');
}
if (args.some(a => String(a).includes('keystroke "v"'))) pasteReached = true;
return '';
}
}));

expect(result).toBe('delivered');
expect(pasteReached).toBe(true);
});
});

test.describe('#17629 — receiver parks and bounds deferred wakes', () => {

const buildReceiver = async dispatchImpl => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'wake-dialog-gate-'));
const state = new WakeReceiverState({stateDir: dir});
const manifest = {
schemaVersion: 1,
routes : {
'WAKE_SUB:dialog-defer-r': {
agentIdentity : '@neo-preview',
signingKey : 'k'.repeat(64),
harnessTargetMetadata: {adapter: 'test'},
adapterConfig : {attemptTimeoutMs: 1000}
}
}
};
const lines = [];
const receiver = createWakeReceiver({
manifest,
state,
dispatch: dispatchImpl,
logger : {warn: m => lines.push(m), error: m => lines.push(m), log: () => {}}
});
await state.init();
return {state, receiver, lines};
};

test('deferred outcome returns the record to pending with named defer metadata', async () => {
const {state, receiver} = await buildReceiver(async () => ({
outcome : 'deferred',
outcomeReason: 'interactive-dialog-pending'
}));

await state.accept({
subscriptionId: 'WAKE_SUB:dialog-defer-r',
eventId : 'evt-defer-a',
envelope : {payload: {totalEvents: 1}}
});

await receiver.drain();
const record = await state.read(getWakeRecordKey({subscriptionId: 'WAKE_SUB:dialog-defer-r', eventId: 'evt-defer-a'}));

expect(record.state).toBe('pending');
expect(record.deferCount).toBe(1);
expect(record.deferReason).toContain('interactive-dialog-pending');
});

test('the defer bound exhausts into an observable failure, never silent parking', async () => {
const {state, receiver, lines} = await buildReceiver(async () => ({
outcome : 'deferred',
outcomeReason: 'interactive-dialog-pending'
}));

await state.accept({
subscriptionId: 'WAKE_SUB:dialog-defer-r',
eventId : 'evt-defer-b',
envelope : {payload: {totalEvents: 1}}
});

let record;
for (let i = 0; i < 20; i++) {
await receiver.drain();
record = await state.read(getWakeRecordKey({subscriptionId: 'WAKE_SUB:dialog-defer-r', eventId: 'evt-defer-b'}));
expect(record.state).toBe('pending');
}
expect(record.deferCount).toBe(20);

await receiver.drain();
record = await state.read(getWakeRecordKey({subscriptionId: 'WAKE_SUB:dialog-defer-r', eventId: 'evt-defer-b'}));
expect(record.state).toBe('failed');
expect(record.outcomeReason).toContain('dialog-defer-bound-exhausted');
expect(lines.some(l => l.includes('WAKE_SUB:dialog-defer-r'))).toBe(true);
});

test('envelope identity survives the defer round-trip byte-for-byte', async () => {
let captured;
let calls = 0;
const {state, receiver} = await buildReceiver(async record => {
calls += 1;
if (calls === 1) return {outcome: 'deferred', outcomeReason: 'interactive-dialog-pending'};
captured = record;
return 'delivered';
});

const envelope = {payload: {totalEvents: 3, latestMessage: {subject: 'identity-probe'}}, signature: 'sig'};
await state.accept({
subscriptionId: 'WAKE_SUB:dialog-defer-r',
eventId : 'evt-defer-c',
envelope
});

await receiver.drain();
await receiver.drain();

expect(captured?.envelope).toEqual(envelope);
});
});
Loading