Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
3a26dc2
feat(limrun): serve iOS alert get/wait/accept/dismiss from tree capture
thymikee Oct 15, 2026
12ffa00
fix(limrun): harden iOS alert detection against ambiguous sheets
thymikee Oct 15, 2026
9cdbae7
refactor(alert): drive every tree-based alert backend through one sha…
thymikee Oct 16, 2026
574f1bb
refactor(alert): ride main's deadline primitive with no sleep seam
thymikee Oct 16, 2026
b1d173d
test(limrun): pin the accept-once/second-get-none timeline and satisf…
thymikee Oct 16, 2026
6dfccf9
fix(alerts): resolve multi-grant sheets by a declared preference table
thymikee Oct 16, 2026
2860188
fix(alerts): type the undecided state and bound the seams each budget…
thymikee Oct 16, 2026
11094e7
fix(alerts): close the window holes the review found and cap the budget
thymikee Oct 16, 2026
2219000
fix(alerts): scope alert captures to the warm helper and split the un…
thymikee Oct 16, 2026
257d51b
fix(alerts): let only the latest answered capture claim a visible dialog
thymikee Oct 16, 2026
36fae55
fix(alerts): a deadline-straddling confirm capture cannot erase an an…
thymikee Oct 16, 2026
9696a08
test(alerts): pin the press cutoff's dispatched disclosure; review nits
thymikee Oct 16, 2026
6ac1fb6
fix(alerts): let started probes finish, rank Android grants by id
thymikee Oct 17, 2026
3920f94
fix(alerts): keep the mechanics façade lazy, honor cancel at every po…
thymikee Oct 17, 2026
bb2fc48
docs(alerts): state the real lazy-load constraint, cancel truth, and …
thymikee Oct 17, 2026
09ba2dd
fix(alerts): type the transport-death cancel as pre-press, and make t…
thymikee Oct 17, 2026
e3ac15c
docs(capture-kit): scope the transport-death cancel comment to the ro…
thymikee Oct 17, 2026
18c1a29
fix(alerts): state each permissioncontroller grant's role and rank in…
thymikee Oct 18, 2026
ab7c9fc
fix(alerts): refuse every undeclared permission grant instead of pres…
thymikee Oct 18, 2026
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
4 changes: 4 additions & 0 deletions packages/capture-kit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@
"types": "./src/snapshot/android-replacement-surface-occlusion.ts",
"default": "./src/snapshot/android-replacement-surface-occlusion.ts"
},
"./alert-tree-driver": {
"types": "./src/alert-tree-driver.ts",
"default": "./src/alert-tree-driver.ts"
},
"./app-log-durable-resource": {
"types": "./src/capture-admission/app-log-durable-resource.ts",
"default": "./src/capture-admission/app-log-durable-resource.ts"
Expand Down
443 changes: 443 additions & 0 deletions packages/capture-kit/src/alert-tree-driver.ts

Large diffs are not rendered by default.

107 changes: 107 additions & 0 deletions packages/contracts/src/alert-contract.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { describe, expect, it } from 'vitest';
import { classifyAlertButtonRole, selectRankedAlertButtonByRole } from './alert-contract.ts';

type Button = { label: string; role: ReturnType<typeof classifyAlertButtonRole> };
const button = (label: string): Button => ({ label, role: classifyAlertButtonRole(label) });

describe('selectRankedAlertButtonByRole', () => {
it('resolves distinct grants by the declared preference order', () => {
const buttons = [button("Don't Allow"), button('Allow Once'), button('Allow While Using App')];
expect(selectRankedAlertButtonByRole(buttons, 'accept')?.label).toBe('Allow While Using App');
expect(selectRankedAlertButtonByRole(buttons, 'dismiss')?.label).toBe("Don't Allow");
});

it('resolves the Android grant pair by the same table', () => {
const buttons = [button('While using the app'), button('Only this time'), button('Deny')];
expect(selectRankedAlertButtonByRole(buttons, 'accept')?.label).toBe('While using the app');
});

it('refuses on equal-rank duplicates: the capture names no single physical button', () => {
const buttons = [button('OK'), button('OK'), button('Cancel')];
expect(selectRankedAlertButtonByRole(buttons, 'accept')).toBeNull();
});

it('refuses when multiple candidates share a role the vocabulary cannot rank', () => {
// Roles can arrive from a platform id classifier; labels outside the vocabulary then tie.
const buttons: Button[] = [
{ label: 'While on the go', role: 'accept' },
{ label: 'Just this run', role: 'accept' },
];
expect(selectRankedAlertButtonByRole(buttons, 'accept')).toBeNull();
});

it('refuses a mix of ranked and unranked candidates rather than preferring the known label', () => {
// The unranked one's role came from a platform id classifier, so it is a real grant the
// table cannot order. Letting the one known English label win would press an arbitrary
// grant of an unordered pair.
const buttons: Button[] = [
{ label: 'OK', role: 'accept' },
{ label: 'Unrecognized grant', role: 'accept' },
];
expect(selectRankedAlertButtonByRole(buttons, 'accept')).toBeNull();
});

it('keeps the declared order over mixed English variants of distinct grants', () => {
// The closest positive: both candidates are ranked, so this is a preference, not ambiguity.
const buttons = [button('Allow Once'), button('Allow')];
expect(selectRankedAlertButtonByRole(buttons, 'accept')?.label).toBe('Allow');
});

it('returns null when no button carries the role', () => {
expect(selectRankedAlertButtonByRole([button('Settings')], 'accept')).toBeNull();
});

it('orders candidates the label table cannot read by the backend rank it supplies', () => {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
// The localized-sheet shape at the shared boundary: both labels are outside the vocabulary,
// and the backend's own fact (here, a grant-id rank) is the only order the pair has. The
// boundary must use it instead of refusing a pair the owning backend can order.
type RankedButton = Button & { id: string };
const idRanks: Record<string, number> = { 'id/fg': 0, 'id/once': 1 };
const rankById = (button: RankedButton): number | null => idRanks[button.id] ?? null;
const buttons: RankedButton[] = [
{ label: 'Solo esta vez', role: 'accept', id: 'id/once' },
{ label: 'Mientras la app est\u00e1 en uso', role: 'accept', id: 'id/fg' },
];
expect(selectRankedAlertButtonByRole(buttons, 'accept', rankById)?.id).toBe('id/fg');
});

it('still refuses when the backend rank reaches neither of a multi-candidate pair', () => {
// The closest negative: the ranker exists but names nothing for these buttons, so the
// label fallback leaves an unranked candidate and the refusal stands.
type RankedButton = Button & { id: string };
const buttons: RankedButton[] = [
{ label: 'Mientras la app est\u00e1 en uso', role: 'accept', id: 'vendor/a' },
{ label: 'Solo esta vez', role: 'accept', id: 'vendor/b' },
];
expect(selectRankedAlertButtonByRole(buttons, 'accept', () => null)).toBeNull();
});

it('lets the backend rank decide a pair the label table would order the other way', () => {
// Both sources know these labels, and they conflict: the label table prefers
// "While using the app" (position 1) over "Only this time" (position 15), while the
// backend ranks them 2 and 0. The owning backend's fact is the authority — the label
// table ranks only where the backend declares nothing.
type RankedButton = Button & { id: string };
const idRanks: Record<string, number> = { 'id/once': 0, 'id/fg': 2 };
const rankById = (button: RankedButton): number | null => idRanks[button.id] ?? null;
const buttons: RankedButton[] = [
{ label: 'While using the app', role: 'accept', id: 'id/fg' },
{ label: 'Only this time', role: 'accept', id: 'id/once' },
];
expect(selectRankedAlertButtonByRole(buttons, 'accept', rankById)?.id).toBe('id/once');
});

it('refuses a pair whose ranks come from different sources rather than comparing scales', () => {
// A backend id-rank and a label-table position are numbers on unrelated scales: id-rank 2
// (a one-time grant) says nothing against label position 3 ("Always allow"). The pair is
// not orderable by any table, so it is refused, never resolved by whichever number is
// smaller.
type RankedButton = Button & { id: string };
const rankById = (button: RankedButton): number | null => (button.id === 'id/once' ? 2 : null);
const buttons: RankedButton[] = [
{ label: 'Only this time', role: 'accept', id: 'id/once' },
{ label: 'Always allow', role: 'accept', id: 'no/rank' },
];
expect(selectRankedAlertButtonByRole(buttons, 'accept', rankById)).toBeNull();
});
});
161 changes: 159 additions & 2 deletions packages/contracts/src/alert-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,23 @@ export const ALERT_POLL_INTERVAL_MS = 300;
export const DEFAULT_ALERT_TIMEOUT_MS = 10_000;
export const ALERT_ACTION_RETRY_MS = 2_000;

/**
* The largest alert budget the legs accept. The window signal rides `AbortSignal.timeout`, whose
* Node ceiling is 2^31-1 ms: larger values are rejected outright there, and its underlying timer
* overflows to a 1 ms firing when merely exceeded. The daemon refuses a larger `alert wait`
* budget as `INVALID_ARGS`, and a tree driver reads one as the bounded default — never a
* silently shortened window.
*/
export const MAX_ALERT_WINDOW_MS = 2_147_483_647;

/**
* The one alert failure the family retries on: the backend looked and there was no alert *yet*.
* Both Apple backends state it in typed form — the XCTest runner as this `ErrorPayload.code`
* (surfacing as `details.runnerErrorCode`, like `RUNNER_BUSY`, without changing the wire error
* code), the macOS helper as `details.reason`. Retry and the fallback hint key on these, never on
* the message text: a transport, runner or helper failure must never read as an absent alert.
* code), the macOS helper as details.reason — and so do the tree-based acting legs (Android and
* Limrun iOS, through the shared driver), as this `details.reason` paired with `dispatched:
* 'no'`. Retry and the fallback hint key on these, never on the message text: a transport,
* runner or helper failure must never read as an absent alert.
*/
export const ALERT_NOT_FOUND_RUNNER_CODE = 'ALERT_NOT_FOUND';
export const ALERT_NOT_FOUND_REASON = 'alert-not-found';
Expand All @@ -20,6 +31,152 @@ export const ALERT_NOT_FOUND_REASON = 'alert-not-found';
*/
export const ALERT_BUTTON_NOT_FOUND_RUNNER_CODE = 'ALERT_BUTTON_NOT_FOUND';

/**
* The acting leg's confirm budget ran out after a press landed without settling whether the
* alert is gone: the dialog was seen standing again after the press ("did not dismiss"), or no
* post-press read ever resolved to an answer ("could not be confirmed"). Typed so a caller can
* tell "a press went out and the screen never proved it worked" from absence or an
* unresolvable capture. The lookup never answers with it — a budget ends its loop, it does not
* cancel a started capture — so a quiet screen is always `alert-not-found`.
*/
export const ALERT_WINDOW_EXPIRED_REASON = 'alert-window-expired';

/** What an `alert get` capture resolved to, typed so clients switch on states, not messages. */
export type AlertStatusKind = 'visible' | 'none' | 'undecided';

export type AlertButtonRole = 'accept' | 'dismiss' | 'neutral';

/**
* What one capture from a tree-based alert backend says about alerts. `none` states absence;
* `undecided` states the capture is alert-shaped yet unresolvable (a degraded or shared root,
* competing sheets) and proves nothing — confirming a dismissal may accept `none`, never
* `undecided`. Backends whose capture cannot reach `undecided` report a quiet capture as `none`.
*/
export type AlertCapture<TCandidate> =
| { kind: 'alert'; candidate: TCandidate }
| { kind: 'none' }
| { kind: 'undecided' };

/**
* The one alert button label vocabulary every tree-based alert backend classifies with,
* declared as a per-role preference table: each entry names one English label (regex only for
* spelling variants of the *same* label), earlier entries outrank later ones, so a sheet
* offering several grants resolves by declared preference ("while using the app" over "only
* this time") instead of tree order. The table ranks labels, not buttons it cannot read: a
* backend owning a fact no label carries — Android's permission-grant ids — supplies its own
* rank per candidate, and the label table ranks only where the backend declares nothing.
* Buttons neither the vocabulary nor a backend rank reaches stay neutral, and acting legs
* refuse on the button-undecided reason rather than guessing at a translation.
*/
const ACCEPT_LABEL_PREFERENCES: readonly RegExp[] = [
/^allow while using (?:the )?app$/i,
/^while using the app$/i,
/^allow all$/i,
/^always allow$/i,
/^allow all the time$/i,
/^allow notifications$/i,
/^allow$/i,
/^ok$/i,
/^yes$/i,
/^continue$/i,
/^save$/i,
/^confirm$/i,
/^turn on$/i,
/^open settings$/i,
// One-time grants last: a sheet offering a lasting grant should not spend the tap here.
/^allow once$/i,
/^only this time$/i,
];
// `don.?t allow` covers the straight, missing, and typographic apostrophe in one alternate.
const DISMISS_LABEL_PREFERENCES: readonly RegExp[] = [
/^cancel$/i,
/^deny$/i,
/^don.?t allow$/i,
/^not now$/i,
/^no$/i,
/^dismiss$/i,
/^close$/i,
/^close app$/i,
/^later$/i,
/^skip$/i,
/^ask app not to track$/i,
];

const LABEL_PREFERENCES: Record<'accept' | 'dismiss', readonly RegExp[]> = {
accept: ACCEPT_LABEL_PREFERENCES,
dismiss: DISMISS_LABEL_PREFERENCES,
};

/** One button's action role under the shared vocabulary; anything unnamed is neutral. */
export function classifyAlertButtonRole(label: string): AlertButtonRole {
const trimmed = label.trim();
if (ACCEPT_LABEL_PREFERENCES.some((pattern) => pattern.test(trimmed))) return 'accept';
if (DISMISS_LABEL_PREFERENCES.some((pattern) => pattern.test(trimmed))) return 'dismiss';
return 'neutral';
}

/**
* A role-named button's place in the declared preference table, or null when its label carries
* no vocabulary entry (localized grants, unnamed choices).
*/
function alertButtonPreferenceRank(label: string, role: 'accept' | 'dismiss'): number | null {
const index = LABEL_PREFERENCES[role].findIndex((pattern) => pattern.test(label.trim()));
return index === -1 ? null : index;
}

/**
* The one choice boundary for a shared choice: pick the action's role candidates by declared
* preference. Each candidate's rank comes from the owning backend first — a backend may own
* facts a label cannot carry, like Android's permission-grant ids — and from the label
* vocabulary only where the backend declares nothing. A single candidate wins outright.
* Several resolve only when every one of them carries a rank AND all ranks come from the same
* source: backend ids and label positions are different scales, and an id-rank 2 (a one-time
* grant) says nothing against a label-position 3 ("Always allow"). A mix of sources cannot be
* ordered by any table, so it names no single physical button and is refused rather than
* resolved by tree order or by one side's number happening to be smaller.
*/
export function selectRankedAlertButtonByRole<
TButton extends Readonly<{ role: AlertButtonRole; label: string }>,
>(
buttons: readonly TButton[],
role: 'accept' | 'dismiss',
backendRank?: (button: TButton) => number | null,
): TButton | null {
const matching = buttons.filter((button) => button.role === role);
if (matching.length === 1) return matching[0] ?? null;
if (matching.length === 0) return null;
const ranked = matching.map((button) => {
const fromBackend = backendRank?.(button) ?? null;
return {
button,
rank: fromBackend ?? alertButtonPreferenceRank(button.label, role),
source: fromBackend !== null ? ('backend' as const) : ('label' as const),
};
});
if (ranked.some((entry) => entry.rank === null)) return null;
if (ranked.some((entry) => entry.source !== ranked[0]?.source)) return null;
ranked.sort((a, b) => (a.rank ?? 0) - (b.rank ?? 0));
const [first, second] = ranked;
if (!first || (second && second.rank === first.rank)) return null;
return first.button;
}

/**
* The reason a tree-based acting leg (`alert accept|dismiss`) refuses after seeing an alert: the
* visible buttons name no single choice for the asked-for action. Callers branch on
* `error.details.reason` instead of the prose; `dispatched: 'no'` states the refusal ran before
* any press.
*/
export const ALERT_BUTTON_UNDECIDED_REASON = 'alert_button_undecided';

/**
* The reason a tree-based acting leg refuses before it has a candidate at all: the capture was
* alert-shaped but unresolvable (degraded buttons, competing sheets, an unverifiable owner).
* This is not absence — reporting `alert-not-found` over it would state more than the capture
* supports — and confirming a dismissal must never accept such a capture as proof of absence.
*/
export const ALERT_CAPTURE_UNDECIDED_REASON = 'alert_capture_undecided';

export const ALERT_ACTIONS = ['get', 'accept', 'dismiss', 'wait'] as const;
export type AlertAction = (typeof ALERT_ACTIONS)[number];

Expand Down
10 changes: 7 additions & 3 deletions packages/contracts/src/alert-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ test('each local leg forwards the window and the session target to its own owner
timeoutMs: 37,
appBundleId: 'com.example.app',
surface: 'app',
// The request's cancellation rides along: owners whose transport cannot abort a call (the
// Limrun tree legs) still refuse to consume a capture once the request is canceled.
signal,
};
expect(legs.readAlert).toHaveBeenCalledWith(expectedOptions);
expect(legs.awaitAlert).toHaveBeenCalledWith(expectedOptions);
Expand All @@ -76,12 +79,13 @@ test('an absent target field never reaches the owner as an explicit undefined',
const readAlert = vi.fn(async (_options?: AlertInteractorOptions) => ({}));
const resolveInteractor = async () => ({ readAlert }) as unknown as Interactor;
const source = localInteractorSource({ device, resolveInteractor });
const operations = bindAlertLeg('readAlert', new AbortController().signal, source);
const signal = new AbortController().signal;
const operations = bindAlertLeg('readAlert', signal, source);

await operations.readAlert({ surface: 'frontmost-app' });

expect(readAlert).toHaveBeenCalledWith({ surface: 'frontmost-app' });
expect(Object.keys(readAlert.mock.calls[0]?.[0] ?? {})).toEqual(['surface']);
expect(readAlert).toHaveBeenCalledWith({ surface: 'frontmost-app', signal });
expect(Object.keys(readAlert.mock.calls[0]?.[0] ?? {})).toEqual(['surface', 'signal']);
});

