From 41b61c8870338900bb99f5b62d1cce4efa70efd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 11 Jul 2026 16:15:27 +0200 Subject: [PATCH 1/3] feat(replay): ADR 0012 step 4 target-binding verification + post-resolution guard + step-5 resume wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebuilt onto current main (#1210 selector relocation, #1211 resume + --update retirement, #1213, #1215) as one coherent commit — the prior 4-commit history collided structurally with #1211's own session-replay-runtime split. Step 4 (decision 3, enforcement): every annotated resolved-target replay/test action is verified against a fresh pre-action snapshot BEFORE dispatch (verifyReplayActionTarget → classifyReplayTarget, decision 3's six paths). Only a verified outcome sends the action; selector-miss / identity-mismatch / identity-unverifiable each return a complete target-binding REPLAY_DIVERGENCE built from its own capture, with targetBinding {classification==kind, matchCount per the presence rule, recorded/observed, mismatches, candidates} through #1211's shared failure-response + bounding/sanitizer pipeline. Post-resolution guard: a verified outcome mints internal.replayTargetGuard (the verified member's identity); dispatch's own resolution (occlusion/ visibility guards verification does not replicate) must land on the SAME element or assertExpectedResolvedTarget refuses pre-action, which the loop converts to an identity-mismatch divergence. Guarded dispatch skips the direct-iOS and native-ref fast paths so a tree node exists to check. Step-5 resume wiring (this rebase): the target-binding divergence resume stub is replaced with #1211's buildReplayDivergenceResume — a pre-action divergence resumes AT the failed step (from = step index, real SHA-256 planDigest, allowed unless preflight rejects the skipped range). buildTargetBindingDivergenceResponse is the single resume site; planActions/planDigest are threaded from the loop. --update healing is retired by #1211, so the in-branch "verify healed action" work is dropped (its module and test removed as moot). Tests: classification (13), guard (4), token (5), runtime end-to-end (9, incl. old-script pass-through, guard threading + mismatch conversion, and a real computed resume assertion), plus target-binding wire tests in divergence.test.ts (classification==kind, matchCount presence, overflow-fallback targetBinding, computed-resume-not-stub, text report). Also repoints daemon-http-disconnect.test.ts already handled upstream by #1215. --- src/commands/interaction/runtime/gestures.ts | 4 + .../interaction/runtime/interactions.ts | 12 + .../interaction/runtime/resolution.ts | 46 ++ .../interaction/runtime/selector-read.ts | 5 + ...n-replay-target-classification-fixtures.ts | 92 ++++ ...ssion-replay-target-classification.test.ts | 466 ++++++++++++++++++ .../session-replay-target-guard.test.ts | 191 +++++++ .../session-replay-target-token.test.ts | 53 ++ ...replay-target-verification-runtime.test.ts | 416 ++++++++++++++++ src/daemon/handlers/interaction-touch.ts | 44 +- .../handlers/session-replay-divergence.ts | 28 +- src/daemon/handlers/session-replay-runtime.ts | 85 +++- .../session-replay-target-classification.ts | 401 +++++++++++++++ .../handlers/session-replay-target-token.ts | 22 + .../session-replay-target-verification.ts | 456 +++++++++++++++++ src/daemon/selector-runtime.ts | 6 +- src/daemon/session-target-evidence.ts | 73 +-- src/daemon/types.ts | 11 +- src/replay/__tests__/divergence.test.ts | 160 ++++++ src/replay/divergence.ts | 91 +++- src/replay/target-identity-node.ts | 52 ++ src/selectors/index.ts | 3 +- src/selectors/resolve.ts | 31 ++ 23 files changed, 2678 insertions(+), 70 deletions(-) create mode 100644 src/daemon/handlers/__tests__/session-replay-target-classification-fixtures.ts create mode 100644 src/daemon/handlers/__tests__/session-replay-target-classification.test.ts create mode 100644 src/daemon/handlers/__tests__/session-replay-target-guard.test.ts create mode 100644 src/daemon/handlers/__tests__/session-replay-target-token.test.ts create mode 100644 src/daemon/handlers/__tests__/session-replay-target-verification-runtime.test.ts create mode 100644 src/daemon/handlers/session-replay-target-classification.ts create mode 100644 src/daemon/handlers/session-replay-target-token.ts create mode 100644 src/daemon/handlers/session-replay-target-verification.ts create mode 100644 src/replay/target-identity-node.ts diff --git a/src/commands/interaction/runtime/gestures.ts b/src/commands/interaction/runtime/gestures.ts index 479fdea36..ba82474ea 100644 --- a/src/commands/interaction/runtime/gestures.ts +++ b/src/commands/interaction/runtime/gestures.ts @@ -36,6 +36,7 @@ import type { LongPressCommandResult } from '../../../contracts/interaction.ts'; import { assertSupportedInteractionSurface, captureInteractionSnapshot, + type ExpectedResolvedTarget, type InteractionTarget, type ResolvedInteractionTarget, resolveInteractionTarget, @@ -55,6 +56,8 @@ export type FocusCommandResult = ResolvedInteractionTarget & BackendResultEnvelo export type LongPressCommandOptions = CommandContext & { target: InteractionTarget; durationMs?: number; + /** ADR 0012 step 4: replay-only post-resolution guard; see resolution.ts. */ + expectedResolvedTarget?: ExpectedResolvedTarget; } & SettlePostActionObservationOptions; export type { LongPressCommandResult }; @@ -166,6 +169,7 @@ export const longPressCommand: RuntimeCommand< requireInteractive: true, promoteToHittableAncestor: true, captureEvidenceBaseline: observation.needsPreActionBaseline, + expectedResolvedTarget: options.expectedResolvedTarget, }); if (!runtime.backend.longPress) { throw new AppError('UNSUPPORTED_OPERATION', 'longPress is not supported by this backend'); diff --git a/src/commands/interaction/runtime/interactions.ts b/src/commands/interaction/runtime/interactions.ts index d020ac4cc..61450b893 100644 --- a/src/commands/interaction/runtime/interactions.ts +++ b/src/commands/interaction/runtime/interactions.ts @@ -20,6 +20,7 @@ import { import type { RepeatedInput } from '../../command-input.ts'; import { EXACT_REF_RESOLUTION, + type ExpectedResolvedTarget, type InteractionTarget, preflightNativeRefInteraction, resolveInteractionTarget, @@ -58,6 +59,8 @@ export type PressCommandOptions = CommandContext & RepeatedInput & { target: InteractionTarget; button?: ClickButton; + /** ADR 0012 step 4: replay-only post-resolution guard; see resolution.ts. */ + expectedResolvedTarget?: ExpectedResolvedTarget; } & PostActionObservationOptions; export type ClickCommandOptions = PressCommandOptions; @@ -68,6 +71,8 @@ export type FillCommandOptions = CommandContext & { target: InteractionTarget; text: string; delayMs?: number; + /** ADR 0012 step 4: replay-only post-resolution guard; see resolution.ts. */ + expectedResolvedTarget?: ExpectedResolvedTarget; } & PostActionObservationOptions; export type TypeTextCommandOptions = CommandContext & { @@ -107,6 +112,7 @@ export const fillCommand: RuntimeCommand requireInteractive: true, promoteToHittableAncestor: false, captureEvidenceBaseline: observation.needsPreActionBaseline, + expectedResolvedTarget: options.expectedResolvedTarget, }); if (!runtime.backend.fill) { throw new AppError('UNSUPPORTED_OPERATION', 'fill is not supported by this backend'); @@ -187,6 +193,7 @@ async function tapCommand( requireInteractive: true, promoteToHittableAncestor: true, captureEvidenceBaseline: observation.needsPreActionBaseline, + expectedResolvedTarget: options.expectedResolvedTarget, }); if (!runtime.backend.tap) { throw new AppError('UNSUPPORTED_OPERATION', 'tap is not supported by this backend'); @@ -229,6 +236,9 @@ async function maybeTapRefTarget( return null; } if (hasNonDefaultTapOptions(options)) return null; + // ADR 0012 step 4: a guarded replay action needs the runtime resolution + // path so the post-resolution identity guard actually runs. + if (options.expectedResolvedTarget) return null; // ADR 0011 native-ref preflight: the shared occlusion/offscreen guards run // against the stored session snapshot node before the backend call (a // backend fast path can silently "succeed", so errors must be raised here). @@ -254,6 +264,8 @@ async function maybeFillRefTarget( options: FillCommandOptions, ): Promise { if (options.target.kind !== 'ref' || !runtime.backend.fillTarget) return null; + // ADR 0012 step 4: guarded replay actions take the runtime path — see maybeTapRefTarget. + if (options.expectedResolvedTarget) return null; // ADR 0011 native-ref preflight — see maybeTapRefTarget. const preflight = await preflightNativeRefInteraction(runtime, options, options.target, 'fill'); const backendResult = await runtime.backend.fillTarget( diff --git a/src/commands/interaction/runtime/resolution.ts b/src/commands/interaction/runtime/resolution.ts index cadde0ffb..39090c42e 100644 --- a/src/commands/interaction/runtime/resolution.ts +++ b/src/commands/interaction/runtime/resolution.ts @@ -33,9 +33,51 @@ import type { } from '../../../contracts/interaction.ts'; import { now, toBackendContext } from '../../runtime-common.ts'; import { resolveActionableTouchResolution } from '../../../core/interaction-targeting.ts'; +import type { LocalIdentity } from '../../../replay/target-identity.ts'; +import { + localIdentitiesEqual, + readNodeLocalIdentity, + REPLAY_TARGET_GUARD_MISMATCH_REASON, +} from '../../../replay/target-identity-node.ts'; export type { InteractionTarget, PointTarget, ResolvedInteractionTarget }; +/** + * ADR 0012 migration step 4, post-resolution guard: the normalized local + * identity of the element replay's pre-action verification isolated. Set + * ONLY by the replay step loop (via `DaemonRequest.internal.replayTargetGuard`) + * for annotated verified actions — never on live interactive commands. + * Dispatch's own resolution runs guards verification does not replicate + * (occlusion filtering, visibility-preferring disambiguation), so its winner + * can differ from the verified member; this catches that split BEFORE the + * device action instead of tapping a different element than was verified. + */ +export type ExpectedResolvedTarget = LocalIdentity; + +/** + * Compares the resolution winner (pre-promotion: hittable-ancestor promotion + * deliberately retargets to the same element's actionable container and must + * not trip the guard) against the verified identity; throws pre-action. + */ +export function assertExpectedResolvedTarget( + node: SnapshotNode, + expected: ExpectedResolvedTarget | undefined, + action: string, +): void { + if (!expected) return; + const observed = readNodeLocalIdentity(node); + if (localIdentitiesEqual(observed, expected)) return; + throw new AppError( + 'COMMAND_FAILED', + `${action} resolved to a different element than replay verification isolated; the action was not sent`, + { + reason: REPLAY_TARGET_GUARD_MISMATCH_REASON, + observed, + expected, + }, + ); +} + export type InteractionAction = | 'click' | 'press' @@ -63,6 +105,8 @@ type ResolveInteractionTargetParams = { * when the caller explicitly asked for verify evidence. Defaults to false. */ captureEvidenceBaseline?: boolean; + /** ADR 0012 step 4 post-resolution guard; see `ExpectedResolvedTarget`. */ + expectedResolvedTarget?: ExpectedResolvedTarget; }; export async function resolveInteractionTarget( @@ -149,6 +193,7 @@ async function resolveRefInteractionTarget( ): Promise { const capture = await resolveSnapshotForRef(runtime, options, target); const resolved = capture.resolved; + assertExpectedResolvedTarget(resolved.node, params.expectedResolvedTarget, params.action); const node = params.promoteToHittableAncestor ? resolveActionableNodeOrThrow(capture.snapshot.nodes, resolved.node, { action: params.action, @@ -216,6 +261,7 @@ async function resolveSelectorInteractionTarget( { hint: selectorFailureHint(resolved?.diagnostics ?? []) }, ); } + assertExpectedResolvedTarget(resolved.node, params.expectedResolvedTarget, params.action); const node = params.promoteToHittableAncestor ? resolveActionableNodeOrThrow(capture.snapshot.nodes, resolved.node, { action: params.action, diff --git a/src/commands/interaction/runtime/selector-read.ts b/src/commands/interaction/runtime/selector-read.ts index 46c37d57b..16cb820ad 100644 --- a/src/commands/interaction/runtime/selector-read.ts +++ b/src/commands/interaction/runtime/selector-read.ts @@ -32,6 +32,7 @@ import type { SelectorTarget, } from '../../../contracts/interaction.ts'; import type { RuntimeCommand } from '../../runtime-types.ts'; +import { assertExpectedResolvedTarget, type ExpectedResolvedTarget } from './resolution.ts'; import { type CapturedSnapshot, type SelectorSnapshotOptions, @@ -69,6 +70,8 @@ export type GetCommandOptions = CommandContext & SelectorSnapshotOptions & { property: 'text' | 'attrs'; target: ElementTarget; + /** ADR 0012 step 4: replay-only post-resolution guard; see resolution.ts. */ + expectedResolvedTarget?: ExpectedResolvedTarget; }; export type GetCommandResult = @@ -199,6 +202,7 @@ export const getCommand: RuntimeCommand = a invalidRefMessage: 'get text requires a ref like @e2', notFoundMessage: `Ref ${options.target.ref} not found`, }); + assertExpectedResolvedTarget(resolved.node, options.expectedResolvedTarget, 'get'); const selectorChain = buildSelectorChainForNode(resolved.node, runtime.backend.platform, { action: 'get', }); @@ -215,6 +219,7 @@ export const getCommand: RuntimeCommand = a selector: options.target.selector, disambiguateAmbiguous: options.property === 'text', }); + assertExpectedResolvedTarget(resolved.node, options.expectedResolvedTarget, 'get'); const selectorChain = buildSelectorChainForNode(resolved.node, runtime.backend.platform, { action: 'get', diff --git a/src/daemon/handlers/__tests__/session-replay-target-classification-fixtures.ts b/src/daemon/handlers/__tests__/session-replay-target-classification-fixtures.ts new file mode 100644 index 000000000..f75f8378a --- /dev/null +++ b/src/daemon/handlers/__tests__/session-replay-target-classification-fixtures.ts @@ -0,0 +1,92 @@ +import assert from 'node:assert/strict'; +import type { RawSnapshotNode, SnapshotNode } from '../../../kernel/snapshot.ts'; +import { computeTargetEvidence } from '../../session-target-evidence.ts'; +import type { TargetAnnotationV1 } from '../../../replay/target-identity.ts'; + +export function toSnapshotNodes(raw: RawSnapshotNode[]): SnapshotNode[] { + return raw.map((node, position) => ({ ...node, ref: `e${position + 1}` })); +} + +export function bottomTabsRealCaptureFixture(): SnapshotNode[] { + return toSnapshotNodes([ + { + index: 0, + type: 'Application', + label: 'React Navigation Example', + rect: { x: 0, y: 0, width: 402, height: 874 }, + enabled: true, + hittable: true, + depth: 0, + }, + { + index: 1, + type: 'Window', + rect: { x: 0, y: 0, width: 402, height: 874 }, + enabled: true, + hittable: true, + depth: 1, + parentIndex: 0, + }, + { + index: 2, + type: 'ScrollView', + label: 'Contacts', + rect: { x: 0, y: 116, width: 402, height: 675 }, + enabled: true, + hittable: false, + hiddenContentBelow: true, + depth: 2, + parentIndex: 1, + }, + { + index: 3, + type: 'StaticText', + label: 'Marissa Castillo', + rect: { x: 52, y: 132, width: 110, height: 17 }, + enabled: true, + depth: 3, + parentIndex: 2, + }, + { + index: 4, + type: 'Other', + rect: { x: 0, y: 791, width: 402, height: 83 }, + enabled: true, + hittable: false, + depth: 2, + parentIndex: 1, + }, + { + index: 5, + type: 'Button', + label: 'Article, unselected', + identifier: 'article', + rect: { x: 0, y: 791, width: 101, height: 49 }, + enabled: true, + hittable: false, + depth: 3, + parentIndex: 4, + }, + { + index: 6, + type: 'Button', + label: 'Chat, unselected', + identifier: 'chat', + rect: { x: 101, y: 791, width: 100, height: 49 }, + enabled: true, + hittable: false, + depth: 3, + parentIndex: 4, + }, + ]); +} + +export function recordArticleEvidence(): TargetAnnotationV1 { + const nodes = bottomTabsRealCaptureFixture(); + const winner = nodes.find((node) => node.label === 'Article, unselected'); + if (!winner) throw new Error('fixture missing Article tab'); + const evidence = computeTargetEvidence({ node: winner, preActionNodes: nodes }); + assert.ok(evidence); + assert.equal(evidence.verification, 'verified'); + return evidence; +} diff --git a/src/daemon/handlers/__tests__/session-replay-target-classification.test.ts b/src/daemon/handlers/__tests__/session-replay-target-classification.test.ts new file mode 100644 index 000000000..69e96e8f1 --- /dev/null +++ b/src/daemon/handlers/__tests__/session-replay-target-classification.test.ts @@ -0,0 +1,466 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import type { SnapshotNode } from '../../../kernel/snapshot.ts'; +import type { TargetAnnotationV1 } from '../../../replay/target-identity.ts'; +import { classifyReplayTarget } from '../session-replay-target-classification.ts'; +import { + bottomTabsRealCaptureFixture, + recordArticleEvidence, + toSnapshotNodes, +} from './session-replay-target-classification-fixtures.ts'; + +/** Verified outcomes carry the verified member + matchCount (for the post-resolution guard). */ +function assertVerified( + result: ReturnType, + expected: { winnerRef: string; matchCount: number }, +): void { + assert.equal(result.verified, true); + if (!result.verified) throw new Error('unreachable'); + assert.equal(result.winnerNode.ref, expected.winnerRef); + assert.equal(result.matchCount, expected.matchCount); +} + +const PLATFORM = 'ios' as const; + +test('classifyReplayTarget: real-capture fixture verifies by @ref when the tree is unchanged', () => { + const recorded = recordArticleEvidence(); + const replayNodes = bottomTabsRealCaptureFixture(); + const winner = replayNodes.find((node) => node.label === 'Article, unselected'); + assert.ok(winner); + const result = classifyReplayTarget({ + recorded, + token: `@${winner.ref}`, + nodes: replayNodes, + platform: PLATFORM, + refLabel: undefined, + requireRect: true, + allowDisambiguation: true, + }); + assertVerified(result, { winnerRef: winner.ref, matchCount: 1 }); +}); + +test('classifyReplayTarget: real-capture fixture — a relabeled node is identity-mismatch (path 3)', () => { + const recorded = recordArticleEvidence(); + const replayNodes = bottomTabsRealCaptureFixture(); + const winner = replayNodes.find((node) => node.label === 'Article, unselected'); + assert.ok(winner); + // The id/label both changed (a real rename) but the selector (id="article") + // still resolves — the recorded id no longer matches anything. + winner.identifier = 'articles-tab'; + winner.label = 'Articles, unselected'; + const result = classifyReplayTarget({ + recorded, + token: 'id="article"', + nodes: replayNodes, + platform: PLATFORM, + refLabel: undefined, + requireRect: true, + allowDisambiguation: true, + }); + assert.equal(result.verified, false); + if (result.verified) throw new Error('unreachable'); + assert.equal(result.kind, 'selector-miss'); + assert.equal(result.matchCount, 0); +}); + +// --------------------------------------------------------------------------- +// Path 1 is caller-side (session-replay-target-verification.ts checks +// `recorded.verification === 'unverifiable'` before ever calling +// classifyReplayTarget) — covered by the wire-level test file instead. +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// Minimal synthetic fixture for the remaining paths: a toolbar with a save +// button, isolated from the real-capture tree's own structure so each path's +// setup stays legible. +// --------------------------------------------------------------------------- + +function saveButtonRecorded(overrides: Partial = {}): TargetAnnotationV1 { + return { + id: 'save', + role: 'button', + label: 'Save', + ancestry: [{ role: 'toolbar', label: 'Editor' }], + sibling: 0, + viewportOrder: 0, + verification: 'verified', + ...overrides, + }; +} + +function saveButtonTree(): SnapshotNode[] { + return toSnapshotNodes([ + { index: 0, type: 'Window', depth: 0 }, + { index: 1, type: 'Toolbar', label: 'Editor', depth: 1, parentIndex: 0 }, + { + index: 2, + type: 'Button', + identifier: 'save', + label: 'Save', + rect: { x: 10, y: 10, width: 40, height: 20 }, + depth: 2, + parentIndex: 1, + }, + ]); +} + +test('classifyReplayTarget path 2: selector-miss when the recorded target is gone', () => { + const recorded = saveButtonRecorded(); + const nodes = toSnapshotNodes([ + { index: 0, type: 'Window', depth: 0 }, + { index: 1, type: 'Toolbar', label: 'Editor', depth: 1, parentIndex: 0 }, + ]); + const result = classifyReplayTarget({ + recorded, + token: 'id="save"', + nodes, + platform: PLATFORM, + refLabel: undefined, + requireRect: true, + allowDisambiguation: true, + }); + assert.equal(result.verified, false); + if (result.verified) throw new Error('unreachable'); + assert.equal(result.kind, 'selector-miss'); + assert.equal(result.matchCount, 0); + assert.deepEqual(result.candidateNodes, []); +}); + +test('classifyReplayTarget path 4: verified via @ref on an unchanged tree', () => { + const recorded = saveButtonRecorded(); + const nodes = saveButtonTree(); + const result = classifyReplayTarget({ + recorded, + token: '@e3', + nodes, + platform: PLATFORM, + refLabel: undefined, + requireRect: true, + allowDisambiguation: true, + }); + assertVerified(result, { winnerRef: 'e3', matchCount: 1 }); +}); + +test('classifyReplayTarget uses the later chain alternative that resolution selected after an earlier tie', () => { + const nodes = toSnapshotNodes([ + { + index: 0, + type: 'Button', + label: 'Ambiguous', + rect: { x: 0, y: 0, width: 40, height: 20 }, + depth: 1, + }, + { + index: 1, + type: 'Button', + label: 'Ambiguous', + rect: { x: 60, y: 0, width: 40, height: 20 }, + depth: 1, + }, + { + index: 2, + type: 'Button', + identifier: 'save', + label: 'Save', + rect: { x: 0, y: 40, width: 40, height: 20 }, + depth: 1, + }, + ]); + + const result = classifyReplayTarget({ + recorded: saveButtonRecorded({ ancestry: [], sibling: 2 }), + token: 'label="Ambiguous" || id="save"', + nodes, + platform: PLATFORM, + refLabel: undefined, + requireRect: true, + allowDisambiguation: true, + }); + + // The first alternative ties, so `resolveSelectorChain` skips it and + // selects `id="save"`. Deriving the domain from the first matching + // alternative would report two ambiguous buttons instead of this one winner. + assertVerified(result, { winnerRef: 'e3', matchCount: 1 }); +}); + +test('classifyReplayTarget path 4: verified by ref-label fallback when the ref itself is stale', () => { + const recorded = saveButtonRecorded(); + const nodes = saveButtonTree(); + const result = classifyReplayTarget({ + recorded, + // A ref from a different session/generation never present in this tree. + token: '@e999', + nodes, + platform: PLATFORM, + refLabel: 'Save', + requireRect: true, + allowDisambiguation: true, + }); + assertVerified(result, { winnerRef: 'e3', matchCount: 1 }); +}); + +test('classifyReplayTarget: an unparseable-but-@-ref token with no fallback label is a selector-miss', () => { + const recorded = saveButtonRecorded(); + const nodes = saveButtonTree(); + const result = classifyReplayTarget({ + recorded, + token: '@e999', + nodes, + platform: PLATFORM, + refLabel: undefined, + requireRect: true, + allowDisambiguation: true, + }); + assert.equal(result.verified, false); + if (result.verified) throw new Error('unreachable'); + assert.equal(result.kind, 'selector-miss'); +}); + +test('classifyReplayTarget path 5: a unique-but-wrong rebind is caught even when resolution is unique', () => { + const recorded = saveButtonRecorded({ ancestry: [] }); + // Two "Go back" buttons at different depths: the label-only selector + // matches both, but only one carries the recorded id. The disambiguation + // heuristic (deepest-then-smallest-area) prefers the decoy. + const nodes = toSnapshotNodes([ + { index: 0, type: 'Window', rect: { x: 0, y: 0, width: 400, height: 800 }, depth: 0 }, + { + index: 1, + type: 'Button', + identifier: 'go-back-real', + label: 'Go back', + rect: { x: 0, y: 0, width: 40, height: 20 }, + depth: 2, + parentIndex: 0, + }, + { + index: 2, + type: 'Button', + identifier: 'go-back-decoy', + label: 'Go back', + rect: { x: 100, y: 100, width: 40, height: 20 }, + depth: 5, + parentIndex: 0, + }, + ]); + const goBackRecorded: TargetAnnotationV1 = { + ...recorded, + id: 'go-back-real', + label: 'Go back', + }; + const result = classifyReplayTarget({ + recorded: goBackRecorded, + token: 'label="Go back"', + nodes, + platform: PLATFORM, + refLabel: undefined, + requireRect: true, + allowDisambiguation: true, + }); + assert.equal(result.verified, false); + if (result.verified) throw new Error('unreachable'); + assert.equal(result.kind, 'identity-mismatch'); + assert.equal(result.matchCount, 2); + assert.equal(result.observedNode?.ref, 'e3'); // the decoy (index 2 -> e3), not the recorded winner +}); + +// --------------------------------------------------------------------------- +// Path 6: same local identity recurring under different (anonymous) parents, +// disambiguated by sibling ordinal, then by region-scoped viewportOrder. +// --------------------------------------------------------------------------- + +function duplicateRowTree(): SnapshotNode[] { + return toSnapshotNodes([ + { index: 0, type: 'Window', depth: 0 }, + { index: 1, type: 'ScrollView', identifier: 'list', depth: 1, parentIndex: 0 }, + // Two anonymous section wrappers (role only, no label) — a real + // SectionList/FlatList shape. + { index: 2, type: 'Other', depth: 2, parentIndex: 1 }, + { index: 3, type: 'Other', depth: 2, parentIndex: 1 }, + // Section A's rows (sibling 0, 1 within their own parent). Row 0 is + // uniquely deepest so the disambiguation heuristic (deepest-then- + // smallest-area) always picks it deterministically when ambiguous — used + // to exercise a genuine path-6-verified winner below. + { + index: 4, + type: 'Button', + label: 'Row', + rect: { x: 0, y: 100, width: 100, height: 20 }, + depth: 4, + parentIndex: 2, + }, + { + index: 5, + type: 'Button', + label: 'Row', + rect: { x: 0, y: 150, width: 100, height: 20 }, + depth: 3, + parentIndex: 2, + }, + // Section B's rows — SAME sibling ordinals (0, 1) recurring under a + // DIFFERENT parent. + { + index: 6, + type: 'Button', + label: 'Row', + rect: { x: 0, y: 200, width: 100, height: 20 }, + depth: 3, + parentIndex: 3, + }, + { + index: 7, + type: 'Button', + label: 'Row', + rect: { x: 0, y: 250, width: 100, height: 20 }, + depth: 3, + parentIndex: 3, + }, + ]); +} + +function duplicateRowRecorded(overrides: Partial = {}): TargetAnnotationV1 { + return { + role: 'button', + label: 'Row', + ancestry: [{ role: 'other' }], + sibling: 0, + viewportOrder: 0, + scrollRegion: { role: 'scrollview', id: 'list' }, + verification: 'verified', + ...overrides, + }; +} + +test('classifyReplayTarget path 6: same sibling ordinal recurring under a different parent falls through to region-scoped viewportOrder', () => { + const nodes = duplicateRowTree(); + const recorded = duplicateRowRecorded(); // recorded winner: index 4 (e5), sibling 0, viewportOrder 0 + const result = classifyReplayTarget({ + recorded, + token: 'role=button label="Row"', + nodes, + platform: PLATFORM, + refLabel: undefined, + requireRect: true, + // e5 is the uniquely-deepest match, so the real resolution has a + // genuine (non-tied) winner here — exercising path 6's compare-with-W + // step, not just the identity-set/region math in isolation. + allowDisambiguation: true, + }); + // Sibling ordinal 0 recurs under both anonymous sections (e5 and e7): the + // sibling signal alone cannot isolate. Region-scoped viewportOrder (all + // four rows share ONE scroll region, ordered by rect center) resolves it + // to the topmost row, e5 — which is also the real resolution winner. + assertVerified(result, { winnerRef: 'e5', matchCount: 4 }); +}); + +test('classifyReplayTarget path 6: viewport order resolves a lower row via document order within its region', () => { + const nodes = duplicateRowTree(); + const recorded = duplicateRowRecorded({ viewportOrder: 2 }); // third row top-to-bottom: e7 (Section B, sibling 0) + const result = classifyReplayTarget({ + recorded, + token: 'role=button label="Row"', + nodes, + platform: PLATFORM, + refLabel: undefined, + requireRect: true, + allowDisambiguation: false, + }); + assert.equal(result.verified, false); + if (result.verified) throw new Error('unreachable'); + // e7 was recorded but the CURRENT resolution winner is whatever the + // (disabled) disambiguation left as matchList's first match — a mismatch, + // not a verify, proving viewportOrder actually selected e7 as the + // evidence-denoted member rather than silently accepting matchList's + // first hit. + assert.equal(result.kind, 'identity-mismatch'); +}); + +test('classifyReplayTarget path 6: a recorded scroll region that no longer exists is unavailable, never compared cross-region', () => { + const nodes = duplicateRowTree(); + // The recorded scroll region ("list") no longer exists in the replay tree. + for (const node of nodes) { + if (node.identifier === 'list') node.identifier = 'list-renamed'; + } + const recorded = duplicateRowRecorded(); + const result = classifyReplayTarget({ + recorded, + token: 'role=button label="Row"', + nodes, + platform: PLATFORM, + refLabel: undefined, + requireRect: true, + allowDisambiguation: false, + }); + assert.equal(result.verified, false); + if (result.verified) throw new Error('unreachable'); + assert.equal(result.kind, 'identity-unverifiable'); + assert.equal(result.matchCount, 4); + assert.equal(result.candidateNodes.length, 4); + // Document order: the candidates list is exactly the identity set in tree order. + assert.deepEqual( + result.candidateNodes.map((node) => node.ref), + ['e5', 'e6', 'e7', 'e8'], + ); +}); + +test('classifyReplayTarget path 6: an out-of-range recorded viewportOrder falls through to identity-unverifiable', () => { + const nodes = duplicateRowTree(); + const recorded = duplicateRowRecorded({ viewportOrder: 99 }); + const result = classifyReplayTarget({ + recorded, + token: 'role=button label="Row"', + nodes, + platform: PLATFORM, + refLabel: undefined, + requireRect: true, + allowDisambiguation: false, + }); + assert.equal(result.verified, false); + if (result.verified) throw new Error('unreachable'); + assert.equal(result.kind, 'identity-unverifiable'); + assert.equal(result.matchCount, 4); +}); + +test('classifyReplayTarget: document-order determinism for equal rect centers', () => { + // Two candidates under different anonymous sections (sibling 0 recurs, so + // that signal never isolates), with IDENTICAL rect centers — the ONLY way + // `orderByViewportPosition` can order them is its document-order + // tie-break. The first one (index 2, uniquely deepest so its OWN + // resolution winner is unambiguous) is recorded at viewportOrder 0; if the + // tie-break were nondeterministic or reversed, the winner (e3) would not + // match `orderedRegion[0]` and this would report a mismatch instead of + // verified. + const nodes = toSnapshotNodes([ + { index: 0, type: 'ScrollView', identifier: 'list', depth: 0 }, + { index: 1, type: 'Other', depth: 1, parentIndex: 0 }, + { index: 2, type: 'Other', depth: 1, parentIndex: 0 }, + { + index: 3, + type: 'Button', + label: 'Row', + rect: { x: 0, y: 100, width: 100, height: 20 }, + depth: 3, // uniquely deepest -> unambiguous real resolution winner + parentIndex: 1, + }, + { + index: 4, + type: 'Button', + label: 'Row', + rect: { x: 0, y: 100, width: 100, height: 20 }, // identical center to index 3 + depth: 2, + parentIndex: 2, + }, + ]); + const recorded = duplicateRowRecorded({ sibling: 0, viewportOrder: 0 }); + const result = classifyReplayTarget({ + recorded, + token: 'role=button label="Row"', + nodes, + platform: PLATFORM, + refLabel: undefined, + requireRect: true, + allowDisambiguation: true, + }); + assertVerified(result, { winnerRef: 'e4', matchCount: 2 }); +}); + +// --------------------------------------------------------------------------- diff --git a/src/daemon/handlers/__tests__/session-replay-target-guard.test.ts b/src/daemon/handlers/__tests__/session-replay-target-guard.test.ts new file mode 100644 index 000000000..7dac6c9cf --- /dev/null +++ b/src/daemon/handlers/__tests__/session-replay-target-guard.test.ts @@ -0,0 +1,191 @@ +// Split-resolver guard (coordinator addition to step 4): dispatch resolution +// runs occlusion/visibility guards verification does not replicate, so its +// winner can differ from the verified member. The post-resolution guard +// (`expectedResolvedTarget` -> `assertExpectedResolvedTarget`) must refuse +// PRE-ACTION in that case instead of tapping a different element. +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import type { SnapshotState } from '../../../kernel/snapshot.ts'; +import type { TargetAnnotationV1 } from '../../../replay/target-identity.ts'; +import { readNodeLocalIdentity } from '../../../replay/target-identity-node.ts'; +import { makeSnapshotState } from '../../../__tests__/test-utils/index.ts'; +import { ref as interactionRef, selector } from '../../../commands/index.ts'; +import { createInteractionDevice } from '../../../commands/interaction/runtime/__tests__/test-utils/index.ts'; +import { classifyReplayTarget } from '../session-replay-target-classification.ts'; + +function assertVerified( + result: ReturnType, + expected: { winnerRef: string; matchCount: number }, +): void { + assert.equal(result.verified, true); + if (!result.verified) throw new Error('unreachable'); + assert.equal(result.winnerNode.ref, expected.winnerRef); + assert.equal(result.matchCount, expected.matchCount); +} + +// --------------------------------------------------------------------------- + +/** + * The split fixture: two "Save" buttons. A (deeper) wins verification's + * unfiltered disambiguation, but dispatch filters it out as covered and + * resolves B instead — verified-but-would-tap-a-different-node. + */ +function splitResolverSnapshot(): SnapshotState { + return makeSnapshotState([ + { + index: 0, + depth: 0, + type: 'Application', + label: 'Example', + rect: { x: 0, y: 0, width: 390, height: 844 }, + }, + { + // A: the verified target — deeper (wins deepest-first disambiguation + // over the UNFILTERED domain) but covered, so dispatch's + // interactableSelectorNodes filter removes it. + index: 1, + depth: 3, + parentIndex: 0, + type: 'Button', + identifier: 'save-a', + label: 'Save', + rect: { x: 16, y: 120, width: 140, height: 44 }, + interactionBlocked: 'covered', + }, + { + // B: visible sibling with the same label but a different identity — + // dispatch's winner after A is filtered. + index: 2, + depth: 1, + parentIndex: 0, + type: 'Button', + identifier: 'save-b', + label: 'Save', + rect: { x: 16, y: 790, width: 140, height: 44 }, + hittable: true, + }, + ]); +} + +test('split resolver: verification verifies the covered deeper node while dispatch would resolve its visible sibling', () => { + const nodes = splitResolverSnapshot().nodes; + const nodeA = nodes.find((node) => node.identifier === 'save-a'); + assert.ok(nodeA); + const recorded: TargetAnnotationV1 = { + id: 'save-a', + role: 'button', + label: 'Save', + ancestry: [{ role: 'application', label: 'Example' }], + sibling: 0, + viewportOrder: 0, + verification: 'verified', + }; + const result = classifyReplayTarget({ + recorded, + token: 'label="Save"', + nodes, + platform: 'ios', + refLabel: undefined, + requireRect: true, + allowDisambiguation: true, + }); + // Verification's unfiltered domain: both buttons match; the identity set + // isolates A; the unfiltered disambiguation winner is also A (deepest) — + // path 4 verified. Dispatch would resolve B: exactly the split the + // post-resolution guard exists to catch. + assertVerified(result, { winnerRef: nodeA.ref, matchCount: 2 }); +}); + +test('split resolver: the post-resolution guard refuses pre-action when dispatch resolves a different element', async () => { + const snapshot = splitResolverSnapshot(); + const nodeA = snapshot.nodes.find((node) => node.identifier === 'save-a'); + assert.ok(nodeA); + const taps: unknown[] = []; + const device = createInteractionDevice(snapshot, { + tap: async (_context, point) => { + taps.push(point); + return { ok: true }; + }, + }); + + await assert.rejects( + device.interactions.press(selector('label="Save"'), { + session: 'default', + expectedResolvedTarget: readNodeLocalIdentity(nodeA), + }), + (error: unknown) => { + const appError = error as { + code?: string; + details?: { reason?: string; observed?: { id?: string }; expected?: { id?: string } }; + }; + assert.equal(appError.code, 'COMMAND_FAILED'); + assert.equal(appError.details?.reason, 'replay_target_guard_mismatch'); + assert.equal(appError.details?.observed?.id, 'save-b'); + assert.equal(appError.details?.expected?.id, 'save-a'); + return true; + }, + ); + // The refusal is pre-action: the tap never reached the backend. + assert.deepEqual(taps, []); +}); + +test('split resolver: a matching identity passes the guard and the action proceeds', async () => { + const snapshot = splitResolverSnapshot(); + const nodeB = snapshot.nodes.find((node) => node.identifier === 'save-b'); + assert.ok(nodeB); + const taps: unknown[] = []; + const device = createInteractionDevice(snapshot, { + tap: async (_context, point) => { + taps.push(point); + return { ok: true }; + }, + }); + + const result = await device.interactions.press(selector('label="Save"'), { + session: 'default', + expectedResolvedTarget: readNodeLocalIdentity(nodeB), + }); + assert.equal(result.kind, 'selector'); + assert.equal('node' in result ? result.node?.identifier : undefined, 'save-b'); + assert.equal(taps.length, 1); + // A passing guard must retain the runtime resolution disclosure. + assert.deepEqual('resolution' in result ? result.resolution : undefined, { + source: 'runtime', + phase: 'pre-action', + kind: 'unique', + }); +}); + +test('split resolver: the guard forces the ref fast path onto the runtime resolution path', async () => { + const snapshot = splitResolverSnapshot(); + const nodeB = snapshot.nodes.find((node) => node.identifier === 'save-b'); + assert.ok(nodeB); + const taps: unknown[] = []; + const tapTargets: unknown[] = []; + const device = createInteractionDevice(snapshot, { + tap: async (_context, point) => { + taps.push(point); + return { ok: true }; + }, + tapTarget: async (_context, target) => { + tapTargets.push(target); + return { ok: true }; + }, + }); + + const result = await device.interactions.click(interactionRef(`@${nodeB.ref}`), { + session: 'default', + expectedResolvedTarget: readNodeLocalIdentity(nodeB), + }); + // Without the guard, click @ref with a tapTarget backend takes the native + // fast path (backend.tapTarget) and the guard would never run; with the + // guard it must resolve through the runtime path (backend.tap). + assert.deepEqual(tapTargets, []); + assert.equal(taps.length, 1); + // A guard-forced runtime ref resolution still discloses its exact source. + assert.deepEqual('resolution' in result ? result.resolution : undefined, { + source: 'ref', + phase: 'pre-action', + kind: 'exact', + }); +}); diff --git a/src/daemon/handlers/__tests__/session-replay-target-token.test.ts b/src/daemon/handlers/__tests__/session-replay-target-token.test.ts new file mode 100644 index 000000000..772907e5b --- /dev/null +++ b/src/daemon/handlers/__tests__/session-replay-target-token.test.ts @@ -0,0 +1,53 @@ +// extractReplayTargetToken / readRefLabel — token extraction for each +// eligible command shape, and the point-target / ineligible-command guards. +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import type { SessionAction } from '../../types.ts'; +import { extractReplayTargetToken, readRefLabel } from '../session-replay-target-token.ts'; + +// --------------------------------------------------------------------------- + +function action(overrides: Partial): SessionAction { + return { ts: 0, command: 'click', positionals: [], flags: {}, ...overrides }; +} + +test('extractReplayTargetToken: click/press/longpress/fill take positional 0', () => { + for (const command of ['click', 'press', 'longpress', 'fill']) { + assert.equal( + extractReplayTargetToken(action({ command, positionals: ['id="save"', 'text'] })), + 'id="save"', + ); + } +}); + +test('extractReplayTargetToken: get takes positional 1 (after the text/attrs subcommand)', () => { + assert.equal( + extractReplayTargetToken(action({ command: 'get', positionals: ['text', 'id="save"'] })), + 'id="save"', + ); +}); + +test('extractReplayTargetToken: a two-numeric-positional point target is not eligible', () => { + assert.equal( + extractReplayTargetToken(action({ command: 'click', positionals: ['100', '200'] })), + undefined, + ); +}); + +test('extractReplayTargetToken: an ineligible command (find/is/wait/scroll) returns undefined', () => { + for (const command of ['find', 'is', 'wait', 'scroll', 'swipe']) { + assert.equal( + extractReplayTargetToken(action({ command, positionals: ['id="save"'] })), + undefined, + ); + } +}); + +test('readRefLabel: reads a string result.refLabel, ignores non-string/empty', () => { + assert.equal(readRefLabel(action({ result: { refLabel: 'Save' } })), 'Save'); + assert.equal(readRefLabel(action({ result: { refLabel: '' } })), undefined); + assert.equal(readRefLabel(action({ result: { refLabel: 42 } })), undefined); + assert.equal(readRefLabel(action({})), undefined); +}); + +// --------------------------------------------------------------------------- diff --git a/src/daemon/handlers/__tests__/session-replay-target-verification-runtime.test.ts b/src/daemon/handlers/__tests__/session-replay-target-verification-runtime.test.ts new file mode 100644 index 000000000..6aaf67b23 --- /dev/null +++ b/src/daemon/handlers/__tests__/session-replay-target-verification-runtime.test.ts @@ -0,0 +1,416 @@ +/** + * ADR 0012 migration step 4, end-to-end: `runReplayScriptFile` must consult + * `verifyReplayActionTarget` for every annotated resolved-target action + * BEFORE dispatching it, and never send the device action on a non-verified + * outcome. Mirrors the mocking pattern of `session-replay-runtime.test.ts` + * (mock `dispatchCommand` for the pre-action snapshot capture, mock `invoke` + * for the actual action dispatch). + */ +import { test, expect, vi, beforeEach } from 'vitest'; + +vi.mock('../../../core/dispatch.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, dispatchCommand: vi.fn(async () => ({})), resolveTargetDevice: vi.fn() }; +}); + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { runReplayScriptFile } from '../session-replay-runtime.ts'; +import { SessionStore } from '../../session-store.ts'; +import type { DaemonRequest } from '../../types.ts'; +import { dispatchCommand } from '../../../core/dispatch.ts'; +import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts'; + +const mockDispatchCommand = vi.mocked(dispatchCommand); + +beforeEach(() => { + mockDispatchCommand.mockReset(); + mockDispatchCommand.mockResolvedValue({}); +}); + +function writeReplayFile(root: string, lines: string[]): string { + const filePath = path.join(root, 'flow.ad'); + fs.writeFileSync(filePath, `${lines.join('\n')}\n`); + return filePath; +} + +function baseReq(overrides: Partial = {}): DaemonRequest { + return { token: 'token', session: 'default', command: 'replay', positionals: [], ...overrides }; +} + +const SAVE_ANNOTATION = + '# agent-device:target-v1 {"id":"save","role":"button","label":"Save","ancestry":[],"sibling":0,"viewportOrder":0,"verification":"verified"}'; + +const UNVERIFIABLE_ANNOTATION = + '# agent-device:target-v1 {"id":"save","role":"button","label":"Save","ancestry":[],"sibling":0,"viewportOrder":0,"verification":"unverifiable"}'; + +function setupSession(root: string): { sessionStore: SessionStore; sessionName: string } { + const sessionStore = new SessionStore(path.join(root, 'sessions')); + const sessionName = 'default'; + sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); + return { sessionStore, sessionName }; +} + +test('an unannotated action executes unchanged (old-script pass-through)', async () => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'agent-device-replay-target-verify-passthrough-'), + ); + const { sessionStore, sessionName } = setupSession(root); + const filePath = writeReplayFile(root, ['click id="save"']); + + const invoked: DaemonRequest[] = []; + const response = await runReplayScriptFile({ + req: baseReq({ positionals: [filePath] }), + sessionName, + logPath: path.join(root, 'daemon.log'), + sessionStore, + invoke: async (req) => { + invoked.push(req); + return { ok: true, data: {} }; + }, + }); + + expect(response.ok).toBe(true); + expect(invoked.map((req) => req.command)).toEqual(['click']); + // The pre-action snapshot-capture path (captureDivergenceObservation) is + // never reached for an unannotated action. + expect(mockDispatchCommand).not.toHaveBeenCalled(); +}); + +test('a verified target proceeds to dispatch the action', async () => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'agent-device-replay-target-verify-verified-'), + ); + const { sessionStore, sessionName } = setupSession(root); + const filePath = writeReplayFile(root, [SAVE_ANNOTATION, 'click id="save"']); + + mockDispatchCommand.mockResolvedValue({ + nodes: [ + { + index: 0, + depth: 0, + type: 'Button', + identifier: 'save', + label: 'Save', + rect: { x: 10, y: 10, width: 40, height: 20 }, + }, + ], + truncated: false, + backend: 'xctest', + }); + + const invoked: DaemonRequest[] = []; + const response = await runReplayScriptFile({ + req: baseReq({ positionals: [filePath] }), + sessionName, + logPath: path.join(root, 'daemon.log'), + sessionStore, + invoke: async (req) => { + invoked.push(req); + return { ok: true, data: {} }; + }, + }); + + expect(response.ok).toBe(true); + expect(invoked.map((req) => req.command)).toEqual(['click']); + expect(invoked[0]?.positionals).toEqual(['id="save"']); +}); + +test('a selector-miss divergence blocks dispatch and never sends the action', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-target-verify-miss-')); + const { sessionStore, sessionName } = setupSession(root); + const filePath = writeReplayFile(root, [SAVE_ANNOTATION, 'click id="save"']); + + // The pre-action capture finds an entirely empty tree: the recorded + // selector no longer matches anything. + mockDispatchCommand.mockResolvedValue({ nodes: [], truncated: false, backend: 'xctest' }); + + const invoked: DaemonRequest[] = []; + const response = await runReplayScriptFile({ + req: baseReq({ positionals: [filePath] }), + sessionName, + logPath: path.join(root, 'daemon.log'), + sessionStore, + invoke: async (req) => { + invoked.push(req); + return { ok: true, data: {} }; + }, + }); + + expect(invoked.length).toBe(0); // the click action was never dispatched + expect(response.ok).toBe(false); + if (response.ok) return; + expect(response.error.code).toBe('REPLAY_DIVERGENCE'); + const divergence = response.error.details?.divergence as Record; + expect(divergence.kind).toBe('selector-miss'); + const targetBinding = divergence.targetBinding as Record; + expect(targetBinding.classification).toBe('selector-miss'); + expect(targetBinding.matchCount).toBe(0); + expect(targetBinding.observed).toBeUndefined(); + expect(targetBinding.recorded).toEqual({ id: 'save', role: 'button', label: 'Save' }); +}); + +test('an identity-mismatch divergence reports matchCount and an observed identity', async () => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'agent-device-replay-target-verify-mismatch-'), + ); + const { sessionStore, sessionName } = setupSession(root); + // A label-based selector so it can match a node whose id changed. + const filePath = writeReplayFile(root, [SAVE_ANNOTATION, 'click label="Save"']); + + mockDispatchCommand.mockResolvedValue({ + nodes: [ + { + index: 0, + depth: 0, + type: 'Button', + identifier: 'save-v2', // renamed id: no longer matches the recorded 'save' + label: 'Save', + rect: { x: 10, y: 10, width: 40, height: 20 }, + }, + ], + truncated: false, + backend: 'xctest', + }); + + const invoked: DaemonRequest[] = []; + const response = await runReplayScriptFile({ + req: baseReq({ positionals: [filePath] }), + sessionName, + logPath: path.join(root, 'daemon.log'), + sessionStore, + invoke: async (req) => { + invoked.push(req); + return { ok: true, data: {} }; + }, + }); + + expect(invoked.length).toBe(0); + expect(response.ok).toBe(false); + if (response.ok) return; + const divergence = response.error.details?.divergence as Record; + expect(divergence.kind).toBe('identity-mismatch'); + const targetBinding = divergence.targetBinding as Record; + expect(targetBinding.classification).toBe('identity-mismatch'); + expect(targetBinding.matchCount).toBe(1); + expect(targetBinding.observed).toEqual({ id: 'save-v2', role: 'button', label: 'Save' }); + expect(Array.isArray(targetBinding.mismatches)).toBe(true); + expect((targetBinding.mismatches as string[]).length).toBeGreaterThan(0); +}); + +test('a recorded-unverifiable annotation is an identity-unverifiable divergence with matchCount omitted', async () => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'agent-device-replay-target-verify-unverifiable-'), + ); + const { sessionStore, sessionName } = setupSession(root); + const filePath = writeReplayFile(root, [UNVERIFIABLE_ANNOTATION, 'click id="save"']); + + mockDispatchCommand.mockResolvedValue({ + nodes: [ + { + index: 0, + depth: 0, + type: 'Button', + identifier: 'save', + label: 'Save', + rect: { x: 10, y: 10, width: 40, height: 20 }, + }, + ], + truncated: false, + backend: 'xctest', + }); + + const invoked: DaemonRequest[] = []; + const response = await runReplayScriptFile({ + req: baseReq({ positionals: [filePath] }), + sessionName, + logPath: path.join(root, 'daemon.log'), + sessionStore, + invoke: async (req) => { + invoked.push(req); + return { ok: true, data: {} }; + }, + }); + + expect(invoked.length).toBe(0); + expect(response.ok).toBe(false); + if (response.ok) return; + const divergence = response.error.details?.divergence as Record; + expect(divergence.kind).toBe('identity-unverifiable'); + const targetBinding = divergence.targetBinding as Record; + expect(targetBinding.classification).toBe('identity-unverifiable'); + expect('matchCount' in targetBinding).toBe(false); +}); + +test('a verified annotated action carries the post-resolution guard on its dispatch request', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-target-guard-thread-')); + const { sessionStore, sessionName } = setupSession(root); + const filePath = writeReplayFile(root, [SAVE_ANNOTATION, 'click id="save"']); + + mockDispatchCommand.mockResolvedValue({ + nodes: [ + { + index: 0, + depth: 0, + type: 'Button', + identifier: 'save', + label: 'Save', + rect: { x: 10, y: 10, width: 40, height: 20 }, + }, + ], + truncated: false, + backend: 'xctest', + }); + + const invoked: DaemonRequest[] = []; + const response = await runReplayScriptFile({ + req: baseReq({ positionals: [filePath] }), + sessionName, + logPath: path.join(root, 'daemon.log'), + sessionStore, + invoke: async (req) => { + invoked.push(req); + return { ok: true, data: {} }; + }, + }); + + expect(response.ok).toBe(true); + // The verified member's normalized identity rides the request so dispatch's + // own resolution can cross-check its winner pre-action. + expect(invoked[0]?.internal?.replayTargetGuard).toEqual({ + id: 'save', + role: 'button', + label: 'Save', + }); +}); + +test('an unannotated action never carries a post-resolution guard', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-target-guard-none-')); + const { sessionStore, sessionName } = setupSession(root); + const filePath = writeReplayFile(root, ['click id="save"']); + + const invoked: DaemonRequest[] = []; + const response = await runReplayScriptFile({ + req: baseReq({ positionals: [filePath] }), + sessionName, + logPath: path.join(root, 'daemon.log'), + sessionStore, + invoke: async (req) => { + invoked.push(req); + return { ok: true, data: {} }; + }, + }); + + expect(response.ok).toBe(true); + expect(invoked[0]?.internal?.replayTargetGuard).toBeUndefined(); +}); + +test('a dispatch-time guard mismatch converts to an identity-mismatch target-binding divergence', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-target-guard-mismatch-')); + const { sessionStore, sessionName } = setupSession(root); + const filePath = writeReplayFile(root, [SAVE_ANNOTATION, 'click id="save"']); + + // Pre-action verification passes against this tree (guard minted)... + mockDispatchCommand.mockResolvedValue({ + nodes: [ + { + index: 0, + depth: 0, + type: 'Button', + identifier: 'save', + label: 'Save', + rect: { x: 10, y: 10, width: 40, height: 20 }, + }, + ], + truncated: false, + backend: 'xctest', + }); + + const invoked: DaemonRequest[] = []; + const response = await runReplayScriptFile({ + req: baseReq({ positionals: [filePath] }), + sessionName, + logPath: path.join(root, 'daemon.log'), + sessionStore, + invoke: async (req) => { + invoked.push(req); + // ...but dispatch's own resolution (occlusion/visibility guards) landed + // on a different element and refused pre-action with the guard marker. + return { + ok: false, + error: { + code: 'COMMAND_FAILED', + message: 'click resolved to a different element than replay verification isolated', + details: { + reason: 'replay_target_guard_mismatch', + observed: { id: 'save-decoy', role: 'button', label: 'Save' }, + expected: { id: 'save', role: 'button', label: 'Save' }, + }, + }, + }; + }, + }); + + expect(invoked.length).toBe(1); // dispatched once; the refusal was pre-action inside dispatch + expect(response.ok).toBe(false); + if (response.ok) return; + expect(response.error.code).toBe('REPLAY_DIVERGENCE'); + const divergence = response.error.details?.divergence as Record; + expect(divergence.kind).toBe('identity-mismatch'); + const targetBinding = divergence.targetBinding as Record; + expect(targetBinding.classification).toBe('identity-mismatch'); + // matchCount from verification's recorded-selector resolution (presence rule: + // resolution happened, so the key is present). + expect(targetBinding.matchCount).toBe(1); + expect(targetBinding.recorded).toEqual({ id: 'save', role: 'button', label: 'Save' }); + expect(targetBinding.observed).toEqual({ id: 'save-decoy', role: 'button', label: 'Save' }); + expect((targetBinding.mismatches as string[]).some((entry) => entry.includes('save-decoy'))).toBe( + true, + ); +}); + +// NOTE: the "--update re-verifies a healed action" test was removed here — +// ADR 0012 migration step 6 (#1211) retired `--update` as an actor, so replay +// never heals/retries a step; there is no healed action to re-verify. + +test('a target-binding divergence carries a real computed resume (step 5 wiring, not the retired stub)', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-target-resume-')); + const { sessionStore, sessionName } = setupSession(root); + // Two leading plain actions then the annotated (verified→miss) action at + // step 3: a pre-action divergence resumes AT the failed step, and step 3 is + // reachable because steps 1-2 produce no outputEnv and cross no control flow. + const filePath = writeReplayFile(root, [ + 'wait 10', + 'wait 10', + SAVE_ANNOTATION, + 'click id="save"', + ]); + mockDispatchCommand.mockResolvedValue({ nodes: [], truncated: false, backend: 'xctest' }); + + const response = await runReplayScriptFile({ + req: baseReq({ positionals: [filePath] }), + sessionName, + logPath: path.join(root, 'daemon.log'), + sessionStore, + invoke: async () => ({ ok: true, data: {} }), + }); + + expect(response.ok).toBe(false); + if (response.ok) return; + const divergence = response.error.details?.divergence as Record; + expect(divergence.kind).toBe('selector-miss'); + // Real computed resume: allowed AT the failed step (3), with a concrete + // SHA-256 plan digest — NOT the retired `resume not yet supported` stub. + const resume = divergence.resume as { + allowed: boolean; + from?: number; + planDigest?: string; + reason?: string; + }; + expect(resume.allowed).toBe(true); + expect(resume.from).toBe(3); + expect(typeof resume.planDigest).toBe('string'); + expect((resume.planDigest ?? '').length).toBeGreaterThan(0); + expect(resume.reason).toBeUndefined(); +}); diff --git a/src/daemon/handlers/interaction-touch.ts b/src/daemon/handlers/interaction-touch.ts index 1f4d3e442..26d756966 100644 --- a/src/daemon/handlers/interaction-touch.ts +++ b/src/daemon/handlers/interaction-touch.ts @@ -17,6 +17,7 @@ import type { PressCommandResult, } from '../../contracts/interaction.ts'; import { asAppError, normalizeError } from '../../kernel/errors.ts'; +import type { LocalIdentity } from '../../replay/target-identity.ts'; import type { DaemonResponse, SessionState } from '../types.ts'; import { finalizeTouchInteraction, type InteractionHandlerParams } from './interaction-common.ts'; import { markSessionSnapshotRefsIssued, resolveRefStalenessWarning } from '../session-snapshot.ts'; @@ -153,12 +154,18 @@ async function dispatchTargetedTouchViaRuntime( if (invalidRefFlagsResponse) return invalidRefFlagsResponse; androidFreshnessBaseline = await refreshAndroidRefSnapshotIfFreshnessActive(params, session); } - const directSelector = readDirectIosSelectorTapTarget({ - session, - commandLabel, - target: parsedTarget.target, - flags: req.flags, - }); + // ADR 0012 step 4: a guarded replay dispatch must resolve through the + // runtime tree path so the post-resolution identity guard runs — the + // direct-iOS fast path has no daemon-tree node to check against. + const replayTargetGuard = req.internal?.replayTargetGuard; + const directSelector = replayTargetGuard + ? null + : readDirectIosSelectorTapTarget({ + session, + commandLabel, + target: parsedTarget.target, + flags: req.flags, + }); if (directSelector) { const directResponse = await dispatchDirectIosSelectorTap(params, session, directSelector); if (directResponse) return directResponse; @@ -177,6 +184,7 @@ async function dispatchTargetedTouchViaRuntime( clickButton, flags: req.flags, durationMs, + expectedResolvedTarget: replayTargetGuard, }), afterRun: async (result) => { if (session.lease?.leaseProvider) return; @@ -222,8 +230,10 @@ async function runTargetedTouchInteraction(params: { clickButton: ReturnType; flags: CommandFlags | undefined; durationMs?: number; + expectedResolvedTarget?: LocalIdentity; }): Promise { - const { runtime, command, target, sessionName, requestId, flags } = params; + const { runtime, command, target, sessionName, requestId, flags, expectedResolvedTarget } = + params; const settle = readSettleRequest(flags); if (command === 'longpress') { return await runtime.interactions.longPress(target, { @@ -231,6 +241,7 @@ async function runTargetedTouchInteraction(params: { requestId, durationMs: params.durationMs, settle, + expectedResolvedTarget, }); } @@ -245,6 +256,7 @@ async function runTargetedTouchInteraction(params: { doubleTap: flags?.doubleTap, verify: flags?.verify, settle, + expectedResolvedTarget, }; return command === 'click' ? await runtime.interactions.click(target, options) @@ -538,12 +550,17 @@ async function dispatchFillViaRuntime( ); if (refPreamble.response) return refPreamble.response; const { staleRefsWarning } = refPreamble; - const directResponse = await maybeDispatchDirectIosSelectorFill( - params, - session, - parsedTarget.target, - parsedTarget.text, - ); + // ADR 0012 step 4: guarded replay dispatches take the runtime tree path — + // see dispatchTargetedTouchViaRuntime. + const replayTargetGuard = req.internal?.replayTargetGuard; + const directResponse = replayTargetGuard + ? null + : await maybeDispatchDirectIosSelectorFill( + params, + session, + parsedTarget.target, + parsedTarget.text, + ); if (directResponse) return directResponse; return await dispatchRuntimeInteraction(params, { @@ -554,6 +571,7 @@ async function dispatchFillViaRuntime( delayMs: req.flags?.delayMs, verify: req.flags?.verify, settle: readSettleRequest(req.flags), + expectedResolvedTarget: replayTargetGuard, }), buildPayloads: (result) => buildFillResponsePayloads({ diff --git a/src/daemon/handlers/session-replay-divergence.ts b/src/daemon/handlers/session-replay-divergence.ts index eb4aab0f3..923980264 100644 --- a/src/daemon/handlers/session-replay-divergence.ts +++ b/src/daemon/handlers/session-replay-divergence.ts @@ -32,7 +32,7 @@ import { type ReplayVarScrubEntry, } from '../../replay/divergence.ts'; -type DivergenceFieldSanitizer = (value: string, limit?: number) => string; +export type DivergenceFieldSanitizer = (value: string, limit?: number) => string; /** * ADR 0012 migration step 2: builds the `details.divergence` report for a @@ -120,6 +120,22 @@ export async function buildReplayFailureDivergence(params: { }), }; + return boundReplayDivergenceForSession({ sessionStore, sessionName, divergence, responseLevel }); +} + +/** + * Shared response-level bounding + overflow-artifact wiring (`boundReplayDivergence` + * bound to this session's artifact directory). Exported so step 4's + * target-binding divergence goes through the exact same bounding/overflow + * behavior as an action-failure divergence. + */ +export function boundReplayDivergenceForSession(params: { + sessionStore: SessionStore; + sessionName: string; + divergence: ReplayDivergence; + responseLevel: ResponseLevel | undefined; +}): ReplayDivergence { + const { sessionStore, sessionName, divergence, responseLevel } = params; return boundReplayDivergence({ divergence, level: responseLevel, @@ -128,7 +144,7 @@ export async function buildReplayFailureDivergence(params: { }); } -type DivergenceObservation = +export type DivergenceObservation = | { state: 'available'; nodes: SnapshotNode[]; refsGeneration: number } | { state: 'unavailable'; reason: string; hint: string }; @@ -138,7 +154,7 @@ type DivergenceObservation = * Sparse captures do not write back (selector-capture reliability contract), * so a sparse verdict degrades the whole observation. */ -async function captureDivergenceObservation(params: { +export async function captureDivergenceObservation(params: { session: SessionState; sessionName: string; sessionStore: SessionStore; @@ -199,7 +215,7 @@ function divergenceCaptureInteractiveOnly(action: SessionAction): boolean { return resolveSuggestionMatchingConfig(action).requiresRect; } -function buildDivergenceScreen( +export function buildDivergenceScreen( observation: DivergenceObservation, sanitize: DivergenceFieldSanitizer, ): ReplayDivergenceScreen { @@ -286,9 +302,9 @@ function isSuggestionEligibleCommand(command: string): boolean { return isTouchTargetCommand(command) || ['fill', 'get', 'is', 'wait'].includes(command); } -type SuggestionMatchingConfig = { requiresRect: boolean; allowDisambiguation: boolean }; +export type SuggestionMatchingConfig = { requiresRect: boolean; allowDisambiguation: boolean }; -function resolveSuggestionMatchingConfig(action: SessionAction): SuggestionMatchingConfig { +export function resolveSuggestionMatchingConfig(action: SessionAction): SuggestionMatchingConfig { const isTouch = isTouchTargetCommand(action.command); return { requiresRect: isTouch || action.command === 'fill', diff --git a/src/daemon/handlers/session-replay-runtime.ts b/src/daemon/handlers/session-replay-runtime.ts index b7c2276f2..998752f73 100644 --- a/src/daemon/handlers/session-replay-runtime.ts +++ b/src/daemon/handlers/session-replay-runtime.ts @@ -33,6 +33,11 @@ import { readEffectiveReplayPlanDigestMetadata, resolveReplayEntryIndex, } from './session-replay-runtime-plan.ts'; +import { + buildReplayTargetGuardMismatchResponse, + isReplayTargetGuardMismatchResponse, + verifyReplayActionTarget, +} from './session-replay-target-verification.ts'; // fallow-ignore-next-line complexity export async function runReplayScriptFile(params: { @@ -120,23 +125,74 @@ export async function runReplayScriptFile(params: { emitReplayTestActionProgress(resolved, index, actions.length, action); const sampleStart = readSessionSnapshotSampleCount(sessionStore, sessionName); - const response = await invokeReplayAction({ - req: replayReq, - sessionName, + // ADR 0012 migration step 4: verify the recorded target BEFORE sending + // the device action. A non-verified outcome is a complete target-binding + // REPLAY_DIVERGENCE (built from its own pre-action capture); only a + // verified outcome dispatches, carrying the verified member's identity + // as a post-resolution guard so dispatch's own resolution (occlusion/ + // visibility guards verification does not replicate) must land on the + // SAME element or refuse pre-action. + const verification = await verifyReplayActionTarget({ action, scope, - filePath: resolved, - line: actionLines[index] ?? 1, - sourcePath: actionSourcePaths?.[index], + sourcePath: actionSourcePaths?.[index] ?? resolved, + sourceLine: actionLines[index] ?? 1, + replayPath: resolved, step: index + 1, - tracePath: actionTracePath, - invoke, + sessionName, + sessionStore, + logPath, + artifactPaths: [...artifactPaths], + responseLevel: req.meta?.responseLevel, + planActions: actions, + planDigest, }); + const guard = verification.verified ? verification.guard : undefined; + const guardedReq = guard + ? { ...replayReq, internal: { ...replayReq.internal, replayTargetGuard: guard.expected } } + : replayReq; + let response = verification.verified + ? await invokeReplayAction({ + req: guardedReq, + sessionName, + action, + scope, + filePath: resolved, + line: actionLines[index] ?? 1, + sourcePath: actionSourcePaths?.[index], + step: index + 1, + tracePath: actionTracePath, + invoke, + }) + : verification.response; + if (guard && isReplayTargetGuardMismatchResponse(response)) { + response = await buildReplayTargetGuardMismatchResponse({ + action, + scope, + guard, + failedResponse: response, + sourcePath: actionSourcePaths?.[index] ?? resolved, + sourceLine: actionLines[index] ?? 1, + replayPath: resolved, + step: index + 1, + sessionName, + sessionStore, + logPath, + artifactPaths: [...artifactPaths], + responseLevel: req.meta?.responseLevel, + planActions: actions, + planDigest, + }); + } snapshotDiagnosticSamples.push( ...readSessionSnapshotSamplesSince(sessionStore, sessionName, sampleStart), ); collectReplayActionArtifactPaths(response).forEach((entry) => artifactPaths.add(entry)); if (!response.ok) { + // A complete target-binding divergence must pass through unchanged — + // failStep would rebuild it as a generic action-failure divergence + // (double-capture + lost kind/targetBinding). + if (isCompleteTargetBindingDivergenceResponse(response)) return response; return await failStep(response, action, index); } } @@ -276,6 +332,19 @@ function formatReplaySuccessMessage(replayed: number, wallClockMs: number): stri return `Replayed ${replayed} ${noun} in ${seconds}s`; } +// ADR 0012 step 4: a target-binding divergence is already a complete, final +// REPLAY_DIVERGENCE built from its own pre-action capture — distinguished from +// an action-failure divergence by its non-`action-failure` kind. +function isCompleteTargetBindingDivergenceResponse(response: DaemonResponse): boolean { + if (response.ok || response.error.code !== 'REPLAY_DIVERGENCE') return false; + const divergence = response.error.details?.divergence; + const kind = + divergence && typeof divergence === 'object' + ? (divergence as Record).kind + : undefined; + return typeof kind === 'string' && kind !== 'action-failure'; +} + function readSessionSnapshotSampleCount(sessionStore: SessionStore, sessionName: string): number { return sessionStore.get(sessionName)?.snapshotDiagnostics?.samples.length ?? 0; } diff --git a/src/daemon/handlers/session-replay-target-classification.ts b/src/daemon/handlers/session-replay-target-classification.ts new file mode 100644 index 000000000..0806fc77f --- /dev/null +++ b/src/daemon/handlers/session-replay-target-classification.ts @@ -0,0 +1,401 @@ +/** + * ADR 0012 migration step 4: replay-time target-binding verification + * enforcement. + * + * For every replay/test step whose action carries `target-v1` evidence + * (`action.targetEvidence`, parsed by `src/replay/script.ts` / + * `src/replay/target-identity.ts`), this resolves the SAME recorded + * selector/ref the action's own dispatch would use against a fresh + * pre-action snapshot, classifies the match via decision 3's six-path + * algorithm (`classifyTargetBindingMatch`), and — on any non-verified + * outcome — builds a complete `REPLAY_DIVERGENCE` response carrying the + * target-binding `kind` (`selector-miss` / `identity-mismatch` / + * `identity-unverifiable`) instead of ever sending the device action. Only + * the verified outcome (decision 3 paths 4 and 6-verified) lets the caller + * proceed to the normal `invokeReplayAction` dispatch. + * + * Unannotated actions (`targetEvidence` absent — old scripts, or a command + * this migration step doesn't cover) return `{ verified: true }` + * immediately: pass-through, unchanged behavior. + * + * This module is wired ONLY from the replay/test step loop + * (`session-replay-runtime.ts`) — never from live interactive command + * dispatch — so `resolveInteractionTarget`'s live click/press/fill/ + * longpress/get path is completely unaffected. + * + * `classifyReplayTarget` below is the pure tree-classification core (no + * capture, no session, no wire shaping) so migration step 4's validation + * bullet — all six verification paths, sibling recurrence under different + * parents, region-partitioned viewportOrder domains, a recorded region gone, + * out-of-range ordinals, document-order determinism — is testable directly + * against constructed `SnapshotNode` trees. + */ + +import type { Platform, PublicPlatform } from '../../kernel/device.ts'; +import { findNodeByRef, normalizeRef, type SnapshotNode } from '../../kernel/snapshot.ts'; +import { findNodeByLabel } from '../../snapshot/snapshot-processing.ts'; +import { matchesSelector } from '../../selectors/match.ts'; +import { + listSelectorChainMatches, + resolveSelectorChain, + tryParseSelectorChain, +} from '../../selectors/index.ts'; +import { + buildIndexMap, + boundedLocalIdentity, + buildAncestryChain, + computeSiblingOrdinal, + computeScrollRegionKey, + scrollRegionKeysEqual, + orderByViewportPosition, + filterIdentitySet, +} from '../session-target-evidence.ts'; +import { + classifyTargetBindingMatch, + type LocalIdentity, + type TargetAnnotationV1, +} from '../../replay/target-identity.ts'; +import type { ReplayDivergenceTargetBindingKind } from '../../replay/divergence.ts'; + +// --------------------------------------------------------------------------- +// Pure classification core — no capture, no session, no wire shaping. +// --------------------------------------------------------------------------- + +export type ReplayTargetVerified = { + verified: true; + /** The verified member — the resolution winner on every verified path. */ + winnerNode: SnapshotNode; + /** Decision 3's `matchCount` domain size (nodes matching the recorded selector/ref). */ + matchCount: number; +}; + +export type ReplayTargetDivergent = { + verified: false; + kind: ReplayDivergenceTargetBindingKind; + matchCount: number | undefined; + observedNode: SnapshotNode | undefined; + candidateNodes: SnapshotNode[]; + mismatches: string[]; + causeCode: string; + causeMessage: string; +}; + +export type ReplayTargetClassification = ReplayTargetVerified | ReplayTargetDivergent; + +/** + * Decision 3's replay-time verification, over an already-captured tree: not + * itself path 1 (recorded-`unverifiable`) — callers check that before ever + * building a tree domain, since path 1 fires before any resolution. + */ +export function classifyReplayTarget(params: { + recorded: TargetAnnotationV1; + token: string; + nodes: SnapshotNode[]; + platform: Platform | PublicPlatform; + refLabel: string | undefined; + requireRect: boolean; + allowDisambiguation: boolean; +}): ReplayTargetClassification { + const { recorded, token, nodes, platform, refLabel, requireRect, allowDisambiguation } = params; + + const matching = resolveTargetMatches({ + token, + nodes, + platform, + refLabel, + requireRect, + allowDisambiguation, + }); + + const byIndex = buildIndexMap(nodes); + const identity = identityAsLocalIdentity(recorded); + const identitySet = filterIdentitySet( + matching.matchedNodes, + byIndex, + identity, + recorded.ancestry, + ); + const siblingMatches = identitySet.filter( + (candidate) => computeSiblingOrdinal(nodes, candidate) === recorded.sibling, + ); + const regionMembers = identitySet.filter((candidate) => + scrollRegionKeysEqual(computeScrollRegionKey(candidate, byIndex), recorded.scrollRegion), + ); + const orderedRegion = orderByViewportPosition(regionMembers); + const viewportCandidateRef = orderedRegion[recorded.viewportOrder]?.ref; + + const classification = classifyTargetBindingMatch({ + winnerRef: matching.winnerRef, + matchedRefs: matching.matchedNodes.map((node) => node.ref), + identitySetRefs: identitySet.map((node) => node.ref), + siblingMatchRefs: siblingMatches.map((node) => node.ref), + regionMemberRefs: regionMembers.map((node) => node.ref), + viewportCandidateRef, + }); + + const winnerNode = matching.matchedNodes.find((node) => node.ref === matching.winnerRef); + if (classification.outcome === 'verified' && winnerNode) { + // A verified winner is always an identity-set member ⊆ matched set, so + // winnerNode is defined here; the fall-through below is fail-closed. + return { verified: true, winnerNode, matchCount: matching.matchedNodes.length }; + } + if (classification.outcome === 'verified') { + return { + verified: false, + kind: 'identity-unverifiable', + matchCount: matching.matchedNodes.length, + observedNode: undefined, + candidateNodes: identitySet.slice(0, 5), + mismatches: [], + causeCode: 'IDENTITY_UNVERIFIABLE', + causeMessage: + 'Verification isolated a member that is not part of the matched-node domain (capture anomaly).', + }; + } + + const mapped = mapVerificationFailure({ + classification, + matchCount: matching.matchedNodes.length, + winnerNode, + identitySet, + byIndex, + recorded, + }); + return { verified: false, ...mapped }; +} + +type TargetMatchResolution = { matchedNodes: SnapshotNode[]; winnerRef: string }; + +/** + * Resolves the recorded target's matched-node domain and its resolution + * winner, using the SAME lookup/matching a real dispatch would: ref lookup + * (with the recorded `refLabel` fallback, `resolveRefInteractionTarget`'s + * pattern) or `resolveSelectorChain` with the SAME per-command + * rect/disambiguation config `resolveSuggestionMatchingConfig` already gives + * heal's suggestion re-resolution. + */ +function resolveTargetMatches(params: { + token: string; + nodes: SnapshotNode[]; + platform: Platform | PublicPlatform; + refLabel: string | undefined; + requireRect: boolean; + allowDisambiguation: boolean; +}): TargetMatchResolution { + const { token, nodes, platform, refLabel, requireRect, allowDisambiguation } = params; + return token.startsWith('@') + ? resolveRefTargetMatches(nodes, token, refLabel, requireRect) + : resolveSelectorTargetMatches(nodes, token, platform, requireRect, allowDisambiguation); +} + +function resolveRefTargetMatches( + nodes: SnapshotNode[], + token: string, + refLabel: string | undefined, + requireRect: boolean, +): TargetMatchResolution { + const usable = (node: SnapshotNode | null): node is SnapshotNode => + node !== null && (!requireRect || Boolean(node.rect)); + const ref = normalizeRef(token); + const byRef = ref ? findNodeByRef(nodes, ref) : null; + if (usable(byRef)) return { matchedNodes: [byRef], winnerRef: byRef.ref }; + const byLabel = refLabel ? findNodeByLabel(nodes, refLabel) : null; + return usable(byLabel) + ? { matchedNodes: [byLabel], winnerRef: byLabel.ref } + : { matchedNodes: [], winnerRef: '' }; +} + +function resolveSelectorTargetMatches( + nodes: SnapshotNode[], + token: string, + platform: Platform | PublicPlatform, + requireRect: boolean, + allowDisambiguation: boolean, +): TargetMatchResolution { + const chain = tryParseSelectorChain(token); + if (!chain) return { matchedNodes: [], winnerRef: '' }; + const resolved = resolveSelectorChain(nodes, chain, { + platform, + requireRect, + requireUnique: true, + disambiguateAmbiguous: allowDisambiguation, + }); + if (!resolved) { + // No alternative produced a dispatch winner (for example, ambiguity with + // disambiguation disabled). Keep the established diagnostic domain so + // classification can report that ambiguity, but do not invent a winner. + const matchList = listSelectorChainMatches(nodes, chain, { platform, requireRect }); + return { matchedNodes: matchList?.matchedNodes ?? [], winnerRef: '' }; + } + // `resolved.selector` is the selected chain alternative. The verification + // domain must use that same alternative, not the first one with any match: + // an earlier ambiguous/tied alternative can be skipped in favor of a later + // resolvable alternative. + const matchedNodes = nodes.filter((node) => { + if (requireRect && !node.rect) return false; + return matchesSelector(node, resolved.selector, platform); + }); + return { matchedNodes, winnerRef: resolved.node.ref }; +} + +function identityAsLocalIdentity(recorded: TargetAnnotationV1): LocalIdentity { + return { + ...(recorded.id !== undefined ? { id: recorded.id } : {}), + role: recorded.role, + ...(recorded.label !== undefined ? { label: recorded.label } : {}), + }; +} + +type MappedVerificationFailure = Omit; + +/** Decision 3 paths 2/3/5/6 (excluding verified), mapped onto a wire divergence kind. */ +function mapVerificationFailure(params: { + classification: Exclude, { outcome: 'verified' }>; + matchCount: number; + winnerNode: SnapshotNode | undefined; + identitySet: SnapshotNode[]; + byIndex: Map; + recorded: TargetAnnotationV1; +}): MappedVerificationFailure { + const { classification, matchCount, winnerNode, identitySet, byIndex, recorded } = params; + switch (classification.reason) { + case 'selector-miss': + return { + kind: 'selector-miss', + matchCount: 0, + observedNode: undefined, + candidateNodes: [], + mismatches: [], + causeCode: 'SELECTOR_MISS', + causeMessage: 'The recorded target no longer matches any current element.', + }; + case 'identity-set-empty': + return { + kind: 'identity-mismatch', + matchCount, + observedNode: winnerNode, + candidateNodes: [], + mismatches: winnerNode ? computeIdentityMismatches(recorded, winnerNode, byIndex) : [], + causeCode: 'IDENTITY_MISMATCH', + causeMessage: + 'The recorded selector/ref still matches, but nothing in the current tree carries the recorded identity.', + }; + case 'unique-but-wrong': + return { + kind: 'identity-mismatch', + matchCount, + observedNode: winnerNode, + candidateNodes: [], + mismatches: winnerNode ? computeIdentityMismatches(recorded, winnerNode, byIndex) : [], + causeCode: 'IDENTITY_MISMATCH', + causeMessage: + 'Exactly one current element carries the recorded identity, but the resolved target is a different element.', + }; + case 'signal-isolated-wrong': + return { + kind: 'identity-mismatch', + matchCount, + observedNode: winnerNode, + candidateNodes: [], + mismatches: winnerNode ? computeIdentityMismatches(recorded, winnerNode, byIndex) : [], + causeCode: 'IDENTITY_MISMATCH', + causeMessage: + 'A disambiguation signal (sibling or viewport position) isolated a different element than the resolved target.', + }; + case 'no-signal-isolation': + return { + kind: 'identity-unverifiable', + matchCount, + observedNode: winnerNode, + candidateNodes: identitySet.slice(0, 5), + mismatches: [], + causeCode: 'IDENTITY_UNVERIFIABLE', + causeMessage: `${identitySet.length} current elements carry the recorded identity and neither disambiguation signal isolated one.`, + }; + } +} + +/** + * Bounded, best-effort diagnostic diff between the recorded identity/ + * ancestry and the actual resolution winner's own — first differing field + * only per component, leaf-anchored-prefix semantics for ancestry (the first + * divergence explains everything after it). + */ +function computeIdentityMismatches( + recorded: TargetAnnotationV1, + observedNode: SnapshotNode, + byIndex: Map, +): string[] { + const observed = boundedLocalIdentity(observedNode); + const observedAncestry = buildAncestryChain( + observedNode, + byIndex, + Math.max(recorded.ancestry.length, 1), + ).chain; + return [ + ...identityFieldMismatches(recorded, observed), + ...firstAncestryMismatch(recorded.ancestry, observedAncestry), + ].slice(0, 5); +} + +export function identityFieldMismatches( + recorded: TargetAnnotationV1, + observed: LocalIdentity, +): string[] { + const mismatches: string[] = []; + if (recorded.id !== observed.id) { + mismatches.push(`id: recorded=${recorded.id ?? '(none)'} observed=${observed.id ?? '(none)'}`); + } + if (recorded.role !== observed.role) { + mismatches.push(`role: recorded=${recorded.role} observed=${observed.role}`); + } + if (recorded.label !== observed.label) { + mismatches.push( + `label: recorded=${recorded.label ?? '(none)'} observed=${observed.label ?? '(none)'}`, + ); + } + return mismatches; +} + +function describeAncestryEntry(entry: { role: string; label?: string } | undefined): string { + return entry ? `${entry.role}${entry.label ? `/${entry.label}` : ''}` : '(missing)'; +} + +function ancestryEntryMismatches( + expected: { role: string; label?: string }, + actual: { role: string; label?: string } | undefined, +): boolean { + if (!actual) return true; + if (actual.role !== expected.role) return true; + return expected.label !== undefined && actual.label !== expected.label; +} + +/** Leaf-anchored prefix: the first divergence explains everything after it. */ +function firstAncestryMismatch( + recordedAncestry: readonly { role: string; label?: string }[], + observedAncestry: readonly { role: string; label?: string }[], +): string[] { + for (const [index, expected] of recordedAncestry.entries()) { + const actual = observedAncestry[index]; + if (!ancestryEntryMismatches(expected, actual)) continue; + return [ + `ancestry[${index}]: recorded=${describeAncestryEntry(expected)} observed=${describeAncestryEntry(actual)}`, + ]; + } + return []; +} + +// --------------------------------------------------------------------------- +// Daemon-level orchestration: capture, session, wire shaping. +// --------------------------------------------------------------------------- + +/** + * Post-resolution guard payload for a verified action: dispatch re-resolves + * with its own occlusion/visibility guards, and its winner must carry + * `expected` (the verified member's identity) or the interaction layer + * refuses pre-action (`assertExpectedResolvedTarget`, resolution.ts). + * `matchCount` is verification's recorded-selector match count, carried so + * the resulting identity-mismatch divergence satisfies decision 3's + * matchCount presence rule. + */ +export type ReplayVerifiedTargetGuard = { expected: LocalIdentity; matchCount: number }; diff --git a/src/daemon/handlers/session-replay-target-token.ts b/src/daemon/handlers/session-replay-target-token.ts new file mode 100644 index 000000000..e71b07e3a --- /dev/null +++ b/src/daemon/handlers/session-replay-target-token.ts @@ -0,0 +1,22 @@ +import { isTouchTargetCommand } from '../../replay/script-utils.ts'; +import type { SessionAction } from '../types.ts'; + +/** Returns the resolved-target token carried by an eligible replay action. */ +export function extractReplayTargetToken(action: SessionAction): string | undefined { + const positionals = action.positionals ?? []; + if (action.command === 'get') return positionals[1]; + if (!isTouchTargetCommand(action.command) && action.command !== 'fill') return undefined; + const first = positionals[0]; + if (first === undefined) return undefined; + if (isNumericToken(first) && isNumericToken(positionals[1])) return undefined; + return first; +} + +export function readRefLabel(action: SessionAction): string | undefined { + const refLabel = action.result?.refLabel; + return typeof refLabel === 'string' && refLabel.length > 0 ? refLabel : undefined; +} + +function isNumericToken(value: string | undefined): boolean { + return value !== undefined && value.trim().length > 0 && !Number.isNaN(Number(value)); +} diff --git a/src/daemon/handlers/session-replay-target-verification.ts b/src/daemon/handlers/session-replay-target-verification.ts new file mode 100644 index 000000000..7fe15ecf4 --- /dev/null +++ b/src/daemon/handlers/session-replay-target-verification.ts @@ -0,0 +1,456 @@ +import type { ResponseLevel, DaemonError } from '../../kernel/contracts.ts'; +import type { SnapshotNode } from '../../kernel/snapshot.ts'; +import { displayLabel, formatRole } from '../../snapshot/snapshot-lines.ts'; +import { formatDivergenceActionLabel } from '../../replay/script-utils.ts'; +import { + collectReplayScrubbableVarValues, + resolveReplayAction, + type ReplayVarScope, +} from '../../replay/vars.ts'; +import type { LocalIdentity, TargetAnnotationV1 } from '../../replay/target-identity.ts'; +import { + createReplayDivergenceSanitizer, + type ReplayDivergence, + type ReplayDivergenceTargetBindingKind, + type ReplayDivergenceTargetCandidate, + type ReplayDivergenceTargetIdentity, +} from '../../replay/divergence.ts'; +import { REPLAY_TARGET_GUARD_MISMATCH_REASON } from '../../replay/target-identity-node.ts'; +import type { DaemonResponse, SessionAction } from '../types.ts'; +import { SessionStore } from '../session-store.ts'; +import { boundedLocalIdentity } from '../session-target-evidence.ts'; +import { tryParseSelectorChain } from '../../selectors/index.ts'; +import { + buildDivergenceScreen, + boundReplayDivergenceForSession, + captureDivergenceObservation, + resolveSuggestionMatchingConfig, +} from './session-replay-divergence.ts'; +import { buildReplayDivergenceFailureResponse } from './session-replay-runtime-failure-response.ts'; +import { buildReplayDivergenceResume } from './session-replay-resume.ts'; +import { + classifyReplayTarget, + identityFieldMismatches, +} from './session-replay-target-classification.ts'; +import { extractReplayTargetToken, readRefLabel } from './session-replay-target-token.ts'; + +// --------------------------------------------------------------------------- +// Daemon-level orchestration: capture, session, wire shaping. +// --------------------------------------------------------------------------- + +/** + * Post-resolution guard payload for a verified action: dispatch re-resolves + * with its own occlusion/visibility guards, and its winner must carry + * `expected` (the verified member's identity) or the interaction layer + * refuses pre-action (`assertExpectedResolvedTarget`, resolution.ts). + * `matchCount` is verification's recorded-selector match count, carried so + * the resulting identity-mismatch divergence satisfies decision 3's + * matchCount presence rule. + */ +export type ReplayVerifiedTargetGuard = { expected: LocalIdentity; matchCount: number }; + +export type ReplayTargetVerificationOutcome = + | { verified: true; guard?: ReplayVerifiedTargetGuard } + | { verified: false; response: DaemonResponse }; + +type TargetBindingDivergenceContext = { + recorded: TargetAnnotationV1; + action: SessionAction; + step: number; + sourcePath: string; + sourceLine: number; + replayPath: string; + artifactPaths: string[]; + sessionName: string; + sessionStore: SessionStore; + responseLevel: ResponseLevel | undefined; + scrubVars: ReturnType; + /** ADR 0012 step 5: the full top-level plan + its digest, for `resume`. */ + planActions: SessionAction[]; + planDigest: string; +}; + +type TargetBindingDivergenceBuilt = { + kind: ReplayDivergenceTargetBindingKind; + matchCount: number | undefined; + observed: LocalIdentity | undefined; + candidateNodes: SnapshotNode[]; + mismatches: string[]; + causeCode: string; + causeMessage: string; + causeHint?: string; + screen: ReplayDivergence['screen']; +}; + +/** The one wire-shaping path for every target-binding divergence (pre-action and post-resolution guard). */ +function buildTargetBindingDivergenceResponse( + context: TargetBindingDivergenceContext, + built: TargetBindingDivergenceBuilt, +): DaemonResponse { + const { + recorded, + action, + step, + sourcePath, + sourceLine, + replayPath, + artifactPaths, + sessionName, + sessionStore, + responseLevel, + scrubVars, + planActions, + planDigest, + } = context; + const sanitize = createReplayDivergenceSanitizer(scrubVars); + const targetBinding = { + classification: built.kind, + ...(built.matchCount !== undefined ? { matchCount: built.matchCount } : {}), + recorded: sanitizeIdentity(identityFromAnnotation(recorded), sanitize), + ...(built.observed ? { observed: sanitizeIdentity(built.observed, sanitize) } : {}), + mismatches: built.mismatches.slice(0, 5).map((entry) => sanitize(entry)), + candidates: built.candidateNodes.slice(0, 5).map((node) => describeCandidate(node, sanitize)), + }; + const divergence: ReplayDivergence = { + version: 1, + kind: built.kind, + step: { index: step, source: { path: sanitize(sourcePath), line: sourceLine } }, + action: sanitize(formatDivergenceActionLabel(action)), + cause: { + code: built.causeCode, + message: sanitize(built.causeMessage), + ...(built.causeHint ? { hint: sanitize(built.causeHint) } : {}), + }, + screen: built.screen, + suggestions: [], + suggestionCount: 0, + // ADR 0012 migration step 5 (PR #1211 machinery): a target-binding + // divergence fires PRE-ACTION, so the failed step itself was never + // executed — resuming AT `step` re-runs exactly the action that did not + // send. `buildReplayDivergenceResume` runs the same skip-safety preflight + // as an action-failure divergence (allowed unless a skipped step produces + // outputEnv or the range crosses runtime control flow). This is the only + // resume site for target-binding divergences. + resume: buildReplayDivergenceResume({ + failedIndex: step, + actions: planActions, + planDigest, + }), + targetBinding, + }; + const bounded = boundReplayDivergenceForSession({ + sessionStore, + sessionName, + divergence, + responseLevel, + }); + const cause: DaemonError = { code: built.causeCode, message: built.causeMessage }; + return buildReplayDivergenceFailureResponse({ + error: cause, + action, + step, + replayPath, + artifactPaths, + divergence: bounded, + scrubVars, + }); +} + +export async function verifyReplayActionTarget(params: { + action: SessionAction; + scope: ReplayVarScope; + sourcePath: string; + sourceLine: number; + replayPath: string; + step: number; + sessionName: string; + sessionStore: SessionStore; + logPath: string; + artifactPaths: string[]; + responseLevel: ResponseLevel | undefined; + planActions: SessionAction[]; + planDigest: string; +}): Promise { + const { + action, + scope, + sourcePath, + sourceLine, + replayPath, + step, + sessionName, + sessionStore, + logPath, + artifactPaths, + responseLevel, + planActions, + planDigest, + } = params; + + const recorded = action.targetEvidence; + if (!recorded) return { verified: true }; + + const session = sessionStore.get(sessionName); + if (!session) return { verified: true }; + + // Resolved ONLY to extract the match token below — never serialized onto + // the wire (the response is always built from the ORIGINAL `action`, like + // every other replay divergence, so an expanded `${VAR}` never leaks + // through an un-scrubbed positional). + const resolvedAction = resolveReplayAction(action, scope, { file: sourcePath, line: sourceLine }); + const token = extractReplayTargetToken(resolvedAction); + if (token === undefined) return { verified: true }; + if (!token.startsWith('@') && !tryParseSelectorChain(token)) { + // A malformed recorded selector is not this module's concern — the real + // dispatch will parse (and fail) it the same way an unannotated action + // would. + return { verified: true }; + } + + const scrubVars = collectReplayScrubbableVarValues(scope); + const sanitize = createReplayDivergenceSanitizer(scrubVars); + const context: TargetBindingDivergenceContext = { + recorded, + action, + step, + sourcePath, + sourceLine, + replayPath, + artifactPaths, + sessionName, + sessionStore, + responseLevel, + scrubVars, + planActions, + planDigest, + }; + + // Decision 3 path 1: a recorded-`unverifiable` annotation fires before any + // resolution — matchCount is omitted (never computed). + if (recorded.verification === 'unverifiable') { + const observation = await captureDivergenceObservation({ + session, + sessionName, + sessionStore, + logPath, + action, + }); + return { + verified: false, + response: buildTargetBindingDivergenceResponse(context, { + kind: 'identity-unverifiable', + matchCount: undefined, + observed: undefined, + candidateNodes: [], + mismatches: [], + causeCode: 'IDENTITY_UNVERIFIABLE', + causeMessage: + 'The recorded target evidence could not verify itself when it was captured (a structural capture anomaly), so replay cannot trust it before acting.', + screen: buildDivergenceScreen(observation, sanitize), + }), + }; + } + + const observation = await captureDivergenceObservation({ + session, + sessionName, + sessionStore, + logPath, + action, + }); + if (observation.state !== 'available') { + return { + verified: false, + response: buildTargetBindingDivergenceResponse(context, { + kind: 'identity-unverifiable', + matchCount: undefined, + observed: undefined, + candidateNodes: [], + mismatches: [], + causeCode: 'IDENTITY_UNVERIFIABLE', + causeMessage: `Could not capture a fresh snapshot to verify the recorded target before acting (${observation.reason}).`, + causeHint: observation.hint, + screen: buildDivergenceScreen(observation, sanitize), + }), + }; + } + + const config = resolveSuggestionMatchingConfig(action); + const classification = classifyReplayTarget({ + recorded, + token, + nodes: observation.nodes, + platform: session.device.platform, + refLabel: readRefLabel(action), + requireRect: config.requiresRect, + allowDisambiguation: config.allowDisambiguation, + }); + + if (classification.verified) { + return { + verified: true, + guard: { + expected: boundedLocalIdentity(classification.winnerNode), + matchCount: classification.matchCount, + }, + }; + } + + return { + verified: false, + response: buildTargetBindingDivergenceResponse(context, { + kind: classification.kind, + matchCount: classification.matchCount, + observed: classification.observedNode + ? boundedLocalIdentity(classification.observedNode) + : undefined, + candidateNodes: classification.candidateNodes, + mismatches: classification.mismatches, + causeCode: classification.causeCode, + causeMessage: classification.causeMessage, + screen: buildDivergenceScreen(observation, sanitize), + }), + }; +} + +// --------------------------------------------------------------------------- +// Post-resolution guard (coordinator addition to step 4): dispatch's own +// resolution runs guards verification does not replicate (occlusion +// filtering, visibility-preferring disambiguation), so its winner can differ +// from the verified member even after verification passed. The interaction +// layer cross-checks the two identities pre-action +// (`assertExpectedResolvedTarget`, resolution.ts) and refuses with the +// marker below; the replay loop converts that refusal into an +// identity-mismatch target-binding divergence here. +// --------------------------------------------------------------------------- + +export function isReplayTargetGuardMismatchResponse(response: DaemonResponse): boolean { + return !response.ok && response.error.details?.reason === REPLAY_TARGET_GUARD_MISMATCH_REASON; +} + +export async function buildReplayTargetGuardMismatchResponse(params: { + action: SessionAction; + scope: ReplayVarScope; + guard: ReplayVerifiedTargetGuard; + failedResponse: DaemonResponse; + sourcePath: string; + sourceLine: number; + replayPath: string; + step: number; + sessionName: string; + sessionStore: SessionStore; + logPath: string; + artifactPaths: string[]; + responseLevel: ResponseLevel | undefined; + planActions: SessionAction[]; + planDigest: string; +}): Promise { + const { + action, + scope, + guard, + failedResponse, + sourcePath, + sourceLine, + replayPath, + step, + sessionName, + sessionStore, + logPath, + artifactPaths, + responseLevel, + planActions, + planDigest, + } = params; + // The guard is only ever attached to an annotated action; fall back to the + // original failure if the invariant is somehow violated. + const recorded = action.targetEvidence; + if (!recorded) return failedResponse; + + const scrubVars = collectReplayScrubbableVarValues(scope); + const sanitize = createReplayDivergenceSanitizer(scrubVars); + const observed = failedResponse.ok + ? undefined + : readGuardMismatchObservedIdentity(failedResponse.error.details?.observed); + + const session = sessionStore.get(sessionName); + const observation = session + ? await captureDivergenceObservation({ session, sessionName, sessionStore, logPath, action }) + : ({ + state: 'unavailable', + reason: 'no-session', + hint: 'The session closed before a post-failure screen could be captured.', + } as const); + + return buildTargetBindingDivergenceResponse( + { + recorded, + action, + step, + sourcePath, + sourceLine, + replayPath, + artifactPaths, + sessionName, + sessionStore, + responseLevel, + scrubVars, + planActions, + planDigest, + }, + { + kind: 'identity-mismatch', + matchCount: guard.matchCount, + observed, + candidateNodes: [], + mismatches: observed ? identityFieldMismatches(recorded, observed) : [], + causeCode: 'IDENTITY_MISMATCH', + causeMessage: + 'Dispatch resolution (with occlusion/visibility guards) resolved a different element than pre-action verification isolated; the action was not sent.', + screen: buildDivergenceScreen(observation, sanitize), + }, + ); +} + +function readGuardMismatchObservedIdentity(value: unknown): LocalIdentity | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; + const record = value as Record; + if (typeof record.role !== 'string') return undefined; + return { + ...(typeof record.id === 'string' ? { id: record.id } : {}), + role: record.role, + ...(typeof record.label === 'string' ? { label: record.label } : {}), + }; +} + +function identityFromAnnotation(recorded: TargetAnnotationV1): ReplayDivergenceTargetIdentity { + return { + ...(recorded.id !== undefined ? { id: recorded.id } : {}), + role: recorded.role, + ...(recorded.label !== undefined ? { label: recorded.label } : {}), + }; +} + +function sanitizeIdentity( + identity: ReplayDivergenceTargetIdentity, + sanitize: (value: string, limit?: number) => string, +): ReplayDivergenceTargetIdentity { + return { + ...(identity.id !== undefined ? { id: sanitize(identity.id) } : {}), + role: sanitize(identity.role), + ...(identity.label !== undefined ? { label: sanitize(identity.label) } : {}), + }; +} + +function describeCandidate( + node: SnapshotNode, + sanitize: (value: string, limit?: number) => string, +): ReplayDivergenceTargetCandidate { + const role = formatRole(node.type ?? 'Element'); + const label = displayLabel(node, role); + return { + ...(node.ref ? { ref: node.ref } : {}), + role: sanitize(role), + ...(label ? { label: sanitize(label) } : {}), + }; +} diff --git a/src/daemon/selector-runtime.ts b/src/daemon/selector-runtime.ts index 1ec390516..3bb47dc27 100644 --- a/src/daemon/selector-runtime.ts +++ b/src/daemon/selector-runtime.ts @@ -137,7 +137,10 @@ export async function dispatchGetViaRuntime( const invalidRefFlagsResponse = refSnapshotFlagGuardResponse('get', req.flags); if (invalidRefFlagsResponse) return invalidRefFlagsResponse; } - if (target.target.kind === 'selector') { + // ADR 0012 step 4: a guarded replay dispatch must resolve through the + // snapshot path so the post-resolution identity guard runs. + const replayTargetGuard = req.internal?.replayTargetGuard; + if (target.target.kind === 'selector' && !replayTargetGuard) { const directResponse = await dispatchDirectIosSelectorGet(params, sub, target.target.selector); if (directResponse) return directResponse; } @@ -165,6 +168,7 @@ export async function dispatchGetViaRuntime( requestId: req.meta?.requestId, property: sub, target: target.target, + expectedResolvedTarget: replayTargetGuard, }); recordIfSession( params.sessionStore, diff --git a/src/daemon/session-target-evidence.ts b/src/daemon/session-target-evidence.ts index 3ac772901..f5be953f8 100644 --- a/src/daemon/session-target-evidence.ts +++ b/src/daemon/session-target-evidence.ts @@ -6,24 +6,25 @@ * against the tree the resolver already captured; it never captures, and * callers gate it on `session.recordSession`. Tree-agnostic spec pieces live * in `src/replay/target-identity.ts`, shared with the parser. + * + * The structural helpers below (identity/ancestry/sibling/scroll-region/ + * viewport-order) are exported so migration step 4's replay-time enforcement + * (`session-replay-target-verification.ts`) computes every ordinal with the + * SAME functions the writer used — "record and replay compute this ordinal + * identically by definition" (decision 3, "Record-time write"). */ import type { SnapshotNode } from '../kernel/snapshot.ts'; import { resolveRectCenter } from '../utils/rect-center.ts'; -import { normalizeType } from '../snapshot/snapshot-processing.ts'; import { findNearestScrollableContainer } from './snapshot-presentation/tree.ts'; +import { readNodeLocalIdentity } from '../replay/target-identity-node.ts'; import { classifyTargetBindingMatch, matchesAncestryPrefix, matchesLocalIdentity, - normalizeIdentifierField, - normalizeLabelField, - normalizeRoleField, serializeTargetAnnotationV1, - truncateToUtf8Bytes, utf8ByteLength, TARGET_ANNOTATION_MAX_ANCESTRY, - TARGET_ANNOTATION_MAX_FIELD_BYTES, TARGET_ANNOTATION_MAX_PAYLOAD_BYTES, type LocalIdentity, type TargetAncestryEntry, @@ -113,27 +114,16 @@ export function computeTargetEvidence( // primitives, computed once per candidate node against the record-time tree. // --------------------------------------------------------------------------- -function buildIndexMap(nodes: readonly SnapshotNode[]): Map { +export function buildIndexMap(nodes: readonly SnapshotNode[]): Map { const map = new Map(); for (const node of nodes) map.set(node.index, node); return map; } -/** The one identity reader: normalized AND field-capped, on every path. */ -function boundedLocalIdentity(node: SnapshotNode): LocalIdentity { - const role = normalizeRoleField(normalizeType(node.type ?? '')); - const id = normalizeIdentifierField(node.identifier); - const label = normalizeLabelField(node.label); - return { - ...(id !== undefined ? { id: truncateToUtf8Bytes(id, TARGET_ANNOTATION_MAX_FIELD_BYTES) } : {}), - role: truncateToUtf8Bytes(role, TARGET_ANNOTATION_MAX_FIELD_BYTES), - ...(label !== undefined - ? { label: truncateToUtf8Bytes(label, TARGET_ANNOTATION_MAX_FIELD_BYTES) } - : {}), - }; -} +/** The one identity reader (normalized AND field-capped, on every path): shared with dispatch's post-resolution guard. */ +export const boundedLocalIdentity = readNodeLocalIdentity; -type AncestryWalk = { +export type AncestryWalk = { chain: TargetAncestryEntry[]; /** * Decision 3 capture anomaly: a `parentIndex` that resolves to no node, or @@ -144,7 +134,7 @@ type AncestryWalk = { }; /** Decision 3 "Ancestry": nearest K ancestors, leaf→root, {role,label?}. */ -function buildAncestryChain( +export function buildAncestryChain( node: SnapshotNode, byIndex: Map, limit: number, @@ -172,7 +162,7 @@ function buildAncestryChain( * its OWN parent's children, in document order. Root-level nodes (no parent) * are siblings of every other root-level node. */ -function computeSiblingOrdinal(nodes: readonly SnapshotNode[], node: SnapshotNode): number { +export function computeSiblingOrdinal(nodes: readonly SnapshotNode[], node: SnapshotNode): number { const parentIndex = node.parentIndex; let ordinal = 0; for (const candidate of nodes) { @@ -184,7 +174,7 @@ function computeSiblingOrdinal(nodes: readonly SnapshotNode[], node: SnapshotNod } /** Decision 3 record-time write step 4: nearest scrollable ancestor's local identity, or `undefined` for *none*. */ -function computeScrollRegionKey( +export function computeScrollRegionKey( node: SnapshotNode, byIndex: Map, ): TargetScrollRegion | undefined { @@ -201,7 +191,7 @@ function computeScrollRegionKey( }; } -function scrollRegionKeysEqual( +export function scrollRegionKeysEqual( a: TargetScrollRegion | undefined, b: TargetScrollRegion | undefined, ): boolean { @@ -230,6 +220,28 @@ type DisambiguationDomain = { viewportOrder: number; }; +/** + * Decision 3 record-time write step 2 / replay-time verification's identity + * set I: candidates sharing the recorded local identity with a matching + * leaf-anchored ancestry prefix. Shared by the writer's self-check (over the + * full record-time tree) and replay-time verification (over the recorded + * selector/ref's matched-node domain) — "record and replay compute this + * ordinal identically by definition" applies to this filter too. + */ +export function filterIdentitySet( + candidates: readonly SnapshotNode[], + byIndex: Map, + identity: LocalIdentity, + ancestry: readonly TargetAncestryEntry[], +): SnapshotNode[] { + return candidates.filter((candidate) => { + if (!matchesLocalIdentity(boundedLocalIdentity(candidate), identity)) return false; + const observed = buildAncestryChain(candidate, byIndex, Math.max(ancestry.length, 1)); + // A candidate with a broken parent walk cannot prove the prefix. + return !observed.broken && matchesAncestryPrefix(observed.chain, ancestry); + }); +} + function computeDisambiguationDomain(params: { nodes: readonly SnapshotNode[]; byIndex: Map; @@ -241,14 +253,7 @@ function computeDisambiguationDomain(params: { }): DisambiguationDomain { const { nodes, byIndex, node, identity, ancestry, sibling, scrollRegion } = params; - // Step 2: all nodes sharing the winner's local identity with a matching - // leaf-anchored ancestry prefix. - const identitySet = nodes.filter((candidate) => { - if (!matchesLocalIdentity(boundedLocalIdentity(candidate), identity)) return false; - const observed = buildAncestryChain(candidate, byIndex, Math.max(ancestry.length, 1)); - // A candidate with a broken parent walk cannot prove the prefix. - return !observed.broken && matchesAncestryPrefix(observed.chain, ancestry); - }); + const identitySet = filterIdentitySet(nodes, byIndex, identity, ancestry); const siblingMatches = identitySet.filter( (candidate) => computeSiblingOrdinal(nodes, candidate) === sibling, @@ -271,7 +276,7 @@ function computeDisambiguationDomain(params: { } /** Decision 3: rect center top-to-bottom then left-to-right; ties by document order; rect-less last, in document order. */ -function orderByViewportPosition(members: readonly SnapshotNode[]): SnapshotNode[] { +export function orderByViewportPosition(members: readonly SnapshotNode[]): SnapshotNode[] { return members .map((node, documentOrder) => ({ node, documentOrder, center: resolveRectCenter(node.rect) })) .sort((a, b) => { diff --git a/src/daemon/types.ts b/src/daemon/types.ts index b6a014ad4..a30534819 100644 --- a/src/daemon/types.ts +++ b/src/daemon/types.ts @@ -18,7 +18,7 @@ import type { RecordingScope } from '../contracts/recording-scope.ts'; import type { DeviceInfo, Platform, PlatformSelector } from '../kernel/device.ts'; import type { ExecBackgroundResult, ExecResult } from '../utils/exec.ts'; import type { SnapshotState } from '../kernel/snapshot.ts'; -import type { TargetAnnotationV1 } from '../replay/target-identity.ts'; +import type { LocalIdentity, TargetAnnotationV1 } from '../replay/target-identity.ts'; import type { AppLogFailure, AppLogState } from './app-log-process.ts'; import type { DeviceLease } from './lease-registry.ts'; import type { AndroidNativePerfSession } from '../platforms/android/perf.ts'; @@ -56,6 +56,15 @@ export type DaemonOpenLifecycle = { type DaemonRequestInternal = { openLifecycle?: DaemonOpenLifecycle; admittedLease?: DeviceLease; + /** + * ADR 0012 step 4 post-resolution guard: the verified target member's + * normalized local identity, set ONLY by the replay step loop when + * dispatching an annotated action whose pre-action verification passed. + * Interaction handlers thread it into command options as + * `expectedResolvedTarget`; dispatch's own resolution refuses (pre-action) + * when its winner carries a different identity. + */ + replayTargetGuard?: LocalIdentity; }; export type DaemonRequest = Omit & { diff --git a/src/replay/__tests__/divergence.test.ts b/src/replay/__tests__/divergence.test.ts index 204190a50..8e620a9f2 100644 --- a/src/replay/__tests__/divergence.test.ts +++ b/src/replay/__tests__/divergence.test.ts @@ -306,3 +306,163 @@ test('scrubReplayVarValues replaces every occurrence with a named marker, longes 'value= done', ); }); + +// --- ADR 0012 migration step 4: target-binding divergence wire shape --- + +test('a target-binding divergence kind carries targetBinding.classification equal to kind', () => { + for (const kind of ['selector-miss', 'identity-mismatch', 'identity-unverifiable'] as const) { + const divergence = buildDivergence({ + kind, + targetBinding: { + classification: kind, + matchCount: kind === 'selector-miss' ? 0 : 1, + recorded: { role: 'button', label: 'Save' }, + mismatches: [], + candidates: [], + }, + }); + assert.equal(divergence.targetBinding?.classification, divergence.kind); + } +}); + +test('matchCount presence rule: the key is entirely absent (never null) for recorded-unverifiable', () => { + const divergence = buildDivergence({ + kind: 'identity-unverifiable', + targetBinding: { + classification: 'identity-unverifiable', + recorded: { role: 'button', label: 'Save' }, + mismatches: [], + candidates: [], + }, + }); + const serialized = JSON.parse(JSON.stringify(divergence)) as Record; + const targetBinding = serialized.targetBinding as Record; + assert.equal('matchCount' in targetBinding, false); +}); + +test('matchCount presence rule: the key is present (0..N) on every path that performs resolution', () => { + for (const matchCount of [0, 1, 4]) { + const divergence = buildDivergence({ + kind: matchCount === 0 ? 'selector-miss' : 'identity-mismatch', + targetBinding: { + classification: matchCount === 0 ? 'selector-miss' : 'identity-mismatch', + matchCount, + recorded: { role: 'button', label: 'Save' }, + mismatches: [], + candidates: [], + }, + }); + assert.equal(divergence.targetBinding?.matchCount, matchCount); + } +}); + +test('a target-binding divergence carries a real computed resume object (not a stub)', () => { + // ADR 0012 step 5 (#1211) wiring: a target-binding divergence fires + // pre-action, so it resumes at the SAME failed step with a real digest — + // never the retired `{allowed:false, reason:'resume not yet supported'}` stub. + const divergence = buildDivergence({ + kind: 'identity-mismatch', + resume: { allowed: true, from: 7, planDigest: 'abc123' }, + targetBinding: { + classification: 'identity-mismatch', + matchCount: 1, + recorded: { id: 'save', role: 'button', label: 'Save' }, + observed: { id: 'save-v2', role: 'button', label: 'Save' }, + mismatches: [], + candidates: [], + }, + }); + assert.deepEqual(divergence.resume, { allowed: true, from: 7, planDigest: 'abc123' }); + assert.notEqual((divergence.resume as { reason?: string }).reason, 'resume not yet supported'); +}); + +test('boundReplayDivergence keeps targetBinding on the minimal overflow fallback (it is small repair data, not a bulk digest)', () => { + const divergence = buildDivergence({ + kind: 'identity-unverifiable', + cause: { code: 'IDENTITY_UNVERIFIABLE', message: 'x'.repeat(20_000) }, + targetBinding: { + classification: 'identity-unverifiable', + matchCount: 4, + recorded: { id: 'save', role: 'button', label: 'Save' }, + mismatches: [], + candidates: [ + { ref: 'e1', role: 'button', label: 'Row' }, + { ref: 'e2', role: 'button', label: 'Row' }, + ], + }, + screen: { + state: 'available', + refsGeneration: 1, + refs: Array.from({ length: 20 }, (_, i) => ({ + ref: `e${i}`, + role: 'button', + label: 'B'.repeat(200), + })), + }, + }); + const bounded = boundReplayDivergence({ + divergence, + level: 'digest', + writeOverflowArtifact: () => ({ artifactPath: '/tmp/session/replay-divergence/1.json' }), + }); + assert.ok(bounded.overflow); // confirms the minimal-fallback path actually ran + assert.deepEqual(bounded.targetBinding, divergence.targetBinding); +}); + +test('formatReplayDivergenceReport renders matchCount, mismatches, and candidates for a target-binding divergence', async () => { + const { formatReplayDivergenceReport } = await import('../divergence.ts'); + const report = formatReplayDivergenceReport({ + divergence: { + version: 1, + kind: 'identity-mismatch', + step: { index: 2, source: { path: '/tmp/flow.ad', line: 3 } }, + action: 'click id="save"', + cause: { code: 'IDENTITY_MISMATCH', message: 'wrong element' }, + screen: { state: 'unavailable', reason: 'sparse-snapshot' }, + suggestions: [], + suggestionCount: 0, + resume: { allowed: true, from: 2, planDigest: 'deadbeef' }, + targetBinding: { + classification: 'identity-mismatch', + matchCount: 1, + recorded: { id: 'save', role: 'button', label: 'Save' }, + observed: { id: 'save-v2', role: 'button', label: 'Save' }, + mismatches: ['id: recorded=save observed=save-v2'], + candidates: [], + }, + }, + }); + assert.ok(report); + assert.match(report!, /Target binding: identity-mismatch \(matchCount 1\)/); + assert.match(report!, /mismatches: id: recorded=save observed=save-v2/); +}); + +test('formatReplayDivergenceReport lists candidates for an identity-unverifiable target-binding divergence', async () => { + const { formatReplayDivergenceReport } = await import('../divergence.ts'); + const report = formatReplayDivergenceReport({ + divergence: { + version: 1, + kind: 'identity-unverifiable', + step: { index: 1, source: { path: '/tmp/flow.ad', line: 1 } }, + action: 'click role=button label="Row"', + cause: { code: 'IDENTITY_UNVERIFIABLE', message: 'ambiguous' }, + screen: { state: 'unavailable', reason: 'sparse-snapshot' }, + suggestions: [], + suggestionCount: 0, + resume: { allowed: true, from: 1, planDigest: 'deadbeef' }, + targetBinding: { + classification: 'identity-unverifiable', + matchCount: 2, + recorded: { role: 'button', label: 'Row' }, + mismatches: [], + candidates: [ + { ref: 'e1', role: 'button', label: 'Row' }, + { ref: 'e2', role: 'button', label: 'Row' }, + ], + }, + }, + }); + assert.ok(report); + assert.match(report!, /2 candidate\(s\) shared the recorded identity/); + assert.match(report!, /@e1 \[button\] "Row"/); +}); diff --git a/src/replay/divergence.ts b/src/replay/divergence.ts index 27991283b..de3066d25 100644 --- a/src/replay/divergence.ts +++ b/src/replay/divergence.ts @@ -2,14 +2,57 @@ import type { ResponseLevel } from '../kernel/contracts.ts'; import { redactDiagnosticData } from '../kernel/redaction.ts'; /** - * ADR 0012 migration step 2: structured replay divergence report. + * ADR 0012 migration steps 2 + 4: structured replay divergence report. * - * `kind` is scoped to `'action-failure'` in this step — the target-binding - * kinds (`selector-miss`/`identity-mismatch`/`identity-unverifiable`) are - * decision 3/step 4 territory and are not produced here. `targetBinding` is - * likewise out of scope (step 4). + * `kind` is `'action-failure'` for an ordinary failed step (step 2), or one + * of decision 3's three target-binding classes when pre-action verification + * (step 4, `session-replay-target-verification.ts`) blocks a resolved-target + * action before it is sent: `selector-miss` (the recorded selector/ref no + * longer matches anything), `identity-mismatch` (something matches, but not + * the recorded identity — or a unique/isolated identity-set member differs + * from the resolution winner), or `identity-unverifiable` (the recording + * itself carries no trustworthy identity, or several candidates share the + * recorded identity and neither disambiguation signal isolates one). A + * target-binding `kind` always carries `targetBinding` with + * `targetBinding.classification === kind`. */ -export type ReplayDivergenceKind = 'action-failure'; +export type ReplayDivergenceKind = + | 'action-failure' + | 'selector-miss' + | 'identity-mismatch' + | 'identity-unverifiable'; + +export type ReplayDivergenceTargetBindingKind = Exclude; + +/** The identity tier of a `target-v1` annotation (decision 3), as reported on the wire. */ +export type ReplayDivergenceTargetIdentity = { + id?: string; + role: string; + label?: string; +}; + +export type ReplayDivergenceTargetCandidate = { + ref?: string; + role: string; + label?: string; +}; + +/** + * ADR 0012 decision 4: the `targetBinding` object attached to a + * target-binding divergence. `matchCount` follows decision 3's conditional + * presence rule exactly — present (0..N) on every path that performs + * resolution, absent (key omitted, never `null`) only when + * `classification === 'identity-unverifiable'` was reached through path 1 + * (a recorded-`unverifiable` annotation, before any resolution). + */ +export type ReplayDivergenceTargetBinding = { + classification: ReplayDivergenceTargetBindingKind; + matchCount?: number; + recorded: ReplayDivergenceTargetIdentity; + observed?: ReplayDivergenceTargetIdentity; + mismatches: string[]; + candidates: ReplayDivergenceTargetCandidate[]; +}; export type ReplayDivergenceStepSource = { path: string; @@ -94,6 +137,8 @@ export type ReplayDivergence = { resume: ReplayDivergenceResume; overflow?: ReplayDivergenceOverflow; artifactUnavailable?: true; + /** Present iff `kind` is a target-binding kind; `targetBinding.classification === kind`. */ + targetBinding?: ReplayDivergenceTargetBinding; }; type BoundedResponseLevel = 'digest' | 'default' | 'full'; @@ -253,6 +298,10 @@ function buildMinimalReplayDivergence(capped: ReplayDivergence): ReplayDivergenc suggestions: [], suggestionCount: capped.suggestionCount, resume: capped.resume, + // targetBinding is the actual repair value of a target-binding + // divergence and is small relative to a full screen digest — keep it on + // the minimal fallback rather than dropping it with the screen/suggestions. + ...(capped.targetBinding ? { targetBinding: capped.targetBinding } : {}), }; } @@ -269,6 +318,7 @@ export function formatReplayDivergenceReport( const record = divergence as Record; const lines = [ ...divergenceStepLine(record.step), + ...divergenceTargetBindingLines(record.kind, record.targetBinding), ...divergenceScreenLine(record.screen), ...divergenceSuggestionLines(record.suggestions, record.suggestionCount), ...divergenceOverflowLine(record.overflow, record.artifactUnavailable), @@ -276,6 +326,35 @@ export function formatReplayDivergenceReport( return lines.length > 0 ? lines.join('\n') : null; } +function divergenceTargetBindingLines(kind: unknown, targetBinding: unknown): string[] { + if (typeof kind !== 'string' || kind === 'action-failure') return []; + const record = targetBinding as Record | undefined; + if (!record) return []; + return [ + divergenceTargetBindingHeaderLine(kind, record.matchCount), + ...divergenceTargetBindingMismatchLines(record.mismatches), + ...divergenceTargetBindingCandidateLines(record.candidates), + ]; +} + +function divergenceTargetBindingHeaderLine(kind: string, matchCount: unknown): string { + const suffix = typeof matchCount === 'number' ? ` (matchCount ${matchCount})` : ''; + return `Target binding: ${kind}${suffix} — recorded target evidence did not verify.`; +} + +function divergenceTargetBindingMismatchLines(mismatches: unknown): string[] { + if (!Array.isArray(mismatches) || mismatches.length === 0) return []; + return [` mismatches: ${mismatches.slice(0, 5).join('; ')}`]; +} + +function divergenceTargetBindingCandidateLines(candidates: unknown): string[] { + if (!Array.isArray(candidates) || candidates.length === 0) return []; + return [ + ` ${candidates.length} candidate(s) shared the recorded identity:`, + ...candidates.slice(0, 5).map((candidate) => ` ${divergenceScreenRefLine(candidate)}`), + ]; +} + function divergenceStepLine(step: unknown): string[] { const record = step as Record | undefined; if (typeof record?.index !== 'number') return []; diff --git a/src/replay/target-identity-node.ts b/src/replay/target-identity-node.ts new file mode 100644 index 000000000..afa99e82a --- /dev/null +++ b/src/replay/target-identity-node.ts @@ -0,0 +1,52 @@ +/** + * ADR 0012 decision 3: the ONE snapshot-node local-identity reader — + * normalized (NFC, label whitespace collapse, `normalizeType` role) AND + * 256-byte field-capped, on every path. Shared by the record-time writer + * (`src/daemon/session-target-evidence.ts`), replay-time verification + * (`src/daemon/handlers/session-replay-target-verification.ts`), and the + * dispatch-side post-resolution guard + * (`src/commands/interaction/runtime/resolution.ts`), so all three compute + * a node's identity with byte-identical semantics. Kept out of + * `target-identity.ts` so that module stays tree-agnostic. + */ + +import type { RawSnapshotNode } from '../kernel/snapshot.ts'; +import { normalizeType } from '../snapshot/snapshot-processing.ts'; +import { + normalizeIdentifierField, + normalizeLabelField, + normalizeRoleField, + truncateToUtf8Bytes, + TARGET_ANNOTATION_MAX_FIELD_BYTES, + type LocalIdentity, +} from './target-identity.ts'; + +export function readNodeLocalIdentity( + node: Pick, +): LocalIdentity { + const role = normalizeRoleField(normalizeType(node.type ?? '')); + const id = normalizeIdentifierField(node.identifier); + const label = normalizeLabelField(node.label); + return { + ...(id !== undefined ? { id: truncateToUtf8Bytes(id, TARGET_ANNOTATION_MAX_FIELD_BYTES) } : {}), + role: truncateToUtf8Bytes(role, TARGET_ANNOTATION_MAX_FIELD_BYTES), + ...(label !== undefined + ? { label: truncateToUtf8Bytes(label, TARGET_ANNOTATION_MAX_FIELD_BYTES) } + : {}), + }; +} + +/** Exact-equality comparison of two normalized local identities (all three fields). */ +export function localIdentitiesEqual(a: LocalIdentity, b: LocalIdentity): boolean { + return a.id === b.id && a.role === b.role && a.label === b.label; +} + +/** + * `details.reason` marker on the pre-action refusal thrown by dispatch's + * post-resolution guard (`assertExpectedResolvedTarget`, + * `src/commands/interaction/runtime/resolution.ts`), detected by the replay + * step loop to convert the refusal into an identity-mismatch target-binding + * divergence. Lives here (replay zone) so both the commands and daemon + * layers can share it without a layering back-edge. + */ +export const REPLAY_TARGET_GUARD_MISMATCH_REASON = 'replay_target_guard_mismatch'; diff --git a/src/selectors/index.ts b/src/selectors/index.ts index cb77c26e6..6365a8ff3 100644 --- a/src/selectors/index.ts +++ b/src/selectors/index.ts @@ -1,5 +1,5 @@ export type { Selector, SelectorChain } from './arguments.ts'; -export type { SelectorDiagnostics, SelectorResolution } from './resolve.ts'; +export type { SelectorDiagnostics, SelectorResolution, SelectorChainMatchList } from './resolve.ts'; export { parseSelectorChain, @@ -12,6 +12,7 @@ export { isNodeVisible, isNodeEditable } from './match.ts'; export { resolveSelectorChain, + listSelectorChainMatches, findSelectorChainMatch, formatSelectorFailure, selectorFailureHint, diff --git a/src/selectors/resolve.ts b/src/selectors/resolve.ts index 067614c53..6d466f90b 100644 --- a/src/selectors/resolve.ts +++ b/src/selectors/resolve.ts @@ -73,6 +73,37 @@ export function resolveSelectorChain( return null; } +export type SelectorChainMatchList = { + selector: Selector; + selectorIndex: number; + matchedNodes: SnapshotNode[]; +}; + +/** + * ADR 0012 migration step 4: the raw matched-node list for whichever chain + * alternative `resolveSelectorChain` would pick (first alternative with any + * match, in order) — mirrors `analyzeSelectorMatches`'s alternate-selection + * exactly, decoupled from uniqueness/disambiguation, so a replay-time + * verifier can compute `matchCount` and the recorded-identity set over the + * SAME domain resolution itself used, independent of whether resolution + * could pick a unique winner. + */ +export function listSelectorChainMatches( + nodes: SnapshotState['nodes'], + chain: SelectorChain, + options: { platform: Platform | PublicPlatform; requireRect?: boolean }, +): SelectorChainMatchList | null { + const requireRect = options.requireRect ?? false; + for (const [i, selector] of chain.selectors.entries()) { + const matchedNodes = nodes.filter((node) => { + if (requireRect && !node.rect) return false; + return matchesSelector(node, selector, options.platform); + }); + if (matchedNodes.length > 0) return { selector, selectorIndex: i, matchedNodes }; + } + return null; +} + export function findSelectorChainMatch( nodes: SnapshotState['nodes'], chain: SelectorChain, From 0c66e67b422c4e06134641f2193fbedc47a26fd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 11 Jul 2026 16:40:50 +0200 Subject: [PATCH 2/3] test(replay): assert real computed resume on identity-mismatch and guard-mismatch paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Non-blocking review follow-up: only the selector-miss runtime test asserted the computed resume {allowed, from, planDigest}. All three target-binding paths funnel through buildTargetBindingDivergenceResponse (one shared resume site), so this is coverage not a bug — but pin resume on the identity-mismatch and guard-mismatch tests too so no path can regress its resume wiring silently. --- ...sion-replay-target-verification-runtime.test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/daemon/handlers/__tests__/session-replay-target-verification-runtime.test.ts b/src/daemon/handlers/__tests__/session-replay-target-verification-runtime.test.ts index 6aaf67b23..893a00ca4 100644 --- a/src/daemon/handlers/__tests__/session-replay-target-verification-runtime.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-target-verification-runtime.test.ts @@ -197,6 +197,13 @@ test('an identity-mismatch divergence reports matchCount and an observed identit expect(targetBinding.observed).toEqual({ id: 'save-v2', role: 'button', label: 'Save' }); expect(Array.isArray(targetBinding.mismatches)).toBe(true); expect((targetBinding.mismatches as string[]).length).toBeGreaterThan(0); + // Real computed resume (shared builder): pre-action divergence at step 1 + // resumes AT the failed step with a concrete plan digest, never the stub. + const resume = divergence.resume as { allowed: boolean; from?: number; planDigest?: string }; + expect(resume.allowed).toBe(true); + expect(resume.from).toBe(1); + expect(typeof resume.planDigest).toBe('string'); + expect((resume.planDigest ?? '').length).toBeGreaterThan(0); }); test('a recorded-unverifiable annotation is an identity-unverifiable divergence with matchCount omitted', async () => { @@ -368,6 +375,13 @@ test('a dispatch-time guard mismatch converts to an identity-mismatch target-bin expect((targetBinding.mismatches as string[]).some((entry) => entry.includes('save-decoy'))).toBe( true, ); + // The guard-mismatch path funnels through the same shared builder: it must + // also carry a real computed resume (from = the failed step), not the stub. + const resume = divergence.resume as { allowed: boolean; from?: number; planDigest?: string }; + expect(resume.allowed).toBe(true); + expect(resume.from).toBe(1); + expect(typeof resume.planDigest).toBe('string'); + expect((resume.planDigest ?? '').length).toBeGreaterThan(0); }); // NOTE: the "--update re-verifies a healed action" test was removed here — From f4a60c99d3e4a445b6acc7022bdf331b840350b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 11 Jul 2026 17:40:47 +0200 Subject: [PATCH 3/3] fix(replay): carry structural denotation into the post-resolution guard (P1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard carried only LOCAL identity {id, role, label}. ADR path 6 isolates ONE member among several SAME-local-identity nodes via sibling / region-scoped viewportOrder — a discriminator lost at the guard. So if verification denoted duplicate A but dispatch's occlusion/visibility filter selected duplicate B (identical id/role/label), the guard passed and SENT the action on the wrong element — the exact verified-but-taps-different-node mis-binding step 4 exists to prevent. Fix: replayTargetGuard now carries a structural denotation of the verified member — its pre-order document index (decision 3's canonical total order, `node.index` after the shared reindex pipeline) plus its same-parent sibling ordinal. `assertExpectedResolvedTarget` compares BOTH local identity AND structural denotation against dispatch's pre-promotion capture, refusing pre-action when EITHER differs. Two distinct duplicates always differ in document order, so a different-member resolution can no longer pass. The promotion exemption is preserved (comparison is on the verified LEAF, and duplicates are distinct leaves). New helpers live in target-identity-node.ts (replay zone) so writer/classifier/guard share one sibling-ordinal implementation; computeSiblingOrdinal now delegates to it. The guard-mismatch divergence surfaces a `position:` mismatch line (from the structural denotations) so `mismatches` is never empty when local identity is identical. END-TO-END regression (session-replay-target-guard.test.ts): two "Save" buttons with IDENTICAL id/role/label, A covered+deeper (verification's winner), B visible (dispatch's winner) — driven through the real interaction runtime (`device.interactions.press`), the guard refuses pre-action with ZERO backend taps AND zero native tapTargets, the refusal driven purely by the structural denotation (both observedStructural/expectedStructural asserted). Plus a same-structural passing case, a runtime-level `position:`-mismatch divergence test, and updated guard-threading assertions for the {identity, structural} shape. Wire contract unchanged: replayTargetGuard is DaemonRequestInternal (never on the wire); decision 4's targetBinding {recorded, observed} stay LOCAL identity per decision 3's Identity tier — the structural denotation is an internal guard concern, surfaced only as a `mismatches` string. No ADR change needed. --- .../interaction/runtime/resolution.ts | 65 +++++-- .../interaction/runtime/selector-read.ts | 14 +- .../session-replay-target-guard.test.ts | 173 +++++++++++++++++- ...replay-target-verification-runtime.test.ts | 67 ++++++- src/daemon/handlers/interaction-touch.ts | 4 +- .../session-replay-target-verification.ts | 56 +++++- src/daemon/session-target-evidence.ts | 15 +- src/daemon/types.ts | 17 +- src/replay/target-identity-node.ts | 64 +++++++ 9 files changed, 419 insertions(+), 56 deletions(-) diff --git a/src/commands/interaction/runtime/resolution.ts b/src/commands/interaction/runtime/resolution.ts index 39090c42e..fdad3fe39 100644 --- a/src/commands/interaction/runtime/resolution.ts +++ b/src/commands/interaction/runtime/resolution.ts @@ -33,47 +33,66 @@ import type { } from '../../../contracts/interaction.ts'; import { now, toBackendContext } from '../../runtime-common.ts'; import { resolveActionableTouchResolution } from '../../../core/interaction-targeting.ts'; -import type { LocalIdentity } from '../../../replay/target-identity.ts'; import { localIdentitiesEqual, readNodeLocalIdentity, + readNodeStructuralDenotation, + structuralDenotationsEqual, REPLAY_TARGET_GUARD_MISMATCH_REASON, + type ReplayTargetGuardDenotation, } from '../../../replay/target-identity-node.ts'; export type { InteractionTarget, PointTarget, ResolvedInteractionTarget }; /** - * ADR 0012 migration step 4, post-resolution guard: the normalized local - * identity of the element replay's pre-action verification isolated. Set - * ONLY by the replay step loop (via `DaemonRequest.internal.replayTargetGuard`) - * for annotated verified actions — never on live interactive commands. - * Dispatch's own resolution runs guards verification does not replicate - * (occlusion filtering, visibility-preferring disambiguation), so its winner - * can differ from the verified member; this catches that split BEFORE the - * device action instead of tapping a different element than was verified. + * ADR 0012 migration step 4, post-resolution guard: the LOCAL identity AND + * the STRUCTURAL denotation (pre-order document index + same-parent sibling + * ordinal) of the element replay's pre-action verification isolated. Set ONLY + * by the replay step loop (via `DaemonRequest.internal.replayTargetGuard`) for + * annotated verified actions — never on live interactive commands. + * + * Local identity alone is insufficient: ADR path 6 isolates ONE member among + * several nodes that share the same `{id, role, label}` using sibling / + * region-scoped viewportOrder. If verification isolates duplicate A but + * dispatch's occlusion/visibility filtering selects duplicate B with the same + * local identity, a local-identity-only guard would pass and tap the wrong + * element. The structural denotation is the discriminator that catches that + * split BEFORE the device action. */ -export type ExpectedResolvedTarget = LocalIdentity; +export type ExpectedResolvedTarget = ReplayTargetGuardDenotation; /** * Compares the resolution winner (pre-promotion: hittable-ancestor promotion - * deliberately retargets to the same element's actionable container and must - * not trip the guard) against the verified identity; throws pre-action. + * deliberately retargets to the same LEAF's actionable container and must not + * trip the guard — duplicates are distinct leaves with distinct structural + * denotations, so comparing the leaf is exactly right) against the verified + * member's local identity AND structural denotation; throws pre-action when + * EITHER differs. */ export function assertExpectedResolvedTarget( node: SnapshotNode, + nodes: SnapshotState['nodes'], expected: ExpectedResolvedTarget | undefined, action: string, ): void { if (!expected) return; - const observed = readNodeLocalIdentity(node); - if (localIdentitiesEqual(observed, expected)) return; + const observedIdentity = readNodeLocalIdentity(node); + const observedStructural = readNodeStructuralDenotation(node, nodes); + if ( + localIdentitiesEqual(observedIdentity, expected.identity) && + structuralDenotationsEqual(observedStructural, expected.structural) + ) { + return; + } throw new AppError( 'COMMAND_FAILED', `${action} resolved to a different element than replay verification isolated; the action was not sent`, { reason: REPLAY_TARGET_GUARD_MISMATCH_REASON, - observed, - expected, + observed: observedIdentity, + observedStructural, + expected: expected.identity, + expectedStructural: expected.structural, }, ); } @@ -193,7 +212,12 @@ async function resolveRefInteractionTarget( ): Promise { const capture = await resolveSnapshotForRef(runtime, options, target); const resolved = capture.resolved; - assertExpectedResolvedTarget(resolved.node, params.expectedResolvedTarget, params.action); + assertExpectedResolvedTarget( + resolved.node, + capture.snapshot.nodes, + params.expectedResolvedTarget, + params.action, + ); const node = params.promoteToHittableAncestor ? resolveActionableNodeOrThrow(capture.snapshot.nodes, resolved.node, { action: params.action, @@ -261,7 +285,12 @@ async function resolveSelectorInteractionTarget( { hint: selectorFailureHint(resolved?.diagnostics ?? []) }, ); } - assertExpectedResolvedTarget(resolved.node, params.expectedResolvedTarget, params.action); + assertExpectedResolvedTarget( + resolved.node, + capture.snapshot.nodes, + params.expectedResolvedTarget, + params.action, + ); const node = params.promoteToHittableAncestor ? resolveActionableNodeOrThrow(capture.snapshot.nodes, resolved.node, { action: params.action, diff --git a/src/commands/interaction/runtime/selector-read.ts b/src/commands/interaction/runtime/selector-read.ts index 16cb820ad..a7d6696ee 100644 --- a/src/commands/interaction/runtime/selector-read.ts +++ b/src/commands/interaction/runtime/selector-read.ts @@ -202,7 +202,12 @@ export const getCommand: RuntimeCommand = a invalidRefMessage: 'get text requires a ref like @e2', notFoundMessage: `Ref ${options.target.ref} not found`, }); - assertExpectedResolvedTarget(resolved.node, options.expectedResolvedTarget, 'get'); + assertExpectedResolvedTarget( + resolved.node, + capture.snapshot.nodes, + options.expectedResolvedTarget, + 'get', + ); const selectorChain = buildSelectorChainForNode(resolved.node, runtime.backend.platform, { action: 'get', }); @@ -219,7 +224,12 @@ export const getCommand: RuntimeCommand = a selector: options.target.selector, disambiguateAmbiguous: options.property === 'text', }); - assertExpectedResolvedTarget(resolved.node, options.expectedResolvedTarget, 'get'); + assertExpectedResolvedTarget( + resolved.node, + resolved.capture.snapshot.nodes, + options.expectedResolvedTarget, + 'get', + ); const selectorChain = buildSelectorChainForNode(resolved.node, runtime.backend.platform, { action: 'get', diff --git a/src/daemon/handlers/__tests__/session-replay-target-guard.test.ts b/src/daemon/handlers/__tests__/session-replay-target-guard.test.ts index 7dac6c9cf..93d398c7a 100644 --- a/src/daemon/handlers/__tests__/session-replay-target-guard.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-target-guard.test.ts @@ -5,14 +5,26 @@ // PRE-ACTION in that case instead of tapping a different element. import { test } from 'vitest'; import assert from 'node:assert/strict'; -import type { SnapshotState } from '../../../kernel/snapshot.ts'; +import type { SnapshotNode, SnapshotState } from '../../../kernel/snapshot.ts'; import type { TargetAnnotationV1 } from '../../../replay/target-identity.ts'; -import { readNodeLocalIdentity } from '../../../replay/target-identity-node.ts'; +import { + readNodeLocalIdentity, + readNodeStructuralDenotation, + type ReplayTargetGuardDenotation, +} from '../../../replay/target-identity-node.ts'; import { makeSnapshotState } from '../../../__tests__/test-utils/index.ts'; import { ref as interactionRef, selector } from '../../../commands/index.ts'; import { createInteractionDevice } from '../../../commands/interaction/runtime/__tests__/test-utils/index.ts'; import { classifyReplayTarget } from '../session-replay-target-classification.ts'; +/** The verified-member guard denotation the replay loop mints (identity + structural position). */ +function guardFor(node: SnapshotNode, nodes: SnapshotNode[]): ReplayTargetGuardDenotation { + return { + identity: readNodeLocalIdentity(node), + structural: readNodeStructuralDenotation(node, nodes), + }; +} + function assertVerified( result: ReturnType, expected: { winnerRef: string; matchCount: number }, @@ -111,7 +123,7 @@ test('split resolver: the post-resolution guard refuses pre-action when dispatch await assert.rejects( device.interactions.press(selector('label="Save"'), { session: 'default', - expectedResolvedTarget: readNodeLocalIdentity(nodeA), + expectedResolvedTarget: guardFor(nodeA, snapshot.nodes), }), (error: unknown) => { const appError = error as { @@ -143,7 +155,7 @@ test('split resolver: a matching identity passes the guard and the action procee const result = await device.interactions.press(selector('label="Save"'), { session: 'default', - expectedResolvedTarget: readNodeLocalIdentity(nodeB), + expectedResolvedTarget: guardFor(nodeB, snapshot.nodes), }); assert.equal(result.kind, 'selector'); assert.equal('node' in result ? result.node?.identifier : undefined, 'save-b'); @@ -175,7 +187,7 @@ test('split resolver: the guard forces the ref fast path onto the runtime resolu const result = await device.interactions.click(interactionRef(`@${nodeB.ref}`), { session: 'default', - expectedResolvedTarget: readNodeLocalIdentity(nodeB), + expectedResolvedTarget: guardFor(nodeB, snapshot.nodes), }); // Without the guard, click @ref with a tapTarget backend takes the native // fast path (backend.tapTarget) and the guard would never run; with the @@ -189,3 +201,154 @@ test('split resolver: the guard forces the ref fast path onto the runtime resolu kind: 'exact', }); }); + +// --------------------------------------------------------------------------- +// P1 regression: two SAME-local-identity duplicates ({id,role,label} all +// equal) at different structural positions. Verification denotes A (covered, +// deeper); dispatch's occlusion filter resolves B. A local-identity-only +// guard would pass (identical id/role/label) and tap the WRONG element — the +// exact verified-but-taps-different-node mis-binding step 4 exists to prevent. +// The structural denotation (document order + sibling) must catch the split. +// --------------------------------------------------------------------------- + +/** Two "Save" buttons with IDENTICAL id/role/label; only structural position differs. */ +function sameIdentityDuplicatesSnapshot(): SnapshotState { + return makeSnapshotState([ + { + index: 0, + depth: 0, + type: 'Application', + label: 'Example', + rect: { x: 0, y: 0, width: 390, height: 844 }, + }, + { + // A: covered + deeper → verification's unfiltered disambiguation winner, + // dropped by dispatch's occlusion filter. sibling 0, document order 1. + index: 1, + depth: 3, + parentIndex: 0, + type: 'Button', + identifier: 'save', + label: 'Save', + rect: { x: 16, y: 120, width: 140, height: 44 }, + interactionBlocked: 'covered', + }, + { + // B: visible → dispatch's winner. SAME id/role/label as A; sibling 1, + // document order 2. + index: 2, + depth: 1, + parentIndex: 0, + type: 'Button', + identifier: 'save', + label: 'Save', + rect: { x: 16, y: 790, width: 140, height: 44 }, + hittable: true, + }, + ]); +} + +test('same-identity duplicates: verification denotes the covered member A among two identical id/role/label buttons', () => { + const nodes = sameIdentityDuplicatesSnapshot().nodes; + const nodeA = nodes[1]!; // document order 1, covered + const nodeB = nodes[2]!; // document order 2, visible + // Sanity: A and B are genuinely indistinguishable by local identity. + assert.deepEqual(readNodeLocalIdentity(nodeA), readNodeLocalIdentity(nodeB)); + const recorded: TargetAnnotationV1 = { + id: 'save', + role: 'button', + label: 'Save', + ancestry: [{ role: 'application', label: 'Example' }], + sibling: 0, // isolates A among the two same-identity duplicates + viewportOrder: 0, + verification: 'verified', + }; + const result = classifyReplayTarget({ + recorded, + token: 'label="Save"', + nodes, + platform: 'ios', + refLabel: undefined, + requireRect: true, + allowDisambiguation: true, + }); + // matchCount 2 (both match); path 6 sibling ordinal isolates A, which is + // also the unfiltered disambiguation winner → verified on A. + assertVerified(result, { winnerRef: nodeA.ref, matchCount: 2 }); +}); + +test('same-identity duplicates: the structural guard refuses pre-action when dispatch resolves the OTHER duplicate (P1 regression)', async () => { + const snapshot = sameIdentityDuplicatesSnapshot(); + const nodeA = snapshot.nodes[1]!; + const taps: unknown[] = []; + const tapTargets: unknown[] = []; + const device = createInteractionDevice(snapshot, { + tap: async (_context, point) => { + taps.push(point); + return { ok: true }; + }, + tapTarget: async (_context, target) => { + tapTargets.push(target); + return { ok: true }; + }, + }); + + await assert.rejects( + // Verification isolated A (document order 1); dispatch's occlusion filter + // will resolve B (document order 2) — identical local identity. + device.interactions.press(selector('label="Save"'), { + session: 'default', + expectedResolvedTarget: guardFor(nodeA, snapshot.nodes), + }), + (error: unknown) => { + const appError = error as { + code?: string; + details?: { + reason?: string; + observed?: { id?: string }; + expected?: { id?: string }; + observedStructural?: { documentOrder?: number; sibling?: number }; + expectedStructural?: { documentOrder?: number; sibling?: number }; + }; + }; + assert.equal(appError.code, 'COMMAND_FAILED'); + assert.equal(appError.details?.reason, 'replay_target_guard_mismatch'); + // Local identity is IDENTICAL on both sides — the refusal is driven + // purely by the structural denotation. + assert.equal(appError.details?.observed?.id, 'save'); + assert.equal(appError.details?.expected?.id, 'save'); + assert.equal(appError.details?.expectedStructural?.documentOrder, 1); + assert.equal(appError.details?.observedStructural?.documentOrder, 2); + assert.equal(appError.details?.expectedStructural?.sibling, 0); + assert.equal(appError.details?.observedStructural?.sibling, 1); + return true; + }, + ); + // Zero backend calls: neither the runtime tap nor the native fast path fired. + assert.deepEqual(taps, []); + assert.deepEqual(tapTargets, []); +}); + +test('same-identity duplicates: the guard passes when dispatch resolves the SAME structural member', async () => { + // Remove A's `covered` flag so dispatch also resolves the deeper member A + // (document order 1) that verification denoted — structural match, action proceeds. + const snapshot = sameIdentityDuplicatesSnapshot(); + const nodeA = snapshot.nodes[1]!; + delete nodeA.interactionBlocked; + const taps: unknown[] = []; + const device = createInteractionDevice(snapshot, { + tap: async (_context, point) => { + taps.push(point); + return { ok: true }; + }, + }); + + const result = await device.interactions.press(selector('label="Save"'), { + session: 'default', + expectedResolvedTarget: guardFor(nodeA, snapshot.nodes), + }); + assert.equal(result.kind, 'selector'); + // Dispatch's deepest-first disambiguation also picks A (document order 1). + assert.equal('node' in result ? result.node?.index : undefined, 1); + assert.equal(taps.length, 1); +}); diff --git a/src/daemon/handlers/__tests__/session-replay-target-verification-runtime.test.ts b/src/daemon/handlers/__tests__/session-replay-target-verification-runtime.test.ts index 893a00ca4..61f0f947a 100644 --- a/src/daemon/handlers/__tests__/session-replay-target-verification-runtime.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-target-verification-runtime.test.ts @@ -283,12 +283,12 @@ test('a verified annotated action carries the post-resolution guard on its dispa }); expect(response.ok).toBe(true); - // The verified member's normalized identity rides the request so dispatch's - // own resolution can cross-check its winner pre-action. + // The verified member's normalized identity AND structural denotation ride + // the request so dispatch's own resolution can cross-check its winner + // pre-action — the structural part distinguishes a same-identity duplicate. expect(invoked[0]?.internal?.replayTargetGuard).toEqual({ - id: 'save', - role: 'button', - label: 'Save', + identity: { id: 'save', role: 'button', label: 'Save' }, + structural: { documentOrder: 0, sibling: 0 }, }); }); @@ -384,6 +384,63 @@ test('a dispatch-time guard mismatch converts to an identity-mismatch target-bin expect((resume.planDigest ?? '').length).toBeGreaterThan(0); }); +test('a same-identity guard mismatch surfaces the structural position difference in the divergence', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-target-guard-struct-')); + const { sessionStore, sessionName } = setupSession(root); + const filePath = writeReplayFile(root, [SAVE_ANNOTATION, 'click id="save"']); + + mockDispatchCommand.mockResolvedValue({ + nodes: [ + { + index: 0, + depth: 0, + type: 'Button', + identifier: 'save', + label: 'Save', + rect: { x: 10, y: 10, width: 40, height: 20 }, + }, + ], + truncated: false, + backend: 'xctest', + }); + + const response = await runReplayScriptFile({ + req: baseReq({ positionals: [filePath] }), + sessionName, + logPath: path.join(root, 'daemon.log'), + sessionStore, + // Dispatch refused a DIFFERENT DUPLICATE with IDENTICAL local identity — + // the refusal is driven purely by the structural denotation. + invoke: async () => ({ + ok: false, + error: { + code: 'COMMAND_FAILED', + message: 'click resolved to a different element than replay verification isolated', + details: { + reason: 'replay_target_guard_mismatch', + observed: { id: 'save', role: 'button', label: 'Save' }, + expected: { id: 'save', role: 'button', label: 'Save' }, + observedStructural: { documentOrder: 2, sibling: 1 }, + expectedStructural: { documentOrder: 1, sibling: 0 }, + }, + }, + }), + }); + + expect(response.ok).toBe(false); + if (response.ok) return; + const divergence = response.error.details?.divergence as Record; + expect(divergence.kind).toBe('identity-mismatch'); + const targetBinding = divergence.targetBinding as Record; + // Local identity is identical on both sides; the ONLY mismatch is structural. + expect(targetBinding.observed).toEqual({ id: 'save', role: 'button', label: 'Save' }); + const mismatches = targetBinding.mismatches as string[]; + expect(mismatches.some((entry) => entry.startsWith('position:'))).toBe(true); + expect( + mismatches.some((entry) => entry.includes('doc1/sibling0') && entry.includes('doc2/sibling1')), + ).toBe(true); +}); + // NOTE: the "--update re-verifies a healed action" test was removed here — // ADR 0012 migration step 6 (#1211) retired `--update` as an actor, so replay // never heals/retries a step; there is no healed action to re-verify. diff --git a/src/daemon/handlers/interaction-touch.ts b/src/daemon/handlers/interaction-touch.ts index 26d756966..314547ee8 100644 --- a/src/daemon/handlers/interaction-touch.ts +++ b/src/daemon/handlers/interaction-touch.ts @@ -17,7 +17,7 @@ import type { PressCommandResult, } from '../../contracts/interaction.ts'; import { asAppError, normalizeError } from '../../kernel/errors.ts'; -import type { LocalIdentity } from '../../replay/target-identity.ts'; +import type { ReplayTargetGuardDenotation } from '../../replay/target-identity-node.ts'; import type { DaemonResponse, SessionState } from '../types.ts'; import { finalizeTouchInteraction, type InteractionHandlerParams } from './interaction-common.ts'; import { markSessionSnapshotRefsIssued, resolveRefStalenessWarning } from '../session-snapshot.ts'; @@ -230,7 +230,7 @@ async function runTargetedTouchInteraction(params: { clickButton: ReturnType; flags: CommandFlags | undefined; durationMs?: number; - expectedResolvedTarget?: LocalIdentity; + expectedResolvedTarget?: ReplayTargetGuardDenotation; }): Promise { const { runtime, command, target, sessionName, requestId, flags, expectedResolvedTarget } = params; diff --git a/src/daemon/handlers/session-replay-target-verification.ts b/src/daemon/handlers/session-replay-target-verification.ts index 7fe15ecf4..589696f33 100644 --- a/src/daemon/handlers/session-replay-target-verification.ts +++ b/src/daemon/handlers/session-replay-target-verification.ts @@ -15,7 +15,11 @@ import { type ReplayDivergenceTargetCandidate, type ReplayDivergenceTargetIdentity, } from '../../replay/divergence.ts'; -import { REPLAY_TARGET_GUARD_MISMATCH_REASON } from '../../replay/target-identity-node.ts'; +import { + readNodeStructuralDenotation, + REPLAY_TARGET_GUARD_MISMATCH_REASON, + type ReplayTargetGuardDenotation, +} from '../../replay/target-identity-node.ts'; import type { DaemonResponse, SessionAction } from '../types.ts'; import { SessionStore } from '../session-store.ts'; import { boundedLocalIdentity } from '../session-target-evidence.ts'; @@ -47,7 +51,10 @@ import { extractReplayTargetToken, readRefLabel } from './session-replay-target- * the resulting identity-mismatch divergence satisfies decision 3's * matchCount presence rule. */ -export type ReplayVerifiedTargetGuard = { expected: LocalIdentity; matchCount: number }; +export type ReplayVerifiedTargetGuard = { + expected: ReplayTargetGuardDenotation; + matchCount: number; +}; export type ReplayTargetVerificationOutcome = | { verified: true; guard?: ReplayVerifiedTargetGuard } @@ -290,7 +297,13 @@ export async function verifyReplayActionTarget(params: { return { verified: true, guard: { - expected: boundedLocalIdentity(classification.winnerNode), + // Carry BOTH the verified member's local identity AND its structural + // denotation (document order + sibling), so dispatch's guard refuses a + // different duplicate that shares the same {id, role, label}. + expected: { + identity: boundedLocalIdentity(classification.winnerNode), + structural: readNodeStructuralDenotation(classification.winnerNode, observation.nodes), + }, matchCount: classification.matchCount, }, }; @@ -369,9 +382,15 @@ export async function buildReplayTargetGuardMismatchResponse(params: { const scrubVars = collectReplayScrubbableVarValues(scope); const sanitize = createReplayDivergenceSanitizer(scrubVars); - const observed = failedResponse.ok - ? undefined - : readGuardMismatchObservedIdentity(failedResponse.error.details?.observed); + const details = failedResponse.ok ? undefined : failedResponse.error.details; + const observed = readGuardMismatchObservedIdentity(details?.observed); + // The guard fires even when local identity is identical (a same-identity + // duplicate resolved by structural position) — surface the structural + // difference so `mismatches` is never empty on a real divergence. + const structuralMismatch = describeStructuralMismatch( + details?.expectedStructural, + details?.observedStructural, + ); const session = sessionStore.get(sessionName); const observation = session @@ -403,7 +422,10 @@ export async function buildReplayTargetGuardMismatchResponse(params: { matchCount: guard.matchCount, observed, candidateNodes: [], - mismatches: observed ? identityFieldMismatches(recorded, observed) : [], + mismatches: [ + ...(observed ? identityFieldMismatches(recorded, observed) : []), + ...(structuralMismatch ? [structuralMismatch] : []), + ], causeCode: 'IDENTITY_MISMATCH', causeMessage: 'Dispatch resolution (with occlusion/visibility guards) resolved a different element than pre-action verification isolated; the action was not sent.', @@ -423,6 +445,26 @@ function readGuardMismatchObservedIdentity(value: unknown): LocalIdentity | unde }; } +/** A `position:` mismatch line from the guard's structural denotations, when both are present and differ. */ +function describeStructuralMismatch(expected: unknown, observed: unknown): string | undefined { + const e = readStructuralDenotation(expected); + const o = readStructuralDenotation(observed); + if (!e || !o) return undefined; + if (e.documentOrder === o.documentOrder && e.sibling === o.sibling) return undefined; + return `position: recorded=doc${e.documentOrder}/sibling${e.sibling} observed=doc${o.documentOrder}/sibling${o.sibling}`; +} + +function readStructuralDenotation( + value: unknown, +): { documentOrder: number; sibling: number } | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; + const record = value as Record; + if (typeof record.documentOrder !== 'number' || typeof record.sibling !== 'number') { + return undefined; + } + return { documentOrder: record.documentOrder, sibling: record.sibling }; +} + function identityFromAnnotation(recorded: TargetAnnotationV1): ReplayDivergenceTargetIdentity { return { ...(recorded.id !== undefined ? { id: recorded.id } : {}), diff --git a/src/daemon/session-target-evidence.ts b/src/daemon/session-target-evidence.ts index f5be953f8..ebcc771e0 100644 --- a/src/daemon/session-target-evidence.ts +++ b/src/daemon/session-target-evidence.ts @@ -17,7 +17,7 @@ import type { SnapshotNode } from '../kernel/snapshot.ts'; import { resolveRectCenter } from '../utils/rect-center.ts'; import { findNearestScrollableContainer } from './snapshot-presentation/tree.ts'; -import { readNodeLocalIdentity } from '../replay/target-identity-node.ts'; +import { readNodeLocalIdentity, siblingOrdinal } from '../replay/target-identity-node.ts'; import { classifyTargetBindingMatch, matchesAncestryPrefix, @@ -160,17 +160,12 @@ export function buildAncestryChain( /** * Decision 3 record-time write step 3: the winner's zero-based index among * its OWN parent's children, in document order. Root-level nodes (no parent) - * are siblings of every other root-level node. + * are siblings of every other root-level node. Delegates to the shared + * `siblingOrdinal` so the record-time writer, the classifier, and the + * dispatch-side guard all compute this ordinal with one implementation. */ export function computeSiblingOrdinal(nodes: readonly SnapshotNode[], node: SnapshotNode): number { - const parentIndex = node.parentIndex; - let ordinal = 0; - for (const candidate of nodes) { - if (candidate.parentIndex !== parentIndex) continue; - if (candidate.index === node.index) return ordinal; - ordinal += 1; - } - return 0; + return siblingOrdinal(nodes, node); } /** Decision 3 record-time write step 4: nearest scrollable ancestor's local identity, or `undefined` for *none*. */ diff --git a/src/daemon/types.ts b/src/daemon/types.ts index a30534819..f7b23974a 100644 --- a/src/daemon/types.ts +++ b/src/daemon/types.ts @@ -18,7 +18,8 @@ import type { RecordingScope } from '../contracts/recording-scope.ts'; import type { DeviceInfo, Platform, PlatformSelector } from '../kernel/device.ts'; import type { ExecBackgroundResult, ExecResult } from '../utils/exec.ts'; import type { SnapshotState } from '../kernel/snapshot.ts'; -import type { LocalIdentity, TargetAnnotationV1 } from '../replay/target-identity.ts'; +import type { TargetAnnotationV1 } from '../replay/target-identity.ts'; +import type { ReplayTargetGuardDenotation } from '../replay/target-identity-node.ts'; import type { AppLogFailure, AppLogState } from './app-log-process.ts'; import type { DeviceLease } from './lease-registry.ts'; import type { AndroidNativePerfSession } from '../platforms/android/perf.ts'; @@ -58,13 +59,15 @@ type DaemonRequestInternal = { admittedLease?: DeviceLease; /** * ADR 0012 step 4 post-resolution guard: the verified target member's - * normalized local identity, set ONLY by the replay step loop when - * dispatching an annotated action whose pre-action verification passed. - * Interaction handlers thread it into command options as - * `expectedResolvedTarget`; dispatch's own resolution refuses (pre-action) - * when its winner carries a different identity. + * normalized local identity AND structural denotation (document order + + * sibling ordinal), set ONLY by the replay step loop when dispatching an + * annotated action whose pre-action verification passed. Interaction + * handlers thread it into command options as `expectedResolvedTarget`; + * dispatch's own resolution refuses (pre-action) when its winner differs in + * local identity OR structural position — the latter distinguishes a + * different same-identity duplicate. */ - replayTargetGuard?: LocalIdentity; + replayTargetGuard?: ReplayTargetGuardDenotation; }; export type DaemonRequest = Omit & { diff --git a/src/replay/target-identity-node.ts b/src/replay/target-identity-node.ts index afa99e82a..148b16655 100644 --- a/src/replay/target-identity-node.ts +++ b/src/replay/target-identity-node.ts @@ -41,6 +41,70 @@ export function localIdentitiesEqual(a: LocalIdentity, b: LocalIdentity): boolea return a.id === b.id && a.role === b.role && a.label === b.label; } +/** + * ADR 0012 decision 3: the STRUCTURAL denotation of a node within its capture + * — the discriminators path 6 uses to isolate ONE member among several nodes + * that share the same local identity. + * + * `documentOrder` is the node's pre-order tree-traversal index (decision 3's + * "canonical total order of this contract"), which distinguishes ANY two + * distinct nodes. `sibling` is its zero-based ordinal among its own parent's + * children (decision 3 path 6.i). Both captures (the verifier's and + * dispatch's) run the same snapshot pipeline, so for the same physical node + * in the same screen these values agree; two distinct duplicates never share + * a `documentOrder`. + */ +export type NodeStructuralDenotation = { + documentOrder: number; + sibling: number; +}; + +type StructuralNode = Pick; + +/** Zero-based ordinal among the node's own parent's children, in document (array) order. */ +export function siblingOrdinal(nodes: readonly StructuralNode[], node: StructuralNode): number { + let ordinal = 0; + for (const candidate of nodes) { + if (candidate.parentIndex !== node.parentIndex) continue; + if (candidate.index === node.index) return ordinal; + ordinal += 1; + } + return 0; +} + +export function readNodeStructuralDenotation( + node: StructuralNode, + nodes: readonly StructuralNode[], +): NodeStructuralDenotation { + return { documentOrder: node.index, sibling: siblingOrdinal(nodes, node) }; +} + +/** + * The guard passes only when BOTH structural discriminators agree — a + * fail-closed comparison: two same-local-identity duplicates differ in + * `documentOrder` (always) and usually `sibling`, so the guard refuses + * whenever dispatch resolved a different member than verification isolated. + */ +export function structuralDenotationsEqual( + a: NodeStructuralDenotation, + b: NodeStructuralDenotation, +): boolean { + return a.documentOrder === b.documentOrder && a.sibling === b.sibling; +} + +/** + * The verified-member denotation carried on the pre-action guard + * (`DaemonRequest.internal.replayTargetGuard`): its normalized local identity + * PLUS the structural discriminator path 6 used to isolate it among same + * local-identity duplicates. Comparing local identity alone would let a + * different duplicate (identical id/role/label) pass the guard — the exact + * verified-but-taps-different-node mis-binding step 4 exists to prevent. + */ +export type ReplayTargetGuardDenotation = { + identity: LocalIdentity; + structural: NodeStructuralDenotation; +}; + /** * `details.reason` marker on the pre-action refusal thrown by dispatch's * post-resolution guard (`assertExpectedResolvedTarget`,