diff --git a/scripts/__tests__/eager-closure-budgets.ts b/scripts/__tests__/eager-closure-budgets.ts index 528d936b92..118558ace2 100644 --- a/scripts/__tests__/eager-closure-budgets.ts +++ b/scripts/__tests__/eager-closure-budgets.ts @@ -95,13 +95,14 @@ export function discoverFacadeEntryFiles(repoRoot: string): string[] { } /** - * Designated hubs: entry points whose closure the whole suite or every CLI run pays for. - * `src/platform-runtime.ts` is the ADR-0019 composition root, the one production module allowed - * to value-import a concrete platform package; its no-growth rule is also the assertion that - * composing the registry stays metadata-eager. + * Designated hubs: entry points whose closure the whole suite, every CLI run, or every daemon start + * pays for. `src/platform-runtime.ts` is the ADR-0019 composition root, the one production module + * allowed to value-import a concrete platform package; its no-growth rule is also the assertion + * that composing the registry stays metadata-eager. */ export const HUB_ENTRY_FILES: readonly string[] = [ 'src/cli.ts', + 'src/daemon.ts', 'src/platform-runtime.ts', 'src/core/interactors/register-builtins.ts', 'src/daemon/session-teardown.ts', diff --git a/src/daemon/__tests__/replay-device-selection.test.ts b/src/daemon/__tests__/replay-device-selection.test.ts index 0f499d5eff..cad572140a 100644 --- a/src/daemon/__tests__/replay-device-selection.test.ts +++ b/src/daemon/__tests__/replay-device-selection.test.ts @@ -2,18 +2,25 @@ import { test, expect } from 'vitest'; import fs from 'node:fs'; import path from 'node:path'; import { parseReplayInput } from '@agent-device/ad-script'; +import type { ReplayScriptSourceBundle } from '@agent-device/contracts/replay'; import { buildReplayTargetDeviceResolution } from '../replay-device-selection.ts'; -import { buildReplayScriptPlatformFlags } from '@agent-device/replay-port/replay-script-selection'; +import { + appTargetResolutionOptions, + buildReplayScriptPlatformFlags, +} from '@agent-device/replay-port/replay-script-selection'; import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; -import { replayScriptSourceBundleFor } from '../../__tests__/test-utils/replay-script-source.ts'; +import { + maestroScriptSourceBundleFor, + replayScriptSourceBundleFor, +} from '../../__tests__/test-utils/replay-script-source.ts'; -test('replay leaves deep-link opens to normal device resolution', () => { +test('replay leaves deep-link opens to normal device resolution', async () => { const root = mkdtempForTestSync('agent-device-replay-device-selection-'); const replayPath = path.join(root, 'deep-link.ad'); fs.writeFileSync(replayPath, 'open demo://checkout\n'); expect( - buildReplayTargetDeviceResolution({ + await buildReplayTargetDeviceResolution({ token: 'test-token', session: 'default', command: 'replay', @@ -36,7 +43,7 @@ test('native replay applies its authored platform before a first deep link', () ).toEqual({ platform: 'ios' }); }); -test('native replay uses its authored Android runtime setting without an iOS app probe', () => { +test('native replay uses its authored Android runtime setting without an iOS app probe', async () => { const root = mkdtempForTestSync('agent-device-replay-device-selection-'); const replayPath = path.join(root, 'android.ad'); fs.writeFileSync( @@ -44,7 +51,7 @@ test('native replay uses its authored Android runtime setting without an iOS app 'runtime set --platform android --metro-port 8081\nopen com.example.demo\n', ); - const resolution = buildReplayTargetDeviceResolution({ + const resolution = await buildReplayTargetDeviceResolution({ token: 'test-token', session: 'default', command: 'replay', @@ -58,3 +65,55 @@ test('native replay uses its authored Android runtime setting without an iOS app expect(resolution?.flags.platform).toBe('android'); expect(resolution?.options).toBeUndefined(); }); + +test('a replay whose wire bundle has a malformed entry stays advisory', async () => { + // The HTTP boundary validates `flags` only as an object, so a remote caller + // can deliver a bundle without a string `entry`. #1802 keeps lock binding + // advisory: the probe must fall back to normal device resolution, and the + // replay handler stays the one to reject the request. + const malformedBundles = [ + // A missing entry fails the bundle read itself. + { entry: undefined, files: {} }, + // A non-string entry survives the read — `files` is keyed by the coerced + // string — and reaches format resolution, where `path.extname` rejects it. + { entry: 42, files: { '42': 'open demo://checkout\n' } }, + ] as unknown as ReplayScriptSourceBundle[]; + + for (const bundle of malformedBundles) { + await expect( + buildReplayTargetDeviceResolution({ + token: 'test-token', + session: 'default', + command: 'replay', + positionals: [], + flags: { + replayBackend: 'maestro', + replayScriptSource: bundle, + }, + meta: { cwd: mkdtempForTestSync('agent-device-replay-device-selection-') }, + }), + ).resolves.toBeUndefined(); + } +}); + +test('a Maestro flow pre-binds an iOS replay to its static appId', async () => { + const root = mkdtempForTestSync('agent-device-replay-device-selection-'); + const flowPath = path.join(root, 'checkout.yaml'); + fs.writeFileSync(flowPath, 'appId: com.example.demo\n---\n- launchApp\n'); + + const resolution = await buildReplayTargetDeviceResolution({ + token: 'test-token', + session: 'default', + command: 'replay', + positionals: [flowPath], + flags: { + platform: 'ios', + replayBackend: 'maestro', + replayScriptSource: await maestroScriptSourceBundleFor(flowPath), + }, + meta: { cwd: root }, + }); + + expect(resolution?.options).toEqual(appTargetResolutionOptions('com.example.demo')); + expect(resolution?.options).toBeDefined(); +}); diff --git a/src/daemon/replay-device-selection.ts b/src/daemon/replay-device-selection.ts index 4eafe33aa2..1c11e516eb 100644 --- a/src/daemon/replay-device-selection.ts +++ b/src/daemon/replay-device-selection.ts @@ -1,4 +1,3 @@ -import { inspectMaestroFlow } from '@agent-device/maestro'; import { parseReplayInput, resolveReplayFormat } from '@agent-device/ad-script'; import type { ResolveTargetDeviceOptions } from '@agent-device/device-selection/dispatch-resolve'; import type { CommandFlags } from '@agent-device/contracts/command'; @@ -24,40 +23,66 @@ export type ReplayTargetDeviceResolution = { * filesystem. Before that this opened the caller's path here too, so against a * remote daemon it silently no-opped (the read threw and the catch below * swallowed it) and every remote replay lost its pre-binding. + * + * The Maestro engine loads only once the entry resolves to a flow: request + * binding is in the daemon's startup closure, and most replays are `.ad`. */ -export function buildReplayTargetDeviceResolution( +export async function buildReplayTargetDeviceResolution( req: DaemonRequest, -): ReplayTargetDeviceResolution | undefined { +): Promise { if (req.command !== 'replay' || req.flags?.replayFrom !== undefined) return undefined; const bundle = req.flags?.replayScriptSource; if (!bundle) return undefined; - try { + return readAdvisoryResolution(async () => { const resolved = bundle.entry; const source = readReplayScriptSourceFile(bundle, resolved); if (resolveReplayFormat(resolved, req.flags?.replayBackend) === 'maestro') { - const flow = inspectMaestroFlow(source, resolved); - return { - flags: req.flags ?? {}, - options: buildMaestroReplayTargetDeviceResolutionOptions( - flow.appTarget, - req.flags?.platform, - ), - }; + return await readMaestroReplayResolution(source, resolved, req.flags); } - const parsed = parseReplayInput(source, req.flags); - const selection = readScriptReplaySelection(parsed.actions); - if (!selection.appTarget) return undefined; - const scriptFlags = buildReplayScriptPlatformFlags(req.flags, parsed.actions); - const platform = scriptFlags.platform ?? parsed.metadata.platform; - return { - flags: - platform && scriptFlags.platform === undefined ? { ...scriptFlags, platform } : scriptFlags, - options: platform === 'ios' ? appTargetResolutionOptions(selection.appTarget) : undefined, - }; + return readAdScriptResolution(source, req.flags); + }); +} + +async function readMaestroReplayResolution( + source: string, + resolvedPath: string, + flags: DaemonRequest['flags'], +): Promise { + const { inspectMaestroFlow } = await import('@agent-device/maestro'); + const flow = inspectMaestroFlow(source, resolvedPath); + return { + flags: flags ?? {}, + options: buildMaestroReplayTargetDeviceResolutionOptions(flow.appTarget, flags?.platform), + }; +} + +function readAdScriptResolution( + source: string, + flags: DaemonRequest['flags'], +): ReplayTargetDeviceResolution | undefined { + const parsed = parseReplayInput(source, flags); + const selection = readScriptReplaySelection(parsed.actions); + if (!selection.appTarget) return undefined; + const scriptFlags = buildReplayScriptPlatformFlags(flags, parsed.actions); + const platform = scriptFlags.platform ?? parsed.metadata.platform; + return { + flags: + platform && scriptFlags.platform === undefined ? { ...scriptFlags, platform } : scriptFlags, + options: platform === 'ios' ? appTargetResolutionOptions(selection.appTarget) : undefined, + }; +} + +async function readAdvisoryResolution( + read: () => Promise, +): Promise { + try { + return await read(); } catch { // Parsing and validation stay in the replay handler. Lock binding is only - // advisory, so an unreadable/invalid plan must not mask its real error. + // advisory, so an unreadable/invalid plan must not mask its real error. The + // whole probe sits behind this guard, including the wire bundle's entry and + // format, which the request boundary only checks as an object. return undefined; } } diff --git a/src/daemon/request-binding.ts b/src/daemon/request-binding.ts index 1c030b53c1..d536376a36 100644 --- a/src/daemon/request-binding.ts +++ b/src/daemon/request-binding.ts @@ -84,14 +84,15 @@ function resolveFreshSessionBindingRequest(req: DaemonRequest): DaemonRequest { } } -function resolveFreshSessionDeviceLock(req: DaemonRequest): +async function resolveFreshSessionDeviceLock(req: DaemonRequest): Promise< | { flags: NonNullable; options: ReturnType | undefined; } - | undefined { + | undefined +> { if (req.command === 'open') return resolveOpenDeviceLock(req); - if (req.command === 'replay') return resolveReplayDeviceLock(req); + if (req.command === 'replay') return await resolveReplayDeviceLock(req); return resolveExplicitDeviceLock(req); } @@ -101,7 +102,7 @@ function resolveFreshSessionDeviceLock(req: DaemonRequest): * happens after: the locked request resolves and binds its device on its own. */ async function resolveFreshSessionDevice(req: DaemonRequest): Promise { - const resolution = resolveFreshSessionDeviceLock(resolveFreshSessionBindingRequest(req)); + const resolution = await resolveFreshSessionDeviceLock(resolveFreshSessionBindingRequest(req)); if (!resolution) return undefined; try { return await resolveTargetDevice(resolution.flags, resolution.options); @@ -116,8 +117,8 @@ function resolveOpenDeviceLock(req: DaemonRequest) { return options ? { flags: req.flags ?? {}, options } : undefined; } -function resolveReplayDeviceLock(req: DaemonRequest) { - return buildReplayTargetDeviceResolution(req) ?? resolveExplicitDeviceLock(req); +async function resolveReplayDeviceLock(req: DaemonRequest) { + return (await buildReplayTargetDeviceResolution(req)) ?? resolveExplicitDeviceLock(req); } function resolveExplicitDeviceLock(req: DaemonRequest) {