// Facts admitted the leg, so an interactor without it is an ownership bug the caller must see
Expand Down
8 changes: 6 additions & 2 deletions packages/contracts/src/alert-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,11 +94,15 @@ async function resolveAlertInteractor(
});
}

function alertInteractorOptions(input: AlertRuntimeInput): AlertInteractorOptions {
function alertInteractorOptions(
input: AlertRuntimeInput,
signal: AbortSignal | undefined,
): AlertInteractorOptions {
return {
...(input.timeoutMs === undefined ? {} : { timeoutMs: input.timeoutMs }),
...(input.appBundleId === undefined ? {} : { appBundleId: input.appBundleId }),
...(input.surface === undefined ? {} : { surface: input.surface }),
...(signal ? { signal } : {}),
};
}

Expand All @@ -124,7 +128,7 @@ export function bindAlertLeg<Leg extends AlertLeg>(
// held at its merge-base size (`eager-closure-budgets`); a static edge would grow it.
const { requireInteractorMethod } = await import('./interactor-operation-binding.ts');
const method = requireInteractorMethod(interactor[leg], ALERT_LEG_LABELS[leg]);
return await method.call(interactor, alertInteractorOptions(input));
return await method.call(interactor, alertInteractorOptions(input, signal));
},
}) as Readonly<Record<Leg, (input: AlertRuntimeInput) => Promise<Record<string, unknown>>>>;
}
2 changes: 2 additions & 0 deletions packages/contracts/src/interactor-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,8 @@ export type Interactor = {
*/
export type AlertInteractorOptions = {
timeoutMs?: number;
/** The daemon request's cancellation; owners whose transport can abort thread it into the legs. */
signal?: AbortSignal;
appBundleId?: string;
surface?: SessionSurface;
};
Original file line number Diff line number Diff line change
Expand Up @@ -140,3 +140,34 @@ for (const action of ['accept', 'dismiss']) {
expect(successor?.actions).toEqual([]);
});
}

