diff --git a/packages/contracts/src/client-settings.test.ts b/packages/contracts/src/client-settings.test.ts index db5c40e456..c40a9fcca5 100644 --- a/packages/contracts/src/client-settings.test.ts +++ b/packages/contracts/src/client-settings.test.ts @@ -4,6 +4,7 @@ import type { SettingsUpdateOptions } from './client-settings.ts'; type Permission = Extract; const MOBILE_TARGETS = [ + 'all', 'camera', 'microphone', 'photos', @@ -23,6 +24,8 @@ const MACOS_ONLY_TARGETS = ['accessibility', 'screen-recording', 'input-monitori // Fixed expected data (#2614): the public client vocabulary is written out here so a shared // declaration can neither widen the accepted permission names nor drop the macOS-only ones. +// The one deliberate widening is `all`: the Maestro setPermissions merge needs it to travel +// as one `settings permission` call while each backend resolves it. describe('public client permission vocabulary', () => { test('names exactly the app-scoped targets plus the macOS-only ones', () => { expectTypeOf().toEqualTypeOf< @@ -31,7 +34,6 @@ describe('public client permission vocabulary', () => { }); test('does not name a permission the vocabulary does not declare', () => { - expectTypeOf<'all'>().not.toMatchTypeOf(); expectTypeOf<'bluetooth'>().not.toMatchTypeOf(); }); diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 686948c3f6..3131b3e1fc 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -18,7 +18,10 @@ import { // Fixed expected data on purpose (#2614): this file is the witness that a shared permission // declaration neither widened nor narrowed what any settings surface already accepted, and that it // kept the accepted names in the order `settings` help has always listed them. +// The one deliberate widening is `all`, first in the list: the Maestro setPermissions merge +// needs it to travel as one `settings permission` call while each backend resolves it. const MOBILE_TARGETS = [ + 'all', 'camera', 'microphone', 'photos', @@ -61,7 +64,6 @@ const NORMALIZATIONS = [ const REJECTED_TARGETS = [ ...MACOS_ONLY_TARGETS, - 'all', 'bluetooth', 'camera-x', 'camera limited', @@ -172,6 +174,5 @@ describe('permission vocabulary types', () => { expectTypeOf<'accessibility'>().not.toMatchTypeOf(); expectTypeOf<'screen-recording'>().not.toMatchTypeOf(); expectTypeOf<'input-monitoring'>().not.toMatchTypeOf(); - expectTypeOf<'all'>().not.toMatchTypeOf(); }); }); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index f8347822d5..32f523319a 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -14,6 +14,7 @@ export const PERMISSION_MODES = ['full', 'limited'] as const; /** The app-scoped targets, the only ones `parsePermissionTarget` accepts. */ export const MOBILE_PERMISSION_TARGETS = [ + 'all', 'camera', 'microphone', 'photos', diff --git a/packages/maestro/src/index.ts b/packages/maestro/src/index.ts index d0acefb8bc..e0f5e7ca3a 100644 --- a/packages/maestro/src/index.ts +++ b/packages/maestro/src/index.ts @@ -36,6 +36,8 @@ export { MAESTRO_COMPAT_SUPPORTED_CAPABILITIES, } from './internal/facade-support.ts'; +export { MAESTRO_PERMISSION_VALUES } from './internal/program-ir-values.ts'; + export { createMaestroRuntimePort, literalFromMaestroRegex, diff --git a/packages/maestro/src/internal/__tests__/program-ir-parser.test.ts b/packages/maestro/src/internal/__tests__/program-ir-parser.test.ts index c54025b290..52dd72b9bb 100644 --- a/packages/maestro/src/internal/__tests__/program-ir-parser.test.ts +++ b/packages/maestro/src/internal/__tests__/program-ir-parser.test.ts @@ -409,6 +409,110 @@ describe('parseMaestroProgram', () => { }); }); + test('parses setPermissions maps, variables, and optional/label', () => { + const program = parseMaestroProgram(`appId: example.app +--- +- setPermissions: + permissions: + all: deny + notifications: unset +- setPermissions: + appId: child.app + permissions: + camera: \${CAMERA_STATE} + location: always + optional: true + label: Prepare scan +`); + + assert.deepEqual(program.commands[0], { + kind: 'setPermissions', + source: { line: 3 }, + permissions: { all: 'deny', notifications: 'unset' }, + }); + assert.deepEqual(program.commands[1], { + kind: 'setPermissions', + source: { line: 7 }, + appId: 'child.app', + permissions: { camera: '${CAMERA_STATE}', location: 'always' }, + optional: true, + label: 'Prepare scan', + }); + // Prototype names are not duplicates: the YAML layer already rejects real + // duplicate keys, so parsing accepts them and the backend verdict applies. + const prototype = parseMaestroProgram(`--- +- setPermissions: + permissions: + constructor: allow +`); + assert.deepEqual(prototype.commands[0], { + kind: 'setPermissions', + source: { line: 2 }, + permissions: { constructor: 'allow' }, + }); + assert.throws( + () => + parseMaestroProgram(`--- +- setPermissions: + appId: example.app +`), + /requires permissions.*line 2/i, + ); + assert.throws( + () => + parseMaestroProgram(`--- +- setPermissions: + permissions: + camera: sometimes +`), + /allow\|deny\|unset.*line 4/i, + ); + assert.throws( + () => + parseMaestroProgram(`--- +- setPermissions: + permissions: + camera: \${ALLOW + 1} +`), + /not supported.*line 4/i, + ); + }); + + test('parses launchApp permissions maps', () => { + const program = parseMaestroProgram(`appId: example.app +--- +- launchApp: + clearState: true + permissions: + all: deny + camera: \${CAMERA_STATE} +`); + + assert.deepEqual(program.commands[0], { + kind: 'launchApp', + source: { line: 3 }, + clearState: true, + permissions: { all: 'deny', camera: '${CAMERA_STATE}' }, + }); + assert.throws( + () => + parseMaestroProgram(`--- +- launchApp: + permissions: {} +`), + /launchApp\.permissions requires at least one permission.*line 3/i, + ); + assert.throws( + () => + parseMaestroProgram(`--- +- launchApp: + permissions: + camera: sometimes +`), + /allow\|deny\|unset.*line 4/i, + ); + }); + test('parses evalScript as a scalar script string', () => { const program = parseMaestroProgram(['---', '- evalScript: ${output.sum = 1 + 2}'].join('\n')); assert.deepEqual(program.commands[0], { diff --git a/packages/maestro/src/internal/__tests__/runtime-port-fixtures.ts b/packages/maestro/src/internal/__tests__/runtime-port-fixtures.ts index 7c89d6f1be..765892c9b2 100644 --- a/packages/maestro/src/internal/__tests__/runtime-port-fixtures.ts +++ b/packages/maestro/src/internal/__tests__/runtime-port-fixtures.ts @@ -59,6 +59,7 @@ export function makeOperations( resolveGestureViewport: async () => ({ x: 0, y: 0, width: 402, height: 874 }), launchApp: noOp, stopApp: noOp, + setPermissions: noOp, clearState: noOp, openLink: noOp, tapOn: noOp, diff --git a/packages/maestro/src/internal/__tests__/runtime-port.test.ts b/packages/maestro/src/internal/__tests__/runtime-port.test.ts index d13d6b81c8..9da89973c2 100644 --- a/packages/maestro/src/internal/__tests__/runtime-port.test.ts +++ b/packages/maestro/src/internal/__tests__/runtime-port.test.ts @@ -10,6 +10,40 @@ import { } from './runtime-port-fixtures.ts'; describe('MaestroRuntimePort', () => { + test('dispatches setPermissions with the flow appId and resolved values', async () => { + const calls: RecordedCall[] = []; + const operations = makeOperations({ + setPermissions: vi.fn(async (input, context) => + record(calls, 'setPermissions', input, context), + ), + }); + const program = parseMaestroProgram( + [ + 'appId: com.example.checkout', + 'env:', + ' CAMERA_STATE: allow', + '---', + '- setPermissions:', + ' permissions:', + ' all: deny', + ' camera: ${CAMERA_STATE}', + ].join('\n'), + ); + + const result = await executeMaestroProgram(program, createMaestroRuntimePort(operations)); + + expect(result).toMatchObject({ executed: 1, skipped: 0 }); + expect(calls).toHaveLength(1); + expect(calls[0]).toMatchObject({ + kind: 'setPermissions', + input: { + appId: 'com.example.checkout', + permissions: { all: 'deny', camera: 'allow' }, + }, + appId: 'com.example.checkout', + }); + }); + test('delegates typed lifecycle, input, keyboard, screenshot, and script operations', async () => { const calls: RecordedCall[] = []; const operations = makeOperations({ diff --git a/packages/maestro/src/internal/conformance-normalize.ts b/packages/maestro/src/internal/conformance-normalize.ts index 92801b1048..c91b99c97c 100644 --- a/packages/maestro/src/internal/conformance-normalize.ts +++ b/packages/maestro/src/internal/conformance-normalize.ts @@ -37,7 +37,13 @@ export type CanonicalGesture = | { mode: 'element'; from: CanonicalSelector; direction?: string; duration?: number | string }; export type CanonicalCommand = - | { kind: 'launchApp'; appId?: string; clearState?: boolean; stopApp?: boolean } + | { + kind: 'launchApp'; + appId?: string; + clearState?: boolean; + stopApp?: boolean; + permissions?: Record; + } // Upstream models `doubleTapOn` as a tap with repeat.repeat == 2, so the repeat // COUNT is the canonical field on both sides rather than a `double` variant on // one — that keeps our distinct tapOn/doubleTapOn kinds comparable to upstream @@ -77,6 +83,7 @@ export type CanonicalCommand = | { kind: 'takeScreenshot' } | { kind: 'waitForAnimationToEnd'; timeout?: number | string } | { kind: 'stopApp' } + | { kind: 'setPermissions'; appId?: string; permissions?: Record } | { kind: 'clearState'; appId?: string } | { kind: 'repeat'; times: string | number } | { kind: 'retry'; maxRetries?: string | number } @@ -105,6 +112,7 @@ function canonicalizeUpstreamLifecycleCommand( appId: str(f.appId), clearState: bool(f.clearState), stopApp: bool(f.stopApp), + permissions: permissionsRecord(f.permissions), }); case 'StopAppCommand': return { kind: 'stopApp' }; @@ -121,9 +129,21 @@ export function canonicalizeUpstreamFlow(commands: UpstreamCommand[]): Canonical .map(canonicalizeUpstreamCommand); } +/** Upstream commands that canonicalize to a bare kind with no fields. */ +const BARE_UPSTREAM_CANONICAL: Record = { + ScrollCommand: { kind: 'scroll' }, + BackPressCommand: { kind: 'back' }, + HideKeyboardCommand: { kind: 'hideKeyboard' }, + TakeScreenshotCommand: { kind: 'takeScreenshot' }, + StopAppCommand: { kind: 'stopApp' }, + RunScriptCommand: { kind: 'runScript' }, +}; + function canonicalizeUpstreamCommand(command: UpstreamCommand): CanonicalCommand { const lifecycle = canonicalizeUpstreamLifecycleCommand(command); if (lifecycle) return lifecycle; + const bare = BARE_UPSTREAM_CANONICAL[command.type]; + if (bare) return bare; const f = command.fields; switch (command.type) { case 'TapOnElementCommand': { @@ -182,8 +202,6 @@ function canonicalizeUpstreamCommand(command: UpstreamCommand): CanonicalCommand } case 'SwipeCommand': return dropUndefined({ kind: 'swipe', label: str(f.label), gesture: upstreamGesture(f) }); - case 'ScrollCommand': - return { kind: 'scroll' }; case 'ScrollUntilVisibleCommand': return dropUndefined({ kind: 'scrollUntilVisible', @@ -203,17 +221,17 @@ function canonicalizeUpstreamCommand(command: UpstreamCommand): CanonicalCommand return dropUndefined({ kind: 'openLink', link: str(f.link) }); case 'PressKeyCommand': return { kind: 'pressKey', key: lower(str(f.code)) ?? '' }; - case 'BackPressCommand': - return { kind: 'back' }; - case 'HideKeyboardCommand': - return { kind: 'hideKeyboard' }; - case 'TakeScreenshotCommand': - return { kind: 'takeScreenshot' }; case 'WaitForAnimationToEndCommand': return dropUndefined({ kind: 'waitForAnimationToEnd', timeout: numLike(f.timeout) ?? str(f.timeout), }); + case 'SetPermissionsCommand': + return dropUndefined({ + kind: 'setPermissions', + appId: str(f.appId), + permissions: permissionsRecord(f.permissions), + }); case 'RepeatCommand': return { kind: 'repeat', times: numLike(f.times) ?? str(f.times) ?? '' }; case 'RetryCommand': @@ -227,8 +245,6 @@ function canonicalizeUpstreamCommand(command: UpstreamCommand): CanonicalCommand label: str(f.label), source: f.sourceDescription != null ? 'file' : 'commands', }); - case 'RunScriptCommand': - return { kind: 'runScript' }; case 'EvalScriptCommand': return { kind: 'evalScript' }; default: @@ -304,6 +320,18 @@ function lower(value: string | undefined): string | undefined { return value?.toLowerCase(); } +function permissionsRecord(value: unknown): Record | undefined { + const record = asRecord(value); + if (!record) return undefined; + const permissions: Record = {}; + for (const [key, entry] of Object.entries(record)) { + const coerced = str(entry)?.toLowerCase(); + if (coerced === undefined) return undefined; + permissions[key] = coerced; + } + return permissions; +} + // --------------------------------------------------------------------------- // agent-device engine IR → canonical // --------------------------------------------------------------------------- @@ -320,6 +348,21 @@ export function canonicalizeAgentCommands( return program.commands.map((command) => canonicalizeAgentCommand(command, program.config)); } +/** Agent commands that canonicalize to a bare kind with no fields. */ +const BARE_AGENT_CANONICAL = { + scroll: { kind: 'scroll' }, + back: { kind: 'back' }, + hideKeyboard: { kind: 'hideKeyboard' }, + takeScreenshot: { kind: 'takeScreenshot' }, + runScript: { kind: 'runScript' }, +} satisfies Record; + +type BareAgentCommand = Extract; + +function isBareAgentCommand(command: MaestroCommand): command is BareAgentCommand { + return command.kind in BARE_AGENT_CANONICAL; +} + type AgentLifecycleCommand = Extract< MaestroCommand, { kind: 'launchApp' | 'stopApp' | 'clearState' } @@ -342,6 +385,7 @@ function canonicalizeAgentLifecycleCommand( appId: command.appId ?? config.appId, clearState: command.clearState, stopApp: command.stopApp, + permissions: command.permissions, }); case 'stopApp': return { kind: 'stopApp' }; @@ -350,11 +394,15 @@ function canonicalizeAgentLifecycleCommand( } } -function canonicalizeAgentCommand( - command: MaestroCommand, - config: MaestroProgram['config'], -): CanonicalCommand { - if (isAgentLifecycleCommand(command)) return canonicalizeAgentLifecycleCommand(command, config); +type AgentTapCommand = Extract; + +const AGENT_TAP_KINDS = ['tapOn', 'doubleTapOn', 'longPressOn'] as const; + +function isAgentTapCommand(command: MaestroCommand): command is AgentTapCommand { + return (AGENT_TAP_KINDS as readonly string[]).includes(command.kind); +} + +function canonicalizeAgentTapCommand(command: AgentTapCommand): CanonicalCommand { switch (command.kind) { case 'tapOn': { const repeat = numLike(command.repeat) ?? 1; @@ -385,6 +433,24 @@ function canonicalizeAgentCommand( label: command.label, target: canonicalizeAgentTarget(command.target), }); + } +} + +type AgentAssertCommand = Extract; + +const AGENT_ASSERT_KINDS = [ + 'assertVisible', + 'assertNotVisible', + 'assertTrue', + 'extendedWaitUntil', +] as const; + +function isAgentAssertCommand(command: MaestroCommand): command is AgentAssertCommand { + return (AGENT_ASSERT_KINDS as readonly string[]).includes(command.kind); +} + +function canonicalizeAgentAssertCommand(command: AgentAssertCommand): CanonicalCommand { + switch (command.kind) { case 'assertVisible': return dropUndefined({ kind: 'assert', @@ -418,6 +484,18 @@ function canonicalizeAgentCommand( label: command.label, selector: canonicalizeAgentSelector(command.notVisible ?? command.visible), }); + } +} + +function canonicalizeAgentCommand( + command: MaestroCommand, + config: MaestroProgram['config'], +): CanonicalCommand { + if (isAgentLifecycleCommand(command)) return canonicalizeAgentLifecycleCommand(command, config); + if (isBareAgentCommand(command)) return BARE_AGENT_CANONICAL[command.kind]; + if (isAgentTapCommand(command)) return canonicalizeAgentTapCommand(command); + if (isAgentAssertCommand(command)) return canonicalizeAgentAssertCommand(command); + switch (command.kind) { case 'swipe': return { kind: 'swipe', label: command.label, gesture: agentGesture(command.gesture) }; case 'inputText': @@ -426,8 +504,6 @@ function canonicalizeAgentCommand( return dropUndefined({ kind: 'eraseText', count: numLike(command.charactersToErase) }); case 'openLink': return dropUndefined({ kind: 'openLink', link: command.link }); - case 'scroll': - return { kind: 'scroll' }; case 'scrollUntilVisible': // Upstream materializes the DOWN default onto the command at parse time; // our engine defers it to execution (runtime-port-commands.ts). Materialize @@ -442,14 +518,14 @@ function canonicalizeAgentCommand( }); case 'pressKey': return { kind: 'pressKey', key: command.key.toLowerCase() }; - case 'back': - return { kind: 'back' }; - case 'hideKeyboard': - return { kind: 'hideKeyboard' }; - case 'takeScreenshot': - return { kind: 'takeScreenshot' }; case 'waitForAnimationToEnd': return dropUndefined({ kind: 'waitForAnimationToEnd', timeout: numLike(command.timeout) }); + case 'setPermissions': + return dropUndefined({ + kind: 'setPermissions', + appId: command.appId ?? config.appId, + permissions: command.permissions, + }); case 'repeat': return { kind: 'repeat', times: numLike(command.times) ?? str(command.times) ?? '' }; case 'retry': @@ -463,8 +539,6 @@ function canonicalizeAgentCommand( label: command.label, source: command.include.kind === 'file' ? 'file' : 'commands', }); - case 'runScript': - return { kind: 'runScript' }; case 'evalScript': return { kind: 'evalScript' }; default: { diff --git a/packages/maestro/src/internal/program-ir-command-parser.ts b/packages/maestro/src/internal/program-ir-command-parser.ts index 9c19da7fbc..f1d797c950 100644 --- a/packages/maestro/src/internal/program-ir-command-parser.ts +++ b/packages/maestro/src/internal/program-ir-command-parser.ts @@ -16,6 +16,7 @@ import type { MaestroPressKeyCommand, MaestroScrollCommand, MaestroScrollUntilVisibleCommand, + MaestroSetPermissionsCommand, MaestroStopAppCommand, MaestroTakeScreenshotCommand, MaestroWaitForAnimationToEndCommand, @@ -60,6 +61,8 @@ import { readSequenceItems, sourceAt, type MaestroProgramParseContext, + MAESTRO_PERMISSION_VALUES, + VARIABLE_PATTERN, } from './program-ir-values.ts'; export function parseMaestroCommandList( @@ -124,6 +127,7 @@ const COMMAND_VALUE_PARSERS: Readonly> = { back: parseBack, waitForAnimationToEnd: parseWaitForAnimationToEnd, stopApp: parseStopApp, + setPermissions: parseSetPermissions, clearState: parseClearState, runScript: parseMaestroRunScriptCommand, evalScript: parseEvalScript, @@ -169,7 +173,7 @@ function parseLaunchApp( assertOnlyKeys( entries, 'launchApp', - ['appId', 'stopApp', 'clearState', 'arguments', 'launchArguments'], + ['appId', 'stopApp', 'clearState', 'permissions', 'arguments', 'launchArguments'], context, ); const appId = readOptionalEntry(entries, 'appId', (entry) => @@ -181,6 +185,9 @@ function parseLaunchApp( const clearState = readOptionalEntry(entries, 'clearState', (entry) => readOptionalBoolean(entry, 'launchApp.clearState', context), ); + const permissions = readOptionalEntry(entries, 'permissions', (entry) => + readSetPermissionsMap(entry, context, 'launchApp'), + ); const args = readOptionalEntry(entries, 'arguments', (entry) => parseLaunchArguments(entry, 'launchApp.arguments', context), ); @@ -193,6 +200,7 @@ function parseLaunchApp( appId, stopApp, clearState, + permissions, arguments: args, launchArguments, }); @@ -451,6 +459,74 @@ function parseStopApp( return { kind: 'stopApp', source, appId: readRequiredString(value, 'stopApp', context) }; } +function parseSetPermissions( + value: Node | null, + commandNode: Node, + context: MaestroProgramParseContext, +): MaestroSetPermissionsCommand { + const source = sourceAt(commandNode, context); + const entries = readMapEntries(value, 'setPermissions', context); + assertOnlyKeys(entries, 'setPermissions', ['appId', 'permissions', 'optional', 'label'], context); + if (!hasEntry(entries, 'permissions')) + invalidAt('Maestro setPermissions requires permissions.', commandNode, context); + const appId = readOptionalEntry(entries, 'appId', (entry) => + readOptionalString(entry, 'setPermissions.appId', context), + ); + const permissions = readSetPermissionsMap(entryValue(entries, 'permissions'), context); + const options = readOptionalCommandOption(entries, 'setPermissions', context); + const label = readMaestroCommandLabel(entries, 'setPermissions', context); + return stripUndefined({ + kind: 'setPermissions' as const, + source, + appId, + permissions, + ...options, + label, + }); +} + +function readSetPermissionsMap( + node: Node | null | undefined, + context: MaestroProgramParseContext, + owner = 'setPermissions', +): Record { + const entries = readMapEntries(node, `${owner}.permissions`, context); + if (entries.length === 0) + invalidAt(`Maestro ${owner}.permissions requires at least one permission.`, node, context); + const permissions: Record = {}; + for (const entry of entries) { + // No duplicate-key check: the YAML layer already rejects duplicate mapping + // keys, and `in`-style checks misfire on prototype names like `constructor`. + permissions[entry.key] = readPermissionValue(entry, context, owner); + } + return permissions; +} + +function readPermissionValue( + entry: { key: string; value: Node | null }, + context: MaestroProgramParseContext, + owner = 'setPermissions', +): string { + const name = `${owner}.permissions.${entry.key}`; + const value = readScalarValue(entry.value, name, context); + if (typeof value !== 'string') + invalidAt(`Maestro ${name} expects a string.`, entry.value, context); + const normalized = value.toLowerCase(); + if (MAESTRO_PERMISSION_VALUES.has(normalized)) return normalized; + if (VARIABLE_PATTERN.test(value)) return value; + if (value.includes('${')) + invalidAt( + `Maestro ${name} only supports allow|deny|unset (plus always|inuse|never|limited for location/photos) or a bare \${VAR} lookup; JavaScript expressions are not supported.`, + entry.value, + context, + ); + invalidAt( + `Maestro ${name} expects allow|deny|unset (plus always|inuse|never|limited for location/photos) or a bare \${VAR} lookup.`, + entry.value, + context, + ); +} + function parseClearState( value: Node | null, commandNode: Node, diff --git a/packages/maestro/src/internal/program-ir-values.ts b/packages/maestro/src/internal/program-ir-values.ts index 4883e79d2e..0b71c13ebb 100644 --- a/packages/maestro/src/internal/program-ir-values.ts +++ b/packages/maestro/src/internal/program-ir-values.ts @@ -199,7 +199,23 @@ export function readOptionalBoolean( return value; } -const VARIABLE_PATTERN = /^\$\{[A-Za-z_][A-Za-z0-9_.]*\}$/; +export const VARIABLE_PATTERN = /^\$\{[A-Za-z_][A-Za-z0-9_.]*\}$/; + +/** + * The `setPermissions`/`launchApp.permissions` value vocabulary, shared by the + * parser, the runtime port, and the daemon adapter: the plain states plus the + * iOS granular `location`/`photos` values. Per-permission validity (which + * granular value belongs where) is enforced by the execution layers. + */ +export const MAESTRO_PERMISSION_VALUES: ReadonlySet = new Set([ + 'allow', + 'deny', + 'unset', + 'always', + 'inuse', + 'never', + 'limited', +]); const NUMERIC_STRING_PATTERN = /^-?\d+(\.\d+)?$/; const INTEGER_STRING_PATTERN = /^-?\d+$/; diff --git a/packages/maestro/src/internal/program-ir.ts b/packages/maestro/src/internal/program-ir.ts index b14e95c0c7..d8020097ee 100644 --- a/packages/maestro/src/internal/program-ir.ts +++ b/packages/maestro/src/internal/program-ir.ts @@ -55,6 +55,7 @@ export type MaestroLaunchAppCommand = { appId?: string; stopApp?: boolean; clearState?: boolean; + permissions?: Record; arguments?: MaestroLaunchArguments; launchArguments?: MaestroLaunchArguments; }; @@ -207,6 +208,14 @@ export type MaestroStopAppCommand = { appId?: string; }; +export type MaestroSetPermissionsCommand = MaestroOptionalCommand & { + kind: 'setPermissions'; + source: MaestroSourceLocation; + appId?: string; + permissions: Record; + label?: string; +}; + export type MaestroClearStateCommand = { kind: 'clearState'; source: MaestroSourceLocation; @@ -277,6 +286,7 @@ export type MaestroCommand = | MaestroBackCommand | MaestroWaitForAnimationToEndCommand | MaestroStopAppCommand + | MaestroSetPermissionsCommand | MaestroClearStateCommand | MaestroRunScriptCommand | MaestroEvalScriptCommand diff --git a/packages/maestro/src/internal/runtime-port-commands.ts b/packages/maestro/src/internal/runtime-port-commands.ts index a65ca3aa97..9a8d5c66df 100644 --- a/packages/maestro/src/internal/runtime-port-commands.ts +++ b/packages/maestro/src/internal/runtime-port-commands.ts @@ -1,5 +1,6 @@ import { AppError } from '@agent-device/kernel/errors'; import { pointInsideRect, stripUndefined } from './shared.ts'; +import { MAESTRO_PERMISSION_VALUES } from './program-ir-values.ts'; import { maestroScrollDurationFromSpeed, MAESTRO_COMPATIBILITY_PRESETS, @@ -30,7 +31,7 @@ type MaestroCommandOf = Extract< >; type MaestroLifecycleCommand = MaestroCommandOf< - 'launchApp' | 'stopApp' | 'clearState' | 'openLink' + 'launchApp' | 'stopApp' | 'setPermissions' | 'clearState' | 'openLink' >; type MaestroTargetCommand = MaestroCommandOf<'tapOn' | 'doubleTapOn' | 'longPressOn'>; type MaestroTextCommand = MaestroCommandOf<'inputText' | 'eraseText'>; @@ -55,6 +56,7 @@ type MaestroRuntimeCommandHandlers = { const MAESTRO_RUNTIME_COMMAND_HANDLERS = { launchApp: executeLifecycleCommand, stopApp: executeLifecycleCommand, + setPermissions: executeLifecycleCommand, clearState: executeLifecycleCommand, openLink: executeLifecycleCommand, tapOn: executeTargetCommand, @@ -81,6 +83,7 @@ const MAESTRO_RUNTIME_COMMAND_HANDLERS = { const MAESTRO_COMMAND_REQUIRES_SETTLED_PREDECESSOR = { launchApp: true, stopApp: true, + setPermissions: true, clearState: true, openLink: true, tapOn: true, @@ -148,6 +151,16 @@ async function executeLifecycleCommand( context, 'invalidate', ); + case 'setPermissions': + return await invokeOperation( + operations.setPermissions, + { + appId: command.appId ?? request.appId, + permissions: resolveSetPermissions(command.permissions), + }, + context, + 'invalidate', + ); case 'clearState': return await invokeOperation( operations.clearState, @@ -170,11 +183,27 @@ function launchAppInput(command: MaestroCommandOf<'launchApp'>, request: Maestro appId: command.appId ?? request.appId, stopApp: command.stopApp, clearState: command.clearState, + permissions: command.permissions ? resolveSetPermissions(command.permissions) : undefined, arguments: command.arguments, launchArguments: command.launchArguments, }); } +function resolveSetPermissions(permissions: Readonly>) { + const resolved: Record = {}; + for (const [name, value] of Object.entries(permissions)) { + const normalized = value.toLowerCase(); + if (!MAESTRO_PERMISSION_VALUES.has(normalized)) { + throw new AppError( + 'INVALID_ARGS', + `Maestro setPermissions.permissions.${name} expects allow|deny|unset (plus always|inuse|never|limited for location/photos); received "${value}".`, + ); + } + resolved[name] = normalized; + } + return resolved; +} + async function executeTargetCommand( command: MaestroTargetCommand, request: MaestroRuntimeRequest, diff --git a/packages/maestro/src/internal/runtime-port-types.ts b/packages/maestro/src/internal/runtime-port-types.ts index 25890d2349..4c82492049 100644 --- a/packages/maestro/src/internal/runtime-port-types.ts +++ b/packages/maestro/src/internal/runtime-port-types.ts @@ -119,10 +119,15 @@ export type MaestroRuntimeOperations = { readonly appId?: string; readonly stopApp?: boolean; readonly clearState?: boolean; + readonly permissions?: Readonly>; readonly arguments?: MaestroLaunchArguments; readonly launchArguments?: MaestroLaunchArguments; }>; readonly stopApp: MaestroRuntimeOperation<{ readonly appId?: string }>; + readonly setPermissions: MaestroRuntimeOperation<{ + readonly appId?: string; + readonly permissions: Readonly>; + }>; readonly clearState: MaestroRuntimeOperation<{ readonly appId?: string }>; readonly openLink: MaestroRuntimeOperation<{ readonly link: string }>; diff --git a/packages/maestro/src/internal/support-matrix.ts b/packages/maestro/src/internal/support-matrix.ts index 3db9872e7c..beb7767561 100644 --- a/packages/maestro/src/internal/support-matrix.ts +++ b/packages/maestro/src/internal/support-matrix.ts @@ -1,5 +1,5 @@ export const MAESTRO_COMPAT_SUPPORTED_CAPABILITIES = [ - 'Flows: launchApp; runFlow file/inline with platform, visibility, and limited boolean conditions; onFlowStart/onFlowComplete; repeat.times and retry.', + 'Flows: launchApp (with clearState, permissions, and Apple-only launch arguments; permissions apply after state clearing but before launch, and a launchApp without permissions touches nothing — there is no silent all: allow default); setPermissions (mid-flow permission grants/denials/resets; all resolves in the backend — one simctl call on iOS, the declared permissions on Android — with specifics overriding after it; unservable names fail loudly instead of being skipped; unset fully resets and location: never denies); runFlow file/inline with platform, visibility, and limited boolean conditions; onFlowStart/onFlowComplete; repeat.times and retry.', 'Interactions: tapOn, doubleTapOn, longPressOn, inputText on the focused element, eraseText, openLink, hideKeyboard, basic pressKey, and back; selector targets poll until available and support recursive index, childOf, above, below, leftOf, rightOf, containsChild, containsDescendants, points, and optional; outer command labels are metadata, not target selectors.', 'Assertions and navigation: assertVisible, assertNotVisible, assertTrue (literal values and ${VAR} lookups only; "", "false", "0", "null", and "undefined" are falsy, everything else is truthy), extendedWaitUntil, scroll, scrollUntilVisible, absolute/percentage/target swipe, takeScreenshot, waitForAnimationToEnd, clearState, and stopApp.', 'Scripts: ordered runScript file/env scripts with http.post, json, and output variables; evalScript inline expressions run flow-scoped JavaScript and write output.* leaves for later steps.', diff --git a/packages/maestro/test/conformance/expected-divergence.ts b/packages/maestro/test/conformance/expected-divergence.ts index 221a728ac0..52f96e4fc1 100644 --- a/packages/maestro/test/conformance/expected-divergence.ts +++ b/packages/maestro/test/conformance/expected-divergence.ts @@ -53,11 +53,6 @@ export const FLOW_DIVERGENCES: Record = { reason: 'Standalone killApp is outside the supported subset.', unsupported: ['killApp'], }, - 'upstream/131_setPermissions': { - classification: 'we-reject', - reason: 'Standalone setPermissions is outside the supported subset.', - unsupported: ['setPermissions'], - }, // --- Deliberately stricter than upstream --- 'invalid/duplicate-keys': { classification: 'we-reject', diff --git a/packages/platform-android/src/__tests__/permission-grant-state.test.ts b/packages/platform-android/src/__tests__/permission-grant-state.test.ts index 41fcb9c1cf..f7f62dcbf2 100644 --- a/packages/platform-android/src/__tests__/permission-grant-state.test.ts +++ b/packages/platform-android/src/__tests__/permission-grant-state.test.ts @@ -1,6 +1,9 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; -import { parseAndroidRuntimePermissionGrants } from '../permission-grant-state.ts'; +import { + parseAndroidRequestedPermissions, + parseAndroidRuntimePermissionGrants, +} from '../permission-grant-state.ts'; // Captured from `adb shell dumpsys package com.callstack.agentdevicelab` on a Pixel 7 / API 36 // emulator, trimmed to the sections that decide the answer. The indentation is load-bearing: @@ -91,3 +94,44 @@ test('sections after Packages: cannot reopen the scan', () => { assert.equal(grants?.get('android.permission.RECORD_AUDIO'), 'not_granted'); assert.equal(grants?.get('android.permission.CAMERA'), 'granted'); }); + +// Requested ids live in their own section: bare names, no grant flags, ending where the +// install section begins. Later top-level sections cannot contribute ids either. +const REQUESTED_DUMP = [ + 'Packages:', + ' Package [com.example.app] (5f3a1c2):', + ' requested permissions:', + ' android.permission.INTERNET', + ' android.permission.RECORD_AUDIO: restricted=false', + ' com.example.app.CUSTOM_PERMISSION', + ' install permissions:', + ' android.permission.INTERNET: granted=true', + ' User 0: ceDataInode=0 installed=true', + 'Queries:', + ' com.example.app.OTHER: granted=true', +].join('\n'); + +test('requested permissions read bare ids up to the install section', () => { + assert.deepEqual(parseAndroidRequestedPermissions(REQUESTED_DUMP), [ + 'android.permission.INTERNET', + 'android.permission.RECORD_AUDIO', + 'com.example.app.CUSTOM_PERMISSION', + ]); +}); + +test.each([ + ['empty output', ''], + ['no Packages section', 'Activity Resolver Table:'], + ['a package without the section', 'Packages:\n Package [com.example.app] (abc):'], +] as const)('requested permissions reads %s as unknown', (_label, output) => { + assert.equal(parseAndroidRequestedPermissions(output), undefined); +}); + +test('an empty requested block declares nothing', () => { + assert.deepEqual( + parseAndroidRequestedPermissions( + 'Packages:\n Package [com.example.app] (abc):\n requested permissions:\n User 0: installed=true', + ), + [], + ); +}); diff --git a/packages/platform-android/src/__tests__/settings-permission.test.ts b/packages/platform-android/src/__tests__/settings-permission.test.ts index cce31f7711..3f1fe1c7ca 100644 --- a/packages/platform-android/src/__tests__/settings-permission.test.ts +++ b/packages/platform-android/src/__tests__/settings-permission.test.ts @@ -302,7 +302,7 @@ test.each([ ], [ 'an iOS-only target', - { permissionTarget: 'calendar' }, + { permissionTarget: 'location-always' }, /Unsupported permission target on Android/i, ], ] as const)('setAndroidSetting permission rejects %s', async (_label, options, message) => { @@ -321,3 +321,202 @@ test('setAndroidSetting permission requires an app in session', async () => { { code: 'INVALID_ARGS', message: /requires an active app in session/ }, ); }); + +// Explicit multi-id names fan out to one pm call per id, in table order. +test('setAndroidSetting permission grant contacts grants both contact ids', async () => { + await withFakeAdb( + fakeAdb((flat) => (flat === CURRENT_USER ? '0' : undefined)), + async ({ calls, device }) => { + await setAndroidSetting(device, 'permission', 'grant', 'com.example.app', { + permissionTarget: 'contacts', + }); + const flat = calls.map((args) => args.join(' ')); + assert.ok( + flat.includes('shell pm grant --user 0 com.example.app android.permission.READ_CONTACTS'), + flat.join('; '), + ); + assert.ok( + flat.includes('shell pm grant --user 0 com.example.app android.permission.WRITE_CONTACTS'), + flat.join('; '), + ); + }, + ); +}); + +/** A dump shaped like the lab app's: install, custom, and runtime permissions side by side. */ +function dumpsysWithRequested(): string { + return [ + 'Packages:', + ' Package [com.example.app] (abc):', + ' requested permissions:', + ' android.permission.INTERNET', + ' android.permission.RECORD_AUDIO', + ' com.example.app.CUSTOM_PERMISSION', + ' install permissions:', + ' android.permission.INTERNET: granted=true', + ' User 0: ceDataInode=0 installed=true', + ' runtime permissions:', + ' android.permission.RECORD_AUDIO: granted=true, flags=[ USER_SET]', + 'Queries:', + ].join('\n'); +} + +// `all` intersects the declared set before issuing anything: INTERNET is declared +// but not changeable, so it is skipped with a reason while RECORD_AUDIO lands. +test('setAndroidSetting permission grant all applies the declared changeable ids', async () => { + await withFakeAdb( + fakeAdb((flat) => { + if (flat === CURRENT_USER) return '0'; + if (flat === DUMPSYS) return dumpsysWithRequested(); + if (flat === 'shell pm grant --user 0 com.example.app android.permission.INTERNET') { + return { + stderr: + "Exception occurred while executing 'grant':\njava.lang.SecurityException: INTERNET is not a changeable permission type", + exitCode: 1, + }; + } + if (flat === 'shell pm grant --user 0 com.example.app com.example.app.CUSTOM_PERMISSION') { + return { + stderr: + 'SecurityException: Package com.example.app has not requested permission com.example.app.CUSTOM_PERMISSION', + exitCode: 1, + }; + } + return undefined; + }), + async ({ calls, device }) => { + const result = await setAndroidSetting(device, 'permission', 'grant', 'com.example.app', { + permissionTarget: 'all', + }); + const flat = calls.map((args) => args.join(' ')); + assert.ok( + flat.includes('shell pm grant --user 0 com.example.app android.permission.RECORD_AUDIO'), + flat.join('; '), + ); + assert.deepEqual(result, { + permission: 'all', + applied: ['android.permission.RECORD_AUDIO'], + warnings: [ + "Skipped android.permission.INTERNET for com.example.app: Exception occurred while executing 'grant': java.lang.SecurityException: INTERNET is not a changeable permission type", + 'Skipped com.example.app.CUSTOM_PERMISSION for com.example.app: SecurityException: Package com.example.app has not requested permission com.example.app.CUSTOM_PERMISSION', + ], + }); + }, + ); +}); + +// Revoke under `all` warns per held permission, like the single path. +test('setAndroidSetting permission revoke all warns for the held runtime id', async () => { + await withFakeAdb( + fakeAdb((flat) => { + if (flat === CURRENT_USER) return '0'; + if (flat === DUMPSYS) return dumpsysWithRequested(); + return undefined; + }), + async ({ device }) => { + const result = (await setAndroidSetting(device, 'permission', 'deny', 'com.example.app', { + permissionTarget: 'all', + })) as Record; + assert.deepEqual(result.permission, 'all'); + assert.ok( + (result.applied as string[]).includes('android.permission.RECORD_AUDIO'), + JSON.stringify(result), + ); + const warnings = (result.warnings as string[]).join('\n'); + assert.match(warnings, /RECORD_AUDIO was granted before this revoke/); + }, + ); +}); + +// Validation happens before mutation: an unreadable dump issues no pm call. +test.each([ + ['dumpsys fails', { stderr: 'error', exitCode: 1 }], + ['no requested section', dumpsys([{ id: 0, runtime: [[MICROPHONE, false]] }])], +] as const)('setAndroidSetting permission all refuses when %s', async (_label, reply) => { + await withFakeAdb( + fakeAdb((flat) => { + if (flat === CURRENT_USER) return '0'; + if (flat === DUMPSYS) return reply as string; + return { stderr: `unexpected args: ${flat}`, exitCode: 1 }; + }), + async ({ calls, device }) => { + await assertRejectsAppError( + () => + setAndroidSetting(device, 'permission', 'grant', 'com.example.app', { + permissionTarget: 'all', + }), + { code: 'COMMAND_FAILED', message: /declared permissions|requested permissions/i }, + ); + assert.ok( + calls.every((args) => !args.includes('pm')), + calls.map((args) => args.join(' ')).join('; '), + ); + }, + ); +}); + +// An operational pm failure mid-`all` aborts instead of being skipped: an +// offline device must not let launchApp continue with half-applied permissions. +test('setAndroidSetting permission grant all propagates an operational pm failure', async () => { + await withFakeAdb( + fakeAdb((flat) => { + if (flat === CURRENT_USER) return '0'; + if (flat === DUMPSYS) return dumpsysWithRequested(); + if (flat === 'shell pm grant --user 0 com.example.app android.permission.RECORD_AUDIO') { + return { stderr: 'device offline', exitCode: 1 }; + } + return undefined; + }), + async ({ device }) => { + await assertRejectsAppError( + () => + setAndroidSetting(device, 'permission', 'grant', 'com.example.app', { + permissionTarget: 'all', + }), + { code: 'COMMAND_FAILED', message: /Failed to grant Android permission.*RECORD_AUDIO/ }, + ); + }, + ); +}); + +// A photos probe that fails operationally (not as non-changeable) aborts `all` +// rather than collapsing into a skip warning. +test('setAndroidSetting permission grant all propagates an operational photos failure', async () => { + const requested = [ + 'Packages:', + ' Package [com.example.app] (abc):', + ' requested permissions:', + ' android.permission.READ_MEDIA_IMAGES', + ' User 0: ceDataInode=0 installed=true', + ' runtime permissions:', + ' android.permission.READ_MEDIA_IMAGES: granted=false', + 'Queries:', + ].join('\n'); + await withFakeAdb( + fakeAdb((flat) => { + if (flat === 'shell getprop ro.build.version.sdk') return '36'; + if (flat === CURRENT_USER) return '0'; + if (flat === DUMPSYS) return requested; + if ( + flat.startsWith('shell pm grant --user 0 com.example.app android.permission.READ_MEDIA') + ) { + return { stderr: 'device offline', exitCode: 1 }; + } + if ( + flat.startsWith('shell pm grant --user 0 com.example.app android.permission.READ_EXTERNAL') + ) { + return { stderr: 'device offline', exitCode: 1 }; + } + return undefined; + }), + async ({ device }) => { + await assertRejectsAppError( + () => + setAndroidSetting(device, 'permission', 'grant', 'com.example.app', { + permissionTarget: 'all', + }), + { code: 'COMMAND_FAILED', message: /Failed to grant Android photos permission/ }, + ); + }, + ); +}); diff --git a/packages/platform-android/src/permission-grant-state.ts b/packages/platform-android/src/permission-grant-state.ts index 5e0bfd18c9..d4ce607615 100644 --- a/packages/platform-android/src/permission-grant-state.ts +++ b/packages/platform-android/src/permission-grant-state.ts @@ -62,10 +62,21 @@ export async function readAndroidCurrentUserId(device: DeviceInfo): Promise text.trim().length > 0) + .map((text) => ({ text, indent: text.length - text.trimStart().length })); +} + /** * Runtime permission grants for `userId` only, or `undefined` when that user has no * runtime-permission block in the dump. @@ -80,10 +91,7 @@ export function parseAndroidRuntimePermissionGrants( dumpsysOutput: string, userId: number, ): AndroidRuntimePermissionGrants | undefined { - const lines = dumpsysOutput - .split('\n') - .filter((text) => text.trim().length > 0) - .map((text) => ({ text, indent: text.length - text.trimStart().length })); + const lines = dumpLines(dumpsysOutput); const packages = nestedBlock( lines, (line) => line.indent === 0 && line.text.trim() === 'Packages:', @@ -117,3 +125,50 @@ function nestedBlock( const end = rest.findIndex((line) => line.indent <= lines[start]!.indent); return end < 0 ? rest : rest.slice(0, end); } + +/** + * Both halves of one `dumpsys package` read: the declared ids and the acting + * user's runtime grants. Each half keeps its own absent-vs-empty semantics — + * see the two parsers — so callers can refuse on a missing section while + * still answering `unknown` for missing grants. + */ +export function parseAndroidPackagePermissions( + dumpsysOutput: string, + userId: number, +): { + requested: string[] | undefined; + grants: AndroidRuntimePermissionGrants | undefined; +} { + return { + requested: parseAndroidRequestedPermissions(dumpsysOutput), + grants: parseAndroidRuntimePermissionGrants(dumpsysOutput, userId), + }; +} + +/** + * The permission ids the package declares, in dump order, or `undefined` when + * the dump carries no `requested permissions:` block for a package. An empty + * block is still an answer — the app declares nothing — while a missing one + * means the device did not tell us, and `all` must refuse rather than guess. + * + * Entries are bare ids (`android.permission.CAMERA`); any trailing attribute + * (`: restricted=false`) is not part of the id. Section scoping reuses the + * same `Packages:` → `Package […]` nesting as the grants read, so the later + * top-level sections cannot leak ids in. + */ +export function parseAndroidRequestedPermissions(dumpsysOutput: string): string[] | undefined { + const lines = dumpLines(dumpsysOutput); + const packages = nestedBlock( + lines, + (line) => line.indent === 0 && line.text.trim() === 'Packages:', + ); + const pkg = nestedBlock(packages, (line) => PACKAGE_BLOCK.test(line.text)); + const requested = nestedBlock(pkg, (line) => REQUESTED_PERMISSIONS_BLOCK.test(line.text)); + if (!requested) return undefined; + const ids: string[] = []; + for (const { text } of requested) { + const id = PERMISSION_ID.exec(text)?.[1]; + if (id && id.includes('.') && !ids.includes(id)) ids.push(id); + } + return ids; +} diff --git a/packages/platform-android/src/settings-permission.ts b/packages/platform-android/src/settings-permission.ts index 3c174d1671..0c09ddef72 100644 --- a/packages/platform-android/src/settings-permission.ts +++ b/packages/platform-android/src/settings-permission.ts @@ -3,10 +3,13 @@ import type { DeviceInfo } from '@agent-device/kernel/device'; import { parsePermissionAction, parsePermissionTarget } from '@agent-device/contracts/settings'; import type { SettingOptions } from '@agent-device/contracts/settings'; import { runAndroidAdb } from './adb.ts'; +import { androidAdbResultError } from './adb-failure.ts'; import { + parseAndroidPackagePermissions, readAndroidCurrentUserId, readAndroidRuntimePermissionGrants, type AndroidPriorGrantState, + type AndroidRuntimePermissionGrants, } from './permission-grant-state.ts'; /** @@ -35,6 +38,43 @@ export function androidRevokedPermissionWarning( type AndroidPermissionTarget = ReturnType; +/** + * Canonical Maestro/Android names to the `pm` permission ids they fan out to. + * Mirrors upstream Maestro's `translatePermissionName`; every id is applied + * with the same `pm grant|revoke` mechanism, so the table needs no per-entry + * device verification — only the mechanism does, and it is covered on both + * paths below. `photos` (SDK-dependent probing) and `notifications` (appops) + * keep their dedicated kinds; `all` resolves against the package instead. + */ +const ANDROID_PERMISSION_TABLE: Readonly> = { + bluetooth: ['android.permission.BLUETOOTH_CONNECT', 'android.permission.BLUETOOTH_SCAN'], + calendar: ['android.permission.WRITE_CALENDAR', 'android.permission.READ_CALENDAR'], + camera: ['android.permission.CAMERA'], + contacts: ['android.permission.READ_CONTACTS', 'android.permission.WRITE_CONTACTS'], + location: [ + 'android.permission.ACCESS_FINE_LOCATION', + 'android.permission.ACCESS_COARSE_LOCATION', + ], + 'media-library': [ + 'android.permission.WRITE_EXTERNAL_STORAGE', + 'android.permission.READ_EXTERNAL_STORAGE', + 'android.permission.READ_MEDIA_AUDIO', + 'android.permission.READ_MEDIA_IMAGES', + 'android.permission.READ_MEDIA_VIDEO', + ], + microphone: ['android.permission.RECORD_AUDIO'], + phone: ['android.permission.CALL_PHONE', 'android.permission.ANSWER_PHONE_CALLS'], + sms: [ + 'android.permission.READ_SMS', + 'android.permission.RECEIVE_SMS', + 'android.permission.SEND_SMS', + ], + storage: [ + 'android.permission.WRITE_EXTERNAL_STORAGE', + 'android.permission.READ_EXTERNAL_STORAGE', + ], +}; + /** * `--user ` for every permission mutation, resolved once so the state read and the mutation * cannot address different users. Never empty: a permission mutation that cannot name its user @@ -74,6 +114,9 @@ export async function setAndroidPermission( const target = parseAndroidPermissionTarget(options?.permissionTarget, options?.permissionMode); const userId = await requireAndroidPermissionUser(device); const userArgs: AndroidUserArgs = ['--user', String(userId)]; + if (target.kind === 'all') { + return await setAllAndroidPermissions(device, appPackage, action, userId, userArgs); + } if (action === 'grant') { await grantAndroidPermission(device, appPackage, target, userArgs); return; @@ -81,16 +124,263 @@ export async function setAndroidPermission( // Read before the revoke — afterwards every permission reads as not granted — but resolved // after it, because `photos` only learns which permission it revoked by probing the device. const grants = await readAndroidRuntimePermissionGrants(device, appPackage, userId); - const permission = await revokeAndroidPermission(device, appPackage, action, target, userArgs); - const priorGrantState: AndroidPriorGrantState = grants?.get(permission) ?? 'unknown'; - const warning = androidRevokedPermissionWarning(appPackage, permission, priorGrantState); + const revoked = await revokeAndroidPermission(device, appPackage, action, target, userArgs); + const states = revoked.map((permission) => grants?.get(permission) ?? 'unknown'); + const priorGrantState: AndroidPriorGrantState = states.includes('granted') + ? 'granted' + : states.includes('unknown') + ? 'unknown' + : 'not_granted'; + const warnings = revoked.flatMap((permission, index) => { + const warning = androidRevokedPermissionWarning(appPackage, permission, states[index]!); + return warning ? [warning] : []; + }); return { - permission, + permission: revoked.join(','), priorGrantState, - ...(warning ? { warnings: [warning] } : {}), + ...(warnings.length > 0 ? { warnings } : {}), + }; +} + +/** + * `all`: every permission the package declares, resolved from one `dumpsys + * package` read before anything is mutated. Declared-but-not-changeable ids + * (install permissions like INTERNET, special ids like MANAGE_EXTERNAL_STORAGE, + * custom ids the runtime rejects) are skipped with a reason instead of + * stopping the sequence — while an explicit target for the same id still + * fails loudly. Anything the dump does not list is never attempted, which is + * what keeps `pm` from throwing "has not requested permission" partway. + * Operational failures (offline device, dropped transport) abort the fan-out + * instead of becoming skips, so launchApp cannot continue half-applied. + */ +async function setAllAndroidPermissions( + device: DeviceInfo, + appPackage: string, + action: 'grant' | 'deny' | 'reset', + userId: number, + userArgs: AndroidUserArgs, +): Promise> { + const dump = await runAndroidAdb(device, ['shell', 'dumpsys', 'package', appPackage], { + allowFailure: true, + }); + if (dump.exitCode !== 0) { + throw new AppError( + 'COMMAND_FAILED', + `Could not read declared permissions for ${appPackage}, so no permission was changed.`, + { appPackage, stdout: dump.stdout, stderr: dump.stderr, exitCode: dump.exitCode }, + ); + } + const { requested, grants: revokedGrants } = parseAndroidPackagePermissions(dump.stdout, userId); + if (requested === undefined) { + throw new AppError( + 'COMMAND_FAILED', + `Could not find declared permissions for ${appPackage}, so no permission was changed.`, + { appPackage }, + ); + } + const grants = action === 'grant' ? undefined : revokedGrants; + const applied: string[] = []; + const warnings: string[] = []; + for (const unit of allPermissionUnits(requested)) { + await applyAllPermissionUnit( + { device, appPackage, action, userArgs, grants, applied, warnings }, + unit, + ); + } + return { + permission: 'all', + applied, + ...(warnings.length > 0 ? { warnings } : {}), }; } +type AllUnitContext = { + device: DeviceInfo; + appPackage: string; + action: 'grant' | 'deny' | 'reset'; + userArgs: AndroidUserArgs; + grants: AndroidRuntimePermissionGrants | undefined; + applied: string[]; + warnings: string[]; +}; + +/** One declared-permission unit: strict appops for notifications, best-effort pm otherwise. */ +async function applyAllPermissionUnit(ctx: AllUnitContext, unit: AllPermissionUnit): Promise { + if (unit.kind === 'notification') return await applyAllNotificationsUnit(ctx); + if (unit.kind === 'photos') return await applyAllPhotosUnit(ctx); + return await applyAllPmUnit(ctx, unit.value); +} + +async function applyAllNotificationsUnit(ctx: AllUnitContext): Promise { + const { device, appPackage, action, userArgs, grants, applied, warnings } = ctx; + await setAndroidNotificationPermission( + device, + appPackage, + action, + { appOps: 'POST_NOTIFICATION', permission: 'android.permission.POST_NOTIFICATIONS' }, + userArgs, + ); + applied.push('android.permission.POST_NOTIFICATIONS'); + warnIfRevoked(warnings, grants, appPackage, 'android.permission.POST_NOTIFICATIONS'); +} + +async function applyAllPhotosUnit(ctx: AllUnitContext): Promise { + const { device, appPackage, action, userArgs, warnings } = ctx; + const resolved = await tryPhotosUnit( + device, + appPackage, + action === 'grant' ? 'grant' : 'revoke', + userArgs, + ); + if (resolved === undefined) { + warnings.push( + `Skipped Android photos permission for ${appPackage}: device refused both media candidates.`, + ); + return; + } + await finishAllUnit(ctx, resolved); +} + +async function applyAllPmUnit(ctx: AllUnitContext, permission: string): Promise { + const { device, appPackage, action, userArgs, warnings } = ctx; + const attempt = await tryPmUnit( + device, + action === 'grant' ? 'grant' : 'revoke', + userArgs, + appPackage, + permission, + ); + if (!attempt.ok) { + warnings.push(`Skipped ${permission} for ${appPackage}: ${attempt.reason}`); + return; + } + await finishAllUnit(ctx, permission); +} + +/** Record a landed mutation: reset its flags when asked, then warn if it may have killed the app. */ +async function finishAllUnit(ctx: AllUnitContext, permission: string): Promise { + const { device, appPackage, action, userArgs, grants, applied, warnings } = ctx; + applied.push(permission); + if (action === 'reset') + await clearAndroidPermissionFlags(device, appPackage, permission, userArgs); + if (action !== 'grant') warnIfRevoked(warnings, grants, appPackage, permission); +} + +type AllPermissionUnit = + | { kind: 'photos' } + | { kind: 'notification' } + | { kind: 'pm'; value: string }; + +/** Collapse declared ids into mutation units: one photos probe, one appops path, direct pm otherwise. */ +function allPermissionUnits(requested: readonly string[]): AllPermissionUnit[] { + const units: AllPermissionUnit[] = []; + let photosQueued = false; + for (const id of requested) { + if (id === 'android.permission.POST_NOTIFICATIONS') units.push({ kind: 'notification' }); + else if ( + id === 'android.permission.READ_MEDIA_IMAGES' || + id === 'android.permission.READ_EXTERNAL_STORAGE' + ) { + if (!photosQueued) { + photosQueued = true; + units.push({ kind: 'photos' }); + } + } else units.push({ kind: 'pm', value: id }); + } + return units; +} + +function warnIfRevoked( + warnings: string[], + grants: AndroidRuntimePermissionGrants | undefined, + appPackage: string, + permission: string, +): void { + const warning = androidRevokedPermissionWarning( + appPackage, + permission, + grants?.get(permission) ?? 'unknown', + ); + if (warning) warnings.push(warning); +} + +async function tryPmUnit( + device: DeviceInfo, + pmAction: 'grant' | 'revoke', + userArgs: AndroidUserArgs, + appPackage: string, + permission: string, +): Promise<{ ok: true } | { ok: false; reason: string }> { + const result = await runAndroidAdb( + device, + ['shell', 'pm', pmAction, ...userArgs, appPackage, permission], + { allowFailure: true }, + ); + if (result.exitCode === 0) return { ok: true }; + if (isSkippablePmStderr(result.stderr)) { + return { ok: false, reason: firstStderrLine(result.stderr) }; + } + throw androidAdbResultError( + `Failed to ${pmAction} Android permission ${permission} for ${appPackage}`, + result, + { appPackage, permission }, + ); +} + +/** + * Only established non-changeable signals are skipped under `all`: an install + * permission `pm` cannot touch, an id the package never requested, or a name + * the runtime does not know as a runtime permission. Anything else (offline + * device, dropped transport, denied op) is operational and must abort the + * fan-out rather than let launchApp continue with half-applied permissions. + */ +function isSkippablePmStderr(stderr: string): boolean { + const text = stderr.toLowerCase(); + return ( + text.includes('not a changeable permission') || + text.includes('has not requested permission') || + text.includes('is not a runtime permission') || + text.includes('unknown permission') + ); +} + +async function tryPhotosUnit( + device: DeviceInfo, + appPackage: string, + pmAction: 'grant' | 'revoke', + userArgs: AndroidUserArgs, +): Promise { + try { + return await setAndroidPhotoPermission(device, appPackage, pmAction, userArgs); + } catch (error) { + if (isSkippablePhotosError(error)) return undefined; + throw error; + } +} + +/** A photos probe failure is skippable only when every candidate was refused as non-changeable. */ +function isSkippablePhotosError(error: unknown): boolean { + if (!(error instanceof AppError) || error.code !== 'COMMAND_FAILED') return false; + const attempts = error.details?.attempts; + if (!Array.isArray(attempts) || attempts.length === 0) return false; + return attempts.every( + (attempt) => + typeof (attempt as { stderr?: unknown }).stderr === 'string' && + isSkippablePmStderr((attempt as { stderr: string }).stderr), + ); +} + +function firstStderrLine(stderr: string): string { + const lines = stderr + .split('\n') + .map((part) => part.trim()) + .filter((part) => part.length > 0); + const first = lines[0] ?? 'unknown device error'; + // adb wraps the cause onto the next line ("Exception occurred ...:\njava.lang..."). + const reason = first.endsWith(':') && lines[1] ? `${first} ${lines[1]}` : first; + return reason.slice(0, 200); +} + async function grantAndroidPermission( device: DeviceInfo, appPackage: string, @@ -99,62 +389,82 @@ async function grantAndroidPermission( ): Promise { if (target.kind === 'notifications') { await setAndroidNotificationPermission(device, appPackage, 'grant', target, userArgs); - } else if (target.type === 'photos') { + } else if (target.kind === 'photos') { await setAndroidPhotoPermission(device, appPackage, 'grant', userArgs); + } else if (target.kind === 'pm') { + for (const value of target.values) { + await runAndroidAdb(device, ['shell', 'pm', 'grant', ...userArgs, appPackage, value]); + } + } else if (target.kind === 'all') { + throw new Error('Unhandled Android permission target: all is resolved by the caller.'); } else { - await runAndroidAdb(device, ['shell', 'pm', 'grant', ...userArgs, appPackage, target.value]); + const exhaustive: never = target; + throw new Error(`Unhandled Android permission target: ${JSON.stringify(exhaustive)}`); } } -/** Revokes (and for `reset`, clears the flags of) the target; returns the permission revoked. */ +/** Revokes (and for `reset`, clears the flags of) the target; returns the permissions revoked. */ async function revokeAndroidPermission( device: DeviceInfo, appPackage: string, action: 'deny' | 'reset', target: AndroidPermissionTarget, userArgs: AndroidUserArgs, -): Promise { +): Promise { if (target.kind === 'notifications') { await setAndroidNotificationPermission(device, appPackage, action, target, userArgs); - return target.permission; + return [target.permission]; } - let permission: string; - if (target.type === 'photos') { - permission = await setAndroidPhotoPermission(device, appPackage, 'revoke', userArgs); - } else { - permission = target.value; - await runAndroidAdb(device, ['shell', 'pm', 'revoke', ...userArgs, appPackage, permission]); + if (target.kind === 'photos') { + const resolved = await setAndroidPhotoPermission(device, appPackage, 'revoke', userArgs); + if (action === 'reset') { + await clearAndroidPermissionFlags(device, appPackage, resolved, userArgs); + } + return [resolved]; } - if (action === 'reset') { - await clearAndroidPermissionFlags(device, appPackage, permission, userArgs); + if (target.kind === 'pm') { + for (const value of target.values) { + await runAndroidAdb(device, ['shell', 'pm', 'revoke', ...userArgs, appPackage, value]); + } + if (action === 'reset') { + for (const value of target.values) { + await clearAndroidPermissionFlags(device, appPackage, value, userArgs); + } + } + return [...target.values]; } - return permission; + if (target.kind === 'all') { + throw new Error('Unhandled Android permission target: all is resolved by the caller.'); + } + const exhaustive: never = target; + throw new Error(`Unhandled Android permission target: ${JSON.stringify(exhaustive)}`); } function parseAndroidPermissionTarget( permissionTarget: string | undefined, permissionMode: string | undefined, ): - | { kind: 'pm'; value: string; type: 'camera' | 'microphone' | 'photos' | 'contacts' } - | { kind: 'notifications'; appOps: string; permission: string } { + | { kind: 'pm'; values: readonly string[] } + | { kind: 'photos' } + | { kind: 'notifications'; appOps: string; permission: string } + | { kind: 'all' } { const normalized = parsePermissionTarget(permissionTarget); + if (normalized === 'all') { + if (permissionMode?.trim()) { + throw new AppError( + 'INVALID_ARGS', + `Permission mode is only supported for photos. Received: ${permissionMode}.`, + ); + } + return { kind: 'all' }; + } if (permissionMode?.trim()) { throw new AppError( 'INVALID_ARGS', `Permission mode is only supported for photos. Received: ${permissionMode}.`, ); } - if (normalized === 'camera') - return { kind: 'pm', value: 'android.permission.CAMERA', type: 'camera' }; - if (normalized === 'microphone') { - return { kind: 'pm', value: 'android.permission.RECORD_AUDIO', type: 'microphone' }; - } - if (normalized === 'photos') { - return { kind: 'pm', value: 'android.permission.READ_MEDIA_IMAGES', type: 'photos' }; - } - if (normalized === 'contacts') { - return { kind: 'pm', value: 'android.permission.READ_CONTACTS', type: 'contacts' }; - } + if (normalized === 'photos') return { kind: 'photos' }; if (normalized === 'notifications') { return { kind: 'notifications', @@ -162,9 +472,12 @@ function parseAndroidPermissionTarget( permission: 'android.permission.POST_NOTIFICATIONS', }; } + const values = ANDROID_PERMISSION_TABLE[normalized]; + if (values) return { kind: 'pm', values }; throw new AppError( 'INVALID_ARGS', - `Unsupported permission target on Android: ${permissionTarget}. Use camera|microphone|photos|contacts|notifications.`, + `Unsupported permission target on Android: ${permissionTarget}. Use all|bluetooth|calendar|camera|contacts|location|media-library|microphone|notifications|phone|photos|sms|storage.`, + { hint: 'Android custom permission ids are attempted through all, not individually.' }, ); } diff --git a/packages/platform-apple/src/core/__tests__/app-settings.test.ts b/packages/platform-apple/src/core/__tests__/app-settings.test.ts index 6f2d9011e6..7e4d084f1f 100644 --- a/packages/platform-apple/src/core/__tests__/app-settings.test.ts +++ b/packages/platform-apple/src/core/__tests__/app-settings.test.ts @@ -320,6 +320,26 @@ test('setIosSetting permission grant calendar uses simctl privacy calendar targe ); }); +test('setIosSetting permission grant all passes all through as one simctl call', async () => { + await withFakeAppleTool( + (args) => { + if (isSimctlListDevices(args)) return BOOTED_SIM_LIST_JSON; + if (args.join(' ') === 'simctl privacy sim-1 grant all com.example.app') return ''; + return unexpectedArgs(args); + }, + async ({ calls }) => { + await setIosSetting(IOS_TEST_SIMULATOR, 'permission', 'grant', 'com.example.app', { + permissionTarget: 'all', + }); + const flat = calls.map((args) => args.join(' ')); + assert.deepEqual( + flat.filter((line) => line.includes('privacy sim-1')), + ['simctl privacy sim-1 grant all com.example.app'], + ); + }, + ); +}); + test('setIosSetting clear-app-state wipes iOS simulator app data container', async () => { const containerPath = await mkdtempForTest('agent-device-ios-clear-app-state-container-'); await fs.mkdir(path.join(containerPath, 'Documents'), { recursive: true }); @@ -435,21 +455,35 @@ test('setIosSetting permission rejects mode for non-photos target', async () => ); }); -test('setIosSetting permission reset notifications falls back to reset all when direct reset is blocked', async () => { +test('setIosSetting permission reset notifications fails targeted when direct reset is blocked', async () => { + // A listed-but-blocked notifications service must not fall back to `reset + // all`: a notifications-only reset would clear microphone, location, and + // other grants. The targeted reset fails instead, leaving the earlier grant + // in place. await withFakeAppleTool( (args) => { if (isSimctlListDevices(args)) return BOOTED_SIM_LIST_JSON; if (args[0] === 'simctl' && args[1] === 'privacy' && args[2] === 'help') return undefined; + if (args.join(' ') === 'simctl privacy sim-1 grant microphone com.example.app') return ''; if (args.join(' ') === 'simctl privacy sim-1 reset notifications com.example.app') { return { stderr: 'Failed to reset access\nOperation not permitted', exitCode: 1 }; } - if (args.join(' ') === 'simctl privacy sim-1 reset all com.example.app') return ''; return unexpectedArgs(args); }, async ({ calls }) => { - await setIosSetting(IOS_TEST_SIMULATOR, 'permission', 'reset', 'com.example.app', { - permissionTarget: 'notifications', + await setIosSetting(IOS_TEST_SIMULATOR, 'permission', 'grant', 'com.example.app', { + permissionTarget: 'microphone', }); + await assertRejectsAppError( + () => + setIosSetting(IOS_TEST_SIMULATOR, 'permission', 'reset', 'com.example.app', { + permissionTarget: 'notifications', + }), + { + code: 'UNSUPPORTED_OPERATION', + message: /does not support resetting notifications permission/i, + }, + ); const flat = calls.map((args) => args.join(' ')); assert.equal( flat.includes('simctl privacy sim-1 reset notifications com.example.app'), @@ -457,7 +491,59 @@ test('setIosSetting permission reset notifications falls back to reset all when flat.join('; '), ); assert.equal( - flat.includes('simctl privacy sim-1 reset all com.example.app'), + flat.some((line) => line.includes('reset all com.example.app')), + false, + flat.join('; '), + ); + assert.equal( + flat.includes('simctl privacy sim-1 grant microphone com.example.app'), + true, + flat.join('; '), + ); + }, + ); +}); + +test('setIosSetting permission reset notifications fails explicitly without touching other services', async () => { + // Runtimes like iOS 26.3 omit notifications from `simctl privacy help`, where + // no targeted reset exists: the probe gate rejects before any privacy call, + // so an earlier microphone grant survives the failed reset. + const device: DeviceInfo = { + ...IOS_TEST_SIMULATOR, + simulatorSetPath: '/fake/privacy-help-no-notifications', + }; + const HELP_WITHOUT_NOTIFICATIONS = `Usage: simctl privacy [] + + service + The service: + microphone - Allow access to audio input.`; + await withFakeAppleTool( + (args) => { + if (isSimctlListDevices(args)) return BOOTED_SIM_LIST_JSON; + if (args.includes('help')) return HELP_WITHOUT_NOTIFICATIONS; + const flat = args.join(' '); + if (flat.includes('grant microphone com.example.app')) return ''; + return unexpectedArgs(args); + }, + async ({ calls }) => { + await setIosSetting(device, 'permission', 'grant', 'com.example.app', { + permissionTarget: 'microphone', + }); + await assertRejectsAppError( + () => + setIosSetting(device, 'permission', 'reset', 'com.example.app', { + permissionTarget: 'notifications', + }), + { code: 'UNSUPPORTED_OPERATION', message: /does not support service "notifications"/i }, + ); + const flat = calls.map((args) => args.join(' ')); + assert.equal( + flat.some((line) => line.includes('reset all com.example.app')), + false, + flat.join('; '), + ); + assert.equal( + flat.some((line) => line.includes('grant microphone com.example.app')), true, flat.join('; '), ); diff --git a/packages/platform-apple/src/core/app-settings.ts b/packages/platform-apple/src/core/app-settings.ts index 7ff4666b41..407706ef69 100644 --- a/packages/platform-apple/src/core/app-settings.ts +++ b/packages/platform-apple/src/core/app-settings.ts @@ -285,48 +285,32 @@ async function runIosPrivacyCommand( } const args = ['privacy', device.id, action, target, appBundleId]; - const isNotificationsTarget = target === 'notifications'; - if (!(action === 'reset' && isNotificationsTarget)) { - try { - await runSimctl(device, args); - return; - } catch (error) { - if (!(isNotificationsTarget && isNotificationsOperationNotPermitted(error))) { - throw error; - } + try { + await runSimctl(device, args); + return; + } catch (error) { + if (!(target === 'notifications' && isNotificationsOperationNotPermitted(error))) { + throw error; + } + if (action === 'reset') { throw new AppError( 'UNSUPPORTED_OPERATION', - 'iOS simulator does not support setting notifications permission via simctl privacy on this runtime.', + 'iOS simulator does not support resetting notifications permission via simctl privacy on this runtime.', { deviceId: device.id, appBundleId, - hint: 'Use reset notifications for reprompt behavior, or toggle notifications manually in Settings.', + hint: 'Use reinstall to force a fresh notifications prompt, or reset simulator content and settings.', }, ); } - } - - try { - await runSimctl(device, args); - return; - } catch (error) { - if (!isNotificationsOperationNotPermitted(error)) { - throw error; - } - } - - try { - await runSimctl(device, ['privacy', device.id, 'reset', 'all', appBundleId]); - } catch (error) { throw new AppError( - 'COMMAND_FAILED', - 'iOS simulator blocked direct notifications reset. Fallback reset-all also failed.', + 'UNSUPPORTED_OPERATION', + 'iOS simulator does not support setting notifications permission via simctl privacy on this runtime.', { deviceId: device.id, appBundleId, - hint: 'Use reinstall to force a fresh notifications prompt, or reset simulator content and settings.', + hint: 'Use reset notifications for reprompt behavior, or toggle notifications manually in Settings.', }, - error instanceof Error ? error : undefined, ); } } @@ -399,6 +383,7 @@ function parseIosPermissionTarget( `Permission mode is only supported for photos. Received: ${permissionMode}.`, ); } + if (normalized === 'all') return 'all'; if (normalized === 'camera') return 'camera'; if (normalized === 'microphone') return 'microphone'; if (normalized === 'contacts') return 'contacts'; @@ -419,7 +404,7 @@ function parseIosPermissionTarget( } throw new AppError( 'INVALID_ARGS', - `Unsupported permission target: ${permissionTarget}. Use camera|microphone|photos|contacts|contacts-limited|notifications|calendar|location|location-always|media-library|motion|reminders|siri.`, + `Unsupported permission target: ${permissionTarget}. Use all|camera|microphone|photos|contacts|contacts-limited|notifications|calendar|location|location-always|media-library|motion|reminders|siri.`, ); } diff --git a/scripts/fuzz/validation-arbitraries-maestro.ts b/scripts/fuzz/validation-arbitraries-maestro.ts index 72e75e14df..1507debbcc 100644 --- a/scripts/fuzz/validation-arbitraries-maestro.ts +++ b/scripts/fuzz/validation-arbitraries-maestro.ts @@ -47,6 +47,9 @@ function validMaestroCommand(pick: number, salt: number): string[] { () => ['- scrollUntilVisible:', ' element:', ` text: ${text}`], () => ['- repeat:', ' times: 2', ' commands:', ' - back'], () => ['- runFlow: other.yaml'], + () => ['- setPermissions:', ' permissions:', ' camera: allow'], + () => ['- setPermissions:', ' permissions:', ' all: deny'], + () => ['- launchApp:', ' appId: com.example.app', ' permissions:', ' camera: allow'], () => [`- evalScript: ${text}`], ]; return options[pick % options.length]!(); @@ -89,6 +92,11 @@ const MAESTRO_MUTATIONS: readonly MaestroMutation[] = [ }, { name: 'bad-press-key', code: 'INVALID_ARGS', lines: () => ['- pressKey: sleep'] }, { name: 'scroll-options', code: 'INVALID_ARGS', lines: () => ['- scroll:', ' direction: UP'] }, + { + name: 'bad-permission-value', + code: 'INVALID_ARGS', + lines: () => ['- setPermissions:', ' permissions:', ' camera: maybe'], + }, ]; /** Declared classes plus the config-level variant `unsupported-field` renders for a salt slice. */ diff --git a/src/commands/capture/settings.test.ts b/src/commands/capture/settings.test.ts index ad41547195..186bcb0fce 100644 --- a/src/commands/capture/settings.test.ts +++ b/src/commands/capture/settings.test.ts @@ -136,7 +136,7 @@ describe('settings CLI permission vocabulary', () => { }); test('rejects a target outside the vocabulary without normalizing it', () => { - for (const permission of ['CAMERA', ' all', 'all', 'location-always ']) { + for (const permission of ['CAMERA', ' all', 'location-always ']) { expectInvalidArgs(() => readGrant(permission), PERMISSION_TARGETS_MESSAGE); } }); diff --git a/src/daemon/adapters/maestro/__tests__/daemon-runtime-port-set-permissions.test.ts b/src/daemon/adapters/maestro/__tests__/daemon-runtime-port-set-permissions.test.ts new file mode 100644 index 0000000000..2639a796b0 --- /dev/null +++ b/src/daemon/adapters/maestro/__tests__/daemon-runtime-port-set-permissions.test.ts @@ -0,0 +1,187 @@ +import assert from 'node:assert/strict'; +import { expect, test } from 'vitest'; +import type { DaemonRequest } from '../../../daemon-request.ts'; +import { createDaemonMaestroRuntimePort } from '../daemon-runtime-port.ts'; +import { makeBaseRequest, makeDependencies } from './daemon-runtime-port-fixtures.ts'; + +function makePort(requests: DaemonRequest[], platform: 'ios' | 'android') { + return createDaemonMaestroRuntimePort({ + baseReq: makeBaseRequest({ flags: { platform, replayBackend: 'maestro' } }), + invoke: async (request) => { + requests.push(request); + return { ok: true, data: {} }; + }, + dependencies: makeDependencies(), + platform, + }); +} + +test('setPermissions sends all as one backend call with specifics after it', async () => { + const requests: DaemonRequest[] = []; + const port = makePort(requests, 'android'); + + await port.execute({ + command: { + kind: 'setPermissions', + source: { line: 3 }, + permissions: { all: 'deny', notifications: 'unset' }, + }, + appId: 'com.example.app', + generation: 0, + env: {}, + invalidateObservation() {}, + }); + + expect(requests.map(({ command }) => command)).toEqual(['settings', 'settings']); + expect(requests.map(({ positionals }) => positionals)).toEqual([ + ['permission', 'deny', 'all'], + ['permission', 'reset', 'notifications'], + ]); + expect( + requests.every(({ internal }) => internal?.settingsAppBundleId === 'com.example.app'), + ).toBe(true); +}); + +test('a mid-sequence backend rejection names what already landed', async () => { + const requests: DaemonRequest[] = []; + let calls = 0; + const port = createDaemonMaestroRuntimePort({ + baseReq: makeBaseRequest({ flags: { platform: 'android', replayBackend: 'maestro' } }), + invoke: async (request) => { + requests.push(request); + calls += 1; + if (calls === 2) { + return { + ok: false, + error: { code: 'UNSUPPORTED_OPERATION', message: 'No such service on this runtime.' }, + }; + } + return { ok: true, data: {} }; + }, + dependencies: makeDependencies(), + platform: 'android', + }); + + const failure = await port + .execute({ + command: { + kind: 'setPermissions', + source: { line: 3 }, + permissions: { all: 'deny', notifications: 'unset' }, + }, + appId: 'com.example.app', + generation: 0, + env: {}, + invalidateObservation() {}, + }) + .then( + () => { + throw new Error('expected setPermissions to fail'); + }, + (error: unknown) => error, + ); + expect(requests.map(({ positionals }) => positionals)).toEqual([ + ['permission', 'deny', 'all'], + ['permission', 'reset', 'notifications'], + ]); + assert.match(String((failure as Error).message), /No such service on this runtime/); + assert.deepEqual( + (failure as { details?: Record }).details?.appliedPermissionMutations, + ['deny all'], + ); + assert.equal( + (failure as { details?: Record }).details?.failedPermissionMutation, + 'reset notifications', + ); +}); + +test('launchApp applies permissions after clearing but before launch', async () => { + const requests: DaemonRequest[] = []; + const port = makePort(requests, 'android'); + + await port.execute({ + command: { + kind: 'launchApp', + source: { line: 3 }, + appId: 'com.example.app', + clearState: true, + permissions: { camera: 'allow' }, + }, + appId: 'com.example.app', + generation: 0, + env: {}, + invalidateObservation() {}, + }); + + expect(requests.map(({ command }) => command)).toEqual(['settings', 'settings', 'open']); + expect(requests[0]?.positionals).toEqual(['clear-app-state', 'com.example.app']); + expect(requests[1]?.positionals).toEqual(['permission', 'grant', 'camera']); + expect(requests[1]?.internal?.settingsAppBundleId).toBe('com.example.app'); + expect(requests[2]?.command).toBe('open'); + expect(requests[2]?.flags).not.toMatchObject({ clearAppState: true }); +}); + +test('launchApp without clearState applies permissions before launch', async () => { + const requests: DaemonRequest[] = []; + const port = makePort(requests, 'android'); + + await port.execute({ + command: { + kind: 'launchApp', + source: { line: 3 }, + appId: 'com.example.app', + permissions: { camera: 'allow' }, + }, + appId: 'com.example.app', + generation: 0, + env: {}, + invalidateObservation() {}, + }); + + expect(requests.map(({ command }) => command)).toEqual(['settings', 'open']); + expect(requests[0]?.positionals).toEqual(['permission', 'grant', 'camera']); + expect(requests[1]?.command).toBe('open'); +}); + +test('launchApp with rejected permissions launches nothing', async () => { + const requests: DaemonRequest[] = []; + const port = makePort(requests, 'android'); + + await expect( + port.execute({ + command: { + kind: 'launchApp', + source: { line: 3 }, + appId: 'com.example.app', + clearState: true, + permissions: { health: 'allow' }, + }, + appId: 'com.example.app', + generation: 0, + env: {}, + invalidateObservation() {}, + }), + ).rejects.toThrow(/health.*not supported on android/i); + expect(requests).toEqual([]); +}); + +test('setPermissions without an appId leaves targeting to the session app', async () => { + const requests: DaemonRequest[] = []; + const port = makePort(requests, 'ios'); + + await port.execute({ + command: { + kind: 'setPermissions', + source: { line: 2 }, + permissions: { location: 'always' }, + }, + generation: 0, + env: {}, + invalidateObservation() {}, + }); + + expect(requests.map(({ positionals }) => positionals)).toEqual([ + ['permission', 'grant', 'location-always'], + ]); + expect(requests[0]).not.toHaveProperty('internal'); +}); diff --git a/src/daemon/adapters/maestro/__tests__/daemon-runtime-public-operation.test.ts b/src/daemon/adapters/maestro/__tests__/daemon-runtime-public-operation.test.ts index 240246d228..b28106a407 100644 --- a/src/daemon/adapters/maestro/__tests__/daemon-runtime-public-operation.test.ts +++ b/src/daemon/adapters/maestro/__tests__/daemon-runtime-public-operation.test.ts @@ -183,6 +183,31 @@ describe('Maestro public operation projection', () => { flags: { noRecord: true }, }, }, + { + operation: { + kind: 'settingsPermission', + appId: 'com.example', + state: 'grant', + permission: 'camera', + }, + expected: { + command: 'settings', + positionals: ['permission', 'grant', 'camera'], + internal: { settingsAppBundleId: 'com.example' }, + }, + }, + { + operation: { + kind: 'settingsPermission', + state: 'grant', + permission: 'photos', + mode: 'limited', + }, + expected: { + command: 'settings', + positionals: ['permission', 'grant', 'photos', 'limited'], + }, + }, ])('projects $operation.kind', ({ operation, expected }) => { expect(projectMaestroPublicOperation(operation)).toEqual(expected); }); diff --git a/src/daemon/adapters/maestro/__tests__/set-permissions-mapping.test.ts b/src/daemon/adapters/maestro/__tests__/set-permissions-mapping.test.ts new file mode 100644 index 0000000000..316e79ff92 --- /dev/null +++ b/src/daemon/adapters/maestro/__tests__/set-permissions-mapping.test.ts @@ -0,0 +1,94 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'vitest'; +import { mapMaestroSetPermissions } from '../set-permissions-mapping.ts'; + +describe('mapMaestroSetPermissions', () => { + test('maps single permissions to grant/deny/reset', () => { + assert.deepEqual( + mapMaestroSetPermissions({ camera: 'allow', notifications: 'deny' }, 'android'), + [ + { state: 'grant', permission: 'camera' }, + { state: 'deny', permission: 'notifications' }, + ], + ); + assert.deepEqual(mapMaestroSetPermissions({ notifications: 'unset' }, 'android'), [ + { state: 'reset', permission: 'notifications' }, + ]); + }); + + test('all travels as one backend call with specifics overriding after it', () => { + assert.deepEqual(mapMaestroSetPermissions({ all: 'deny', notifications: 'unset' }, 'android'), [ + { state: 'deny', permission: 'all' }, + { state: 'reset', permission: 'notifications' }, + ]); + assert.deepEqual(mapMaestroSetPermissions({ all: 'allow' }, 'ios'), [ + { state: 'grant', permission: 'all' }, + ]); + assert.throws( + () => mapMaestroSetPermissions({ all: 'never' }, 'ios'), + /'allow', 'deny' or 'unset'/i, + ); + assert.throws( + () => mapMaestroSetPermissions({ all: 'limited' }, 'ios'), + /'allow', 'deny' or 'unset'/i, + ); + }); + + test('maps iOS granular values and the medialibrary alias', () => { + assert.deepEqual(mapMaestroSetPermissions({ location: 'always' }, 'ios'), [ + { state: 'grant', permission: 'location-always' }, + ]); + assert.deepEqual(mapMaestroSetPermissions({ location: 'inuse' }, 'ios'), [ + { state: 'grant', permission: 'location' }, + ]); + assert.deepEqual(mapMaestroSetPermissions({ location: 'never' }, 'ios'), [ + { state: 'deny', permission: 'location' }, + ]); + // never denies access while unset restores the prompt state. + assert.deepEqual(mapMaestroSetPermissions({ location: 'unset' }, 'ios'), [ + { state: 'reset', permission: 'location' }, + ]); + assert.deepEqual(mapMaestroSetPermissions({ photos: 'limited' }, 'ios'), [ + { state: 'grant', permission: 'photos', mode: 'limited' }, + ]); + assert.deepEqual(mapMaestroSetPermissions({ medialibrary: 'allow' }, 'ios'), [ + { state: 'grant', permission: 'media-library' }, + ]); + }); + + test('maps the extended Android names to backend targets', () => { + assert.deepEqual(mapMaestroSetPermissions({ bluetooth: 'allow' }, 'android'), [ + { state: 'grant', permission: 'bluetooth' }, + ]); + assert.deepEqual(mapMaestroSetPermissions({ location: 'deny' }, 'android'), [ + { state: 'deny', permission: 'location' }, + ]); + assert.deepEqual(mapMaestroSetPermissions({ sms: 'unset' }, 'android'), [ + { state: 'reset', permission: 'sms' }, + ]); + }); + + test('rejects unservable names, empty maps, and nonsense value combos', () => { + assert.throws( + () => mapMaestroSetPermissions({ health: 'allow' }, 'android'), + /health.*not supported on android/i, + ); + assert.throws( + () => mapMaestroSetPermissions({ speech: 'allow' }, 'ios'), + /speech.*not supported on ios/i, + ); + assert.throws( + () => + mapMaestroSetPermissions( + { 'android.permission.MANAGE_EXTERNAL_STORAGE': 'deny' }, + 'android', + ), + /not supported on android/i, + ); + assert.throws(() => mapMaestroSetPermissions({}, 'ios'), /at least one permission/i); + assert.throws( + () => mapMaestroSetPermissions({ camera: 'always' }, 'ios'), + /camera.*does not accept.*always/i, + ); + }); +}); diff --git a/src/daemon/adapters/maestro/daemon-runtime-port.ts b/src/daemon/adapters/maestro/daemon-runtime-port.ts index b7a66b490a..baa5b693d2 100644 --- a/src/daemon/adapters/maestro/daemon-runtime-port.ts +++ b/src/daemon/adapters/maestro/daemon-runtime-port.ts @@ -9,8 +9,13 @@ import { type MaestroRuntimePort, } from '@agent-device/maestro'; import { registerDiagnosticSensitiveValue } from '@agent-device/host-kit/diagnostics'; +import { AppError } from '@agent-device/kernel/errors'; import { stripUndefined } from '@agent-device/kernel/record'; import { executeRunScriptFile } from './run-script-execution.ts'; +import { + mapMaestroSetPermissions, + type MaestroPermissionMutation, +} from './set-permissions-mapping.ts'; import { waitForMaestroAnimationToEnd } from './wait-for-animation-to-end.ts'; import { observeTypedMaestroCondition, @@ -38,6 +43,10 @@ import { export type { CreateDaemonMaestroRuntimeOperationsOptions } from './daemon-runtime-port-support.ts'; +function describePermissionMutation(mutation: MaestroPermissionMutation): string { + return `${mutation.state} ${mutation.permission}${mutation.mode ? ` ${mutation.mode}` : ''}`; +} + function createDaemonMaestroRuntimeParts(options: CreateDaemonMaestroRuntimeOperationsOptions): { operations: MaestroRuntimeOperations; snapshots: MaestroSnapshotSource; @@ -77,6 +86,43 @@ function createDaemonMaestroRuntimeParts(options: CreateDaemonMaestroRuntimeOper context: MaestroRuntimeOperationContext, stability: 'none' | 'deferred' = 'none', ) => await withMutation(() => invoke(operation), context, stability); + // launchApp.permissions applies after state clearing but before launch, so + // startup code observes the requested state, and the map is validated before + // any mutation — a rejected map launches nothing. The split mirrors open + // --clearAppState (clear-app-state, then open without it); one nuance does + // not carry over: that flag also folds a runtime launch URL into the open on + // iOS, which Maestro flows never set, so the split is equivalent here. + const applyPermissionMutations = async ( + appId: string | undefined, + mutations: ReadonlyArray, + context: MaestroRuntimeOperationContext, + ): Promise => { + const applied: string[] = []; + for (const mutation of mutations) { + try { + await invokeMutation( + { + kind: 'settingsPermission', + ...(appId ? { appId } : {}), + state: mutation.state, + permission: mutation.permission, + ...(mutation.mode ? { mode: mutation.mode } : {}), + }, + context, + ); + } catch (error) { + if (error instanceof AppError) { + throw new AppError(error.code, error.message, { + ...error.details, + appliedPermissionMutations: applied, + failedPermissionMutation: describePermissionMutation(mutation), + }); + } + throw error; + } + applied.push(describePermissionMutation(mutation)); + } + }; const typeTextAndSettle = async ( text: string, context: MaestroRuntimeOperationContext, @@ -116,12 +162,19 @@ function createDaemonMaestroRuntimeParts(options: CreateDaemonMaestroRuntimeOper ]; const clearState = input.clearState === true; const relaunch = !clearState && input.stopApp !== false; + if (input.permissions) { + const mutations = mapMaestroSetPermissions(input.permissions, platform); + if (clearState) { + await invokeMutation({ kind: 'clearState', ...(appId ? { appId } : {}) }, context); + } + await applyPermissionMutations(appId, mutations, context); + } await invokeMutation( { kind: 'launchApp', ...(appId ? { appId } : {}), relaunch, - clearState, + clearState: clearState && !input.permissions, launchArgs, }, context, @@ -132,6 +185,13 @@ function createDaemonMaestroRuntimeParts(options: CreateDaemonMaestroRuntimeOper const appId = input.appId ?? context.appId; await invokeMutation({ kind: 'stopApp', ...(appId ? { appId } : {}) }, context); }, + setPermissions: async (input, context) => { + await applyPermissionMutations( + input.appId ?? context.appId, + mapMaestroSetPermissions(input.permissions, platform), + context, + ); + }, clearState: async (input, context) => { const appId = input.appId ?? context.appId; await invokeMutation({ kind: 'clearState', ...(appId ? { appId } : {}) }, context); diff --git a/src/daemon/adapters/maestro/daemon-runtime-public-operation.ts b/src/daemon/adapters/maestro/daemon-runtime-public-operation.ts index 0841180ff6..19ee25dd74 100644 --- a/src/daemon/adapters/maestro/daemon-runtime-public-operation.ts +++ b/src/daemon/adapters/maestro/daemon-runtime-public-operation.ts @@ -21,6 +21,13 @@ export type MaestroPublicOperation = } | { kind: 'stopApp'; appId?: string } | { kind: 'clearState'; appId?: string } + | { + kind: 'settingsPermission'; + appId?: string; + state: 'grant' | 'deny' | 'reset'; + permission: string; + mode?: 'full' | 'limited'; + } | { kind: 'openLink'; appId?: string; link: string; prewarmRunner: boolean } | { kind: 'typeText'; text: string } | { @@ -49,6 +56,7 @@ export function projectMaestroPublicOperation( if (operation.kind === 'clearState') return projectClearState(operation); if (isAppOperation(operation)) return projectAppOperation(operation); if (isCaptureOperation(operation)) return projectCaptureOperation(operation); + if (operation.kind === 'settingsPermission') return projectSettingsPermission(operation); return projectInputOperation(operation); } @@ -117,9 +125,27 @@ function projectOpenLink( }; } +function projectSettingsPermission( + operation: Extract, +): ProjectedMaestroPublicOperation { + return { + command: 'settings', + positionals: [ + 'permission', + operation.state, + operation.permission, + ...(operation.mode ? [operation.mode] : []), + ], + ...(operation.appId ? { internal: { settingsAppBundleId: operation.appId } } : {}), + }; +} + type MaestroInputOperation = Exclude< MaestroPublicOperation, - MaestroAppOperation | MaestroCaptureOperation | { kind: 'clearState' } + | MaestroAppOperation + | MaestroCaptureOperation + | { kind: 'settingsPermission' } + | { kind: 'clearState' } >; function projectInputOperation(operation: MaestroInputOperation): ProjectedMaestroPublicOperation { diff --git a/src/daemon/adapters/maestro/set-permissions-mapping.ts b/src/daemon/adapters/maestro/set-permissions-mapping.ts new file mode 100644 index 0000000000..3082f18d43 --- /dev/null +++ b/src/daemon/adapters/maestro/set-permissions-mapping.ts @@ -0,0 +1,147 @@ +import { AppError } from '@agent-device/kernel/errors'; +import { MAESTRO_PERMISSION_VALUES } from '@agent-device/maestro'; + +export type MaestroPermissionMutation = { + readonly state: 'grant' | 'deny' | 'reset'; + readonly permission: string; + readonly mode?: 'full' | 'limited'; +}; + +/** + * Canonical Maestro names each `settings permission` backend serves + * individually. `all` is not listed: it travels as one `settings permission` + * call and each backend resolves it (iOS `simctl privacy … all`, Android's + * declared-permission intersection). Names outside these lists (iOS + * speech/usertracking/homekit/health; Android custom ids) fail loudly below + * instead of being silently skipped. + */ +const EXPANDABLE_PERMISSIONS = { + android: [ + 'bluetooth', + 'calendar', + 'camera', + 'contacts', + 'location', + 'media-library', + 'microphone', + 'notifications', + 'phone', + 'photos', + 'sms', + 'storage', + ], + ios: [ + 'calendar', + 'camera', + 'contacts', + 'location', + 'media-library', + 'microphone', + 'motion', + 'notifications', + 'photos', + 'reminders', + 'siri', + ], +} as const; + +/** Per-platform hint for names the backends cannot serve yet. */ +const UNSUPPORTED_HINTS = { + android: + 'Supported: all, bluetooth, calendar, camera, contacts, location, media-library, microphone, notifications, phone, photos, sms, storage. Android custom permission ids are attempted through all, not individually.', + ios: 'Supported: all, calendar, camera, contacts, location, media-library, microphone, motion, notifications, photos, reminders, siri. Granular iOS values: location always|inuse|never, photos limited.', +} as const; + +/** Non-canonical spellings accepted alongside the lists above. */ +const PERMISSION_ALIASES: Readonly> = { + medialibrary: 'media-library', +}; + +function canonicalName(name: string): string { + const normalized = name.toLowerCase(); + return PERMISSION_ALIASES[normalized] ?? normalized; +} + +/** Plain values map 1:1 onto settings states; granular iOS values map per permission. */ +const PLAIN_VALUE_STATES = { allow: 'grant', deny: 'deny', unset: 'reset' } as const; + +const GRANULAR_MUTATIONS: Record> = { + location: { + always: { state: 'grant', permission: 'location-always' }, + inuse: { state: 'grant', permission: 'location' }, + // never denies access; unset resets to the prompt state. + never: { state: 'deny', permission: 'location' }, + }, + photos: { + limited: { state: 'grant', permission: 'photos', mode: 'limited' }, + }, +}; + +const GRANULAR_HINTS: Record = { + location: 'Use allow|deny|unset, or the iOS granular always|inuse|never.', + photos: 'Use allow|deny|unset, or the iOS granular limited.', +}; + +/** + * Expand a Maestro `setPermissions` map into ordered `settings permission` + * mutations. `all` travels as one backend call first so specific entries + * always override it regardless of authored order. Values arrive lowercased + * from the Maestro runtime layer; anything else is refused. + * The expansion is fully validated here, so callers must map before issuing + * any mutation — a rejected map changes nothing. + */ +export function mapMaestroSetPermissions( + permissions: Readonly>, + platform: 'ios' | 'android', +): MaestroPermissionMutation[] { + const entries = Object.entries(permissions); + if (entries.length === 0) { + throw new AppError('INVALID_ARGS', 'Maestro setPermissions requires at least one permission.'); + } + const mutations: MaestroPermissionMutation[] = []; + const specific = new Map(); + for (const [name, value] of entries) { + if (name.toLowerCase() === 'all') { + mutations.push(mapMaestroAll(value)); + } else { + specific.set(canonicalName(name), value); + } + } + for (const [name, value] of specific) { + mutations.push(mapMaestroPermission(name, value, platform)); + } + return mutations; +} + +/** `all` accepts only the plain values; granular ones name no single backend state. */ +function mapMaestroAll(value: string): MaestroPermissionMutation { + const state = PLAIN_VALUE_STATES[value as keyof typeof PLAIN_VALUE_STATES]; + if (!MAESTRO_PERMISSION_VALUES.has(value) || !state) { + throw new AppError( + 'INVALID_ARGS', + `Permission 'all' can be set to 'allow', 'deny' or 'unset', not '${value}'.`, + ); + } + return { state, permission: 'all' }; +} + +function mapMaestroPermission( + name: string, + value: string, + platform: 'ios' | 'android', +): MaestroPermissionMutation { + if (!new Set(EXPANDABLE_PERMISSIONS[platform]).has(name)) { + throw new AppError( + 'UNSUPPORTED_OPERATION', + `Maestro permission "${name}" is not supported on ${platform} yet.`, + { hint: UNSUPPORTED_HINTS[platform] }, + ); + } + const granular = GRANULAR_MUTATIONS[name]?.[value]; + if (granular) return granular; + const state = PLAIN_VALUE_STATES[value as keyof typeof PLAIN_VALUE_STATES]; + if (state) return { state, permission: name }; + throw new AppError('INVALID_ARGS', `Maestro permission "${name}" does not accept "${value}".`, { + hint: GRANULAR_HINTS[name] ?? 'Use allow|deny|unset.', + }); +} diff --git a/src/daemon/daemon-request.ts b/src/daemon/daemon-request.ts index 61b52354d1..f61f7ef584 100644 --- a/src/daemon/daemon-request.ts +++ b/src/daemon/daemon-request.ts @@ -118,6 +118,13 @@ type DaemonRequestInternal = { * spoof authored provenance. Same channel as `replayTargetGuard` above. */ replayPlanStep?: boolean; + /** + * Maestro `setPermissions` app targeting. The `settings permission` + * positionals carry no app slot, so the Maestro adapter threads an explicit + * appId here; the settings handler prefers it over the session app. + * Daemon-only like the other keys above — never accepted off the wire. + */ + settingsAppBundleId?: string; }; /** diff --git a/src/daemon/handlers/snapshot-settings.ts b/src/daemon/handlers/snapshot-settings.ts index 107e7a9d7b..f711db6e9e 100644 --- a/src/daemon/handlers/snapshot-settings.ts +++ b/src/daemon/handlers/snapshot-settings.ts @@ -172,7 +172,10 @@ export async function handleSettingsCommand( return errorResponse('INVALID_ARGS', getUnsupportedMacOsSettingMessage(setting)); } - const appBundleId = parsed.appBundleId ?? session?.appBundleId; + // Explicit positional wins; the Maestro adapter's daemon-internal + // settingsAppBundleId overrides the session app for cross-app targeting. + const appBundleId = + parsed.appBundleId ?? req.internal?.settingsAppBundleId ?? session?.appBundleId; if (setting === 'clear-app-state' && !appBundleId) { return errorResponse( 'INVALID_ARGS', diff --git a/website/docs/docs/replay-e2e.md b/website/docs/docs/replay-e2e.md index 03a52702de..4798f6731a 100644 --- a/website/docs/docs/replay-e2e.md +++ b/website/docs/docs/replay-e2e.md @@ -70,7 +70,7 @@ agent-device test ./maestro-flows --maestro --platform android --artifacts-dir . Supported subset: -- Flows: `launchApp`; `runFlow` file/inline with platform, visibility, and limited boolean conditions; `onFlowStart`/`onFlowComplete`; `repeat.times` and retry. +- Flows: `launchApp` (with `clearState`, `permissions`, and Apple-only launch arguments; `permissions` apply after state clearing but before launch, and a `launchApp` without `permissions` touches nothing — there is no silent `all: allow` default); `setPermissions` (mid-flow permission grants/denials/resets; `all` resolves in the backend — one simctl call on iOS, the declared permissions on Android — with specifics overriding after it; unservable names fail loudly instead of being skipped; `unset` fully resets and `location: never` denies); `runFlow` file/inline with platform, visibility, and limited boolean conditions; `onFlowStart`/`onFlowComplete`; `repeat.times` and retry. - Interactions: `tapOn`, `doubleTapOn`, `longPressOn`, `inputText` on the focused element, `eraseText`, `openLink`, `hideKeyboard`, basic `pressKey`, and `back`; selector targets poll until available and support recursive `index`, `childOf`, `above`, `below`, `leftOf`, `rightOf`, `containsChild`, `containsDescendants`, points, and `optional`; outer command labels are metadata, not target selectors. - Assertions and navigation: `assertVisible`, `assertNotVisible`, `assertTrue` (literal values and `${VAR}` lookups only; `""`, `"false"`, `"0"`, `"null"`, and `"undefined"` are falsy, everything else is truthy), `extendedWaitUntil`, `scroll`, `scrollUntilVisible`, absolute/percentage/target `swipe`, `takeScreenshot`, `waitForAnimationToEnd`, `clearState`, and `stopApp`. - Scripts: ordered `runScript` file/env scripts with `http.post`, `json`, and `output` variables; `evalScript` inline expressions run flow-scoped JavaScript and write `output.*` leaves for later steps.