test('alert wait forwards its timeout budget to the runtime leg', async () => {
const sessionStore = makeSessionStore();
const sessionName = 'ios-sim';
sessionStore.publish(sessionName, makeSession(sessionName));

await handleSnapshotCommands(sessionName, sessionStore, ['wait', '1500']);

// The ceiling test below proves the refusal; this proves a legal budget crosses the runtime
// seam whole. (The Apple runner leg reads the state as `get` and waits on its own deadline.)
expect(mockRunnerCommand.mock.calls[0]?.[1]).toMatchObject({
command: 'alert',
timeoutMs: 1_500,
});
});

test('alert wait refuses a budget past the window ceiling instead of arming a misfired timer', async () => {
// Above 2^31-1 ms Node's timer overflows: `AbortSignal.timeout` throws outright and a bare
// setTimeout fires after 1 ms. The refusal must be the typed argument error, never either.
const sessionStore = makeSessionStore();
const sessionName = 'ios-sim';
sessionStore.publish(sessionName, makeSession(sessionName));

await expect(
handleSnapshotCommands(sessionName, sessionStore, ['wait', '4294967296']),
).rejects.toThrow(/exceeds the maximum/);
await expect(
handleSnapshotCommands(sessionName, sessionStore, ['wait', '2147483648']),
).rejects.toThrow(/exceeds the maximum/);
expect(mockRunnerCommand).not.toHaveBeenCalled();
});
Loading
Loading