From 8eaefa36e5cd142024457412b5eec8019205592a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 4 Oct 2026 09:52:22 +0200 Subject: [PATCH 1/3] feat(settings): let permission and iOS location target an explicit app `settings permission` and the iOS-simulator `settings location on|off` privacy change acted only on the app the session had open, so setting a permission before an app's first launch meant opening the app first just to bind it. `simctl privacy` and Android's `pm` need only the bundle id or package, so the requirement was the daemon never being handed one. The named app now rides the request's input payload the way a gesture payload does: `app` is no CLI flag key, and permission's positionals already carry the target and mode. The CLI reaches it through the `--app` option `doctor` already owns, and the client and MCP surfaces through their `app` field, which is now documented. `clear-app-state` keeps carrying its app positionally, unchanged. Where a mutation cannot consume an app at all the request is refused rather than silently dropped: Android's `location on|off` writes the device's `location_mode`, and a macOS permission is a TCC grant to the host process. `settingsAppScope` is that one table, and the refusal carries `setting_app_not_consumed` with `dispatched: no` so a driver branches on the reason and knows no device was touched. No permission is set on a device that never held the app installed differently: the owner resolves the id as it always has, and the Android rule that revoking a granted permission kills the app (#1796) is unchanged. --- .../command-registry/src/command-schema.ts | 9 + .../src/flag-definitions-target.ts | 3 +- packages/contracts/src/client-settings.ts | 20 +- packages/contracts/src/settings.test.ts | 79 ++++++ packages/contracts/src/settings.ts | 122 ++++++++- src/cli/parser/args.ts | 17 ++ src/cli/resolve-cli-options.test.ts | 37 +++ src/commands/capture/settings.test.ts | 69 +++++ src/commands/capture/settings.ts | 52 +++- .../__tests__/snapshot-handler.fixtures.ts | 2 +- .../snapshot-settings-handler.test.ts | 246 ++++++++++++++++++ src/daemon/handlers/snapshot-settings.ts | 66 ++++- website/docs/docs/commands.md | 4 +- 13 files changed, 707 insertions(+), 19 deletions(-) diff --git a/packages/command-registry/src/command-schema.ts b/packages/command-registry/src/command-schema.ts index 50ab6793eb..407aed2b35 100644 --- a/packages/command-registry/src/command-schema.ts +++ b/packages/command-registry/src/command-schema.ts @@ -22,6 +22,15 @@ export type CommandSchema = { */ flagsByAction?: Readonly>; supportedFlags?: readonly FlagKey[]; + /** + * Options this command consumes only when the caller typed them. Config, env, and remote-config + * defaults still fill the flag bag — every other reader of the key keeps its default — but the + * parser strips a value the command line never named before the input reader sees it, so an + * operator-wide default can never stand in for a per-invocation argument. Use it where the key + * selects the subject a mutation acts on (`settings --app`), because silently mutating a default + * target is worse than ignoring an option the caller never asked for. + */ + explicitOnlyFlags?: readonly FlagKey[]; /** * Replaces the generated synopsis grammar in `--help`, for shapes the generator cannot express. * The flag tail after it stays generated from `usageFlags`, so this string never restates the diff --git a/packages/command-registry/src/flag-definitions-target.ts b/packages/command-registry/src/flag-definitions-target.ts index d84aa1cc0e..7e91aec928 100644 --- a/packages/command-registry/src/flag-definitions-target.ts +++ b/packages/command-registry/src/flag-definitions-target.ts @@ -85,7 +85,8 @@ export const TARGET_FLAG_DEFINITIONS: readonly FlagDefinition[] = [ names: ['--app', '--target-app'], type: 'string', usageLabel: '--app ', - usageDescription: 'Doctor: verify an installed target app without opening a session', + usageDescription: + 'Target an app without opening it: doctor verifies an installed app by id or name; settings applies an app-scoped change (permission, iOS location) to a bundle id or package', projectConfig: false, recorded: false, }, diff --git a/packages/contracts/src/client-settings.ts b/packages/contracts/src/client-settings.ts index 4418820a9a..ff98b1e0c7 100644 --- a/packages/contracts/src/client-settings.ts +++ b/packages/contracts/src/client-settings.ts @@ -29,9 +29,20 @@ export type SettingsUpdateOptions = state: 'clear'; }) | (DeviceCommandBaseOptions & { - setting: 'wifi' | 'airplane' | 'location'; + setting: 'wifi' | 'airplane'; state: 'on' | 'off'; }) + /** + * On Apple simulators `on`/`off` grants or revokes the app's location permission, so this leg + * takes the same explicit `app` as `permission` and defaults to the session app. On Android the + * toggle writes the global `location_mode` and consumes no app, so naming one there is refused + * rather than dropped; `settingsAppScope` is the declaration. + */ + | (DeviceCommandBaseOptions & { + setting: 'location'; + state: 'on' | 'off'; + app?: string; + }) | (DeviceCommandBaseOptions & { setting: 'location'; state: 'set'; @@ -68,4 +79,11 @@ export type SettingsUpdateOptions = state: PermissionAction; permission: PermissionTarget; mode?: PermissionMode; + /** + * The app the permission changes, by bundle id or package name. Without it the app bound to + * the session is used; with it no app has to be running or open, because `simctl privacy` and + * Android's `pm` need only the id. macOS permissions are host-level TCC grants, so naming an + * app there is refused rather than dropped. + */ + app?: string; }); diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 13b8b5f7da..66add302a5 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -17,6 +17,9 @@ import { PERMISSION_ACTIONS, PERMISSION_MODES, readTextSizeCategory, + settingsAppNotConsumedRefusal, + settingsAppScope, + SETTINGS_APP_NOT_CONSUMED_REASON, SETTINGS_INVALID_ARGS_MESSAGE, SETTINGS_MACOS_PERMISSION_USAGE, SETTINGS_USAGE_OVERRIDE, @@ -331,3 +334,79 @@ describe('appearance vocabulary types', () => { >(); }); }); + +describe('settingsAppScope', () => { + test('a permission is app-scoped on every mobile target and host-level on macOS', () => { + expect(settingsAppScope('apple', 'permission', 'grant')).toBe('app-scoped'); + expect(settingsAppScope('mobile', 'permission', 'deny')).toBe('app-scoped'); + expect(settingsAppScope('macos-host', 'permission', 'grant')).toBe('device-level'); + }); + + test('clear-app-state is app-scoped wherever it is served', () => { + expect(settingsAppScope('apple', 'clear-app-state', 'clear')).toBe('app-scoped'); + expect(settingsAppScope('mobile', 'clear-app-state', 'clear')).toBe('app-scoped'); + expect(settingsAppScope('macos-host', 'clear-app-state', 'clear')).toBe('unknown'); + }); + + test('an on/off location is app-scoped only where it maps to a privacy grant', () => { + expect(settingsAppScope('apple', 'location', 'on')).toBe('app-scoped'); + expect(settingsAppScope('apple', 'location', 'off')).toBe('app-scoped'); + expect(settingsAppScope('mobile', 'location', 'on')).toBe('device-level'); + expect(settingsAppScope('mobile', 'location', 'off')).toBe('device-level'); + }); + + test('every state parseSettingState accepts classifies like its on/off spelling', () => { + // The owners toggle on `parseSettingState`, which also accepts true/1/false/0. A spelling the + // parser takes must settle the app scope the same way, or `location 1 --app X` silently drops X. + for (const state of ['on', 'true', 'TRUE', '1']) { + expect(parseSettingState(state)).toBe(true); + expect(settingsAppScope('apple', 'location', state)).toBe('app-scoped'); + expect(settingsAppScope('mobile', 'location', state)).toBe('device-level'); + } + for (const state of ['off', 'false', 'FALSE', '0']) { + expect(parseSettingState(state)).toBe(false); + expect(settingsAppScope('apple', 'location', state)).toBe('app-scoped'); + expect(settingsAppScope('mobile', 'location', state)).toBe('device-level'); + } + // The table trims before consulting the grammar, so a padded spelling still classifies. + expect(settingsAppScope('apple', 'location', ' on ')).toBe('app-scoped'); + }); + + test('a location set moves the device itself for everyone', () => { + expect(settingsAppScope('apple', 'location', 'set')).toBe('device-level'); + expect(settingsAppScope('mobile', 'location', 'set')).toBe('device-level'); + }); + + test('a combination the table does not settle stays unknown, not a verdict', () => { + expect(settingsAppScope('macos-host', 'location', 'on')).toBe('unknown'); + expect(settingsAppScope('mobile', 'wifi', 'on')).toBe('unknown'); + expect(settingsAppScope('apple', 'animations', 'on')).toBe('unknown'); + expect(settingsAppScope('mobile', 'location', 'sideways')).toBe('unknown'); + // A mutation with no state is not a write the surface admits, so the table names no scope for it. + expect(settingsAppScope('apple', 'location', undefined)).toBe('unknown'); + }); + + test('the scope keys on the setting and state vocabulary, not its padding or case', () => { + expect(settingsAppScope('apple', ' PERMISSION ', 'GRANT')).toBe('app-scoped'); + expect(settingsAppScope('mobile', 'Location', ' On ')).toBe('device-level'); + }); +}); + +describe('settingsAppNotConsumedRefusal', () => { + test('refuses by naming the mutation and quoting the app back', () => { + const refusal = settingsAppNotConsumedRefusal('location', 'on', 'com.example.app'); + expect(refusal.code).toBe('INVALID_ARGS'); + expect(refusal.message).toContain('settings location on'); + expect(refusal.message).toContain('com.example.app'); + expect(refusal.details.reason).toBe(SETTINGS_APP_NOT_CONSUMED_REASON); + expect(refusal.details.dispatched).toBe('no'); + expect(refusal.details.app).toBe('com.example.app'); + expect(refusal.hint).toContain('settings permission grant location --app com.example.app'); + }); + + test('a stateless mutation is named by its setting alone', () => { + expect( + settingsAppNotConsumedRefusal('permission', undefined, 'com.example.app').message, + ).toContain('settings permission applies to the target itself'); + }); +}); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 10784b97d1..267e9c5949 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -151,6 +151,112 @@ export type SettingOptions = { longitude?: number; }; +/** + * Whether naming an app for one mutation can mean anything on one target. + * + * `app-scoped` is the shape the public `app` field exists for: the change lands on that bundle id + * or package (`simctl privacy`, Android's `pm`). `device-level` is a mutation the same command + * serves for the whole device: Android's on/off `location` writes the global `location_mode`, and + * the macOS host's permissions are TCC grants to the host process, so neither can consume an app. + * `unknown` covers every combination this table does not settle — a setting with no app shape and no + * device-wide write of its own (`wifi` on the macOS host) among them — and is deliberately not a + * verdict: support belongs to the owner's runtime fact, which answers a setting it does not serve + * (`permission` on HarmonyOS) with its own refusal rather than with a complaint about an argument it + * never read. + */ +export type SettingsAppScope = 'app-scoped' | 'device-level' | 'unknown'; + +/** + * What kind of target a `settings` mutation runs on, as far as consuming an app is concerned: + * the Apple family, the macOS host, or the non-Apple targets that share the mobile app shape + * (Android, HarmonyOS, Vega, Linux, web). The daemon derives it from a device with the kernel's + * own predicates; being app-scoped here is a claim about the shape of the change, never about + * support — an owner that serves no such setting answers with its own refusal. + */ +export type SettingsTargetFamily = 'apple' | 'mobile' | 'macos-host'; + +/** + * The one table deciding whether an app named on a `settings` request is consumed. It is keyed on + * the target family, a fact the daemon derives from the device before binding a runtime, and it + * stays honest by settling only the combinations whose owner behavior is already fixed elsewhere: + * `clear-app-state` is app-scoped wherever it is served (`isMacOsSettingSupported` keeps macOS out + * of that claim), `permission` is app-scoped on every non-Apple mobile target and on Apple and + * host-level on macOS, and on/off `location` is app-scoped only on Apple, where it maps to a + * privacy grant — while `location set` moves the device's own location for everyone. + */ +export function settingsAppScope( + family: SettingsTargetFamily, + setting: string, + state: string | undefined, +): SettingsAppScope { + const normalizedSetting = setting.trim().toLowerCase(); + if (normalizedSetting === 'clear-app-state') { + return family === 'macos-host' ? 'unknown' : 'app-scoped'; + } + if (normalizedSetting === 'permission') { + return family === 'macos-host' ? 'device-level' : 'app-scoped'; + } + if (normalizedSetting === 'location') return locationAppScope(family, state); + return 'unknown'; +} + +/** + * One on/off `location` is a privacy grant only on Apple, where it maps to `simctl privacy`; on + * the other mobile targets it writes the global `location_mode`, and `set` moves the device's own + * location for everyone. A state this ladder does not name settles nothing, and the macOS host, + * whose location surface `isMacOsSettingSupported` keeps out of the settings vocabulary, is no + * verdict either. + */ +function locationAppScope( + family: SettingsTargetFamily, + state: string | undefined, +): SettingsAppScope { + const normalizedState = state?.trim().toLowerCase(); + if (normalizedState === 'set') return 'device-level'; + if (readSettingState(normalizedState) === undefined) return 'unknown'; + if (family === 'apple') return 'app-scoped'; + if (family === 'mobile') return 'device-level'; + return 'unknown'; +} + +/** + * The reason a request is told its app names nothing: what a caller does about it — drop the app or + * move to a target whose mutation is app-scoped — is the reason's meaning, so a driver can branch + * on `error.details.reason` instead of the prose. Paired with `dispatched: no`, because the + * refusal runs before a device is touched. + */ +export const SETTINGS_APP_NOT_CONSUMED_REASON = 'setting_app_not_consumed'; + +/** + * The refusal a mutation answers with when its target consumes no app: the code, sentence, typed + * details, and hint as data, so the daemon can answer with it through its own response builder + * rather than by unwrapping an error the CLI would then re-normalize. The `app` that named nothing + * stays in `details` — the caller asked about that bundle id and the answer should quote it back. + */ +export function settingsAppNotConsumedRefusal( + setting: string, + state: string | undefined, + app: string, +): { + code: 'INVALID_ARGS'; + message: string; + details: Record; + hint: string; +} { + const described = state === undefined ? setting : `${setting} ${state}`; + return { + code: 'INVALID_ARGS', + message: `settings ${described} applies to the target itself, not to an app: ${app} names nothing it can grant or revoke.`, + details: { + reason: SETTINGS_APP_NOT_CONSUMED_REASON, + dispatched: 'no', + setting: described, + app, + }, + hint: `Drop the --app option and run \`settings ${described}\`, or aim the app at an app-scoped setting such as \`settings permission grant location --app ${app}\`.`, + }; +} + const SETTINGS_WIFI_USAGE = ' '; const SETTINGS_LOCATION_SET_USAGE = 'location set '; const SETTINGS_ANIMATIONS_USAGE = 'animations '; @@ -299,10 +405,22 @@ export function parseAppearanceAction(state: string): AppearanceAction { ); } -/** The boolean a `settings ` positional spells, in any casing. */ -export function parseSettingState(state: string): boolean { +/** + * The boolean a `settings ` positional spells, or `undefined` when it spells none. + * This is the grammar `parseSettingState` refuses on and the app-scope table classifies with, so a + * state the owners accept can never settle differently about an app than it settles about the toggle. + */ +function readSettingState(state: string | undefined): boolean | undefined { + if (state === undefined) return undefined; const normalized = state.toLowerCase(); if (SETTING_STATE_ON.includes(normalized)) return true; if (SETTING_STATE_OFF.includes(normalized)) return false; + return undefined; +} + +/** The boolean a `settings ` positional spells, in any casing. */ +export function parseSettingState(state: string): boolean { + const parsed = readSettingState(state); + if (parsed !== undefined) return parsed; throw new AppError('INVALID_ARGS', `Invalid setting state: ${state}`); } diff --git a/src/cli/parser/args.ts b/src/cli/parser/args.ts index cf7cc19230..f66570c3b7 100644 --- a/src/cli/parser/args.ts +++ b/src/cli/parser/args.ts @@ -215,6 +215,7 @@ export function finalizeParsedArgs( delete (flags as Record)[key]; } } + stripFlagsTheCommandTreatsAsExplicitOnly(parsed, flags); assertNoConflictingBackModeFlags(parsed); applyCommandDefaults(parsed.command, flags); const normalized = normalizeParsedCommandAliases({ @@ -385,6 +386,22 @@ function normalizeParsedCommandAliases(parsed: ParsedArgs): ParsedArgs { return parsed; } +/** + * Drops a declared explicit-only option whose value arrived only from config, env, or remote-config + * defaults. `providedFlags` records what the command line typed, so a key absent from it is a default + * the command must not consume: `settings permission grant camera` mutates the session app even when + * AGENT_DEVICE_TARGET_APP names a different one. A typed value stays untouched. + */ +function stripFlagsTheCommandTreatsAsExplicitOnly(parsed: RawParsedArgs, flags: CliFlags): void { + const explicitOnly = getCommandSchema(parsed.command)?.explicitOnlyFlags; + if (explicitOnly === undefined) return; + const typedKeys = new Set(parsed.providedFlags.map((entry) => entry.key)); + for (const key of explicitOnly) { + if (typedKeys.has(key)) continue; + delete (flags as Record)[key]; + } +} + /** * The typed options the selected action of a command cannot read. * diff --git a/src/cli/resolve-cli-options.test.ts b/src/cli/resolve-cli-options.test.ts index 31e6d293b8..bd16fe7657 100644 --- a/src/cli/resolve-cli-options.test.ts +++ b/src/cli/resolve-cli-options.test.ts @@ -38,3 +38,40 @@ test('a frame rate the caller typed stays a typed flag', () => { ['fps'], ); }); + +// #3179: settings consumes --app only when this invocation typed it. A mutation that silently +// landed on AGENT_DEVICE_TARGET_APP would change permissions for an app the caller never named, +// while `doctor --app` (and its env default) still reads a configured default app by design. +test('an app from the environment never reaches a settings mutation', () => { + const parsed = resolveCliOptions(['settings', 'permission', 'grant', 'camera'], { + cwd: process.cwd(), + env: isolatedEnv({ AGENT_DEVICE_TARGET_APP: 'com.example.configured' }), + }); + + assert.equal(parsed.flags.targetApp, undefined); + assert.deepEqual( + parsed.providedFlags.map((entry) => entry.key), + [], + ); +}); + +test('a typed --app still reaches a settings mutation', () => { + const parsed = resolveCliOptions( + ['settings', 'permission', 'grant', 'camera', '--app', 'com.example.typed'], + { + cwd: process.cwd(), + env: isolatedEnv({ AGENT_DEVICE_TARGET_APP: 'com.example.configured' }), + }, + ); + + assert.equal(parsed.flags.targetApp, 'com.example.typed'); +}); + +test('the same env default keeps filling doctor, which reads a configured app without a mutation', () => { + const parsed = resolveCliOptions(['doctor'], { + cwd: process.cwd(), + env: isolatedEnv({ AGENT_DEVICE_TARGET_APP: 'com.example.configured' }), + }); + + assert.equal(parsed.flags.targetApp, 'com.example.configured'); +}); diff --git a/src/commands/capture/settings.test.ts b/src/commands/capture/settings.test.ts index 5979b1183c..95757f0933 100644 --- a/src/commands/capture/settings.test.ts +++ b/src/commands/capture/settings.test.ts @@ -156,6 +156,75 @@ describe('settings CLI permission vocabulary', () => { positionals: ['permission', 'deny', 'screen-recording', 'full'], }); }); + + // #3179: an app-scoped change can name an app the session never opened. `app` is no CLI flag key, + // so the request carries it as input the way a gesture payload does. + test('carries --app on a permission grant to the daemon request input', () => { + const input = settingsCliReader(['permission', 'grant', 'camera'], { + targetApp: 'com.example.app', + } as CliFlags); + expect(input).toMatchObject({ app: 'com.example.app' }); + expect(settingsDaemonWriter(input)).toMatchObject({ + command: 'settings', + positionals: ['permission', 'grant', 'camera'], + input: { app: 'com.example.app' }, + }); + }); + + test('carries an app on an iOS location toggle to the daemon request input', () => { + const input = settingsCliReader(['location', 'on'], { + targetApp: 'com.example.app', + } as CliFlags); + expect(settingsDaemonWriter(input)).toMatchObject({ + positionals: ['location', 'on'], + input: { app: 'com.example.app' }, + }); + }); + + test('keeps a location set device-wide and drops the app it never reads', () => { + const input = settingsCliReader(['location', 'set', '37.7', '-122.4'], { + targetApp: 'com.example.app', + } as CliFlags); + expect(settingsDaemonWriter(input)).toMatchObject({ + positionals: ['location', 'set', '37.7', '-122.4'], + }); + expect(settingsDaemonWriter(input).input).toBeUndefined(); + }); + + // The flag bag carries a configured default app the parser strips for settings; the reader still + // gets exercised with one present, so the clear-app-state branch cannot regress into letting + // `--app` redirect this destructive mutation away from the positional id. + test('keeps a clear-app-state app positional and out of the request input', () => { + const input = settingsCliReader(['clear-app-state', 'com.example.app'], { + targetApp: 'com.example.other', + } as CliFlags); + const writer = settingsDaemonWriter(input); + expect(writer.positionals).toEqual(['clear-app-state', 'com.example.app']); + expect(writer.input).toBeUndefined(); + // The app the input carries is the positional, never the flag bag's default. + expect(input).toMatchObject({ app: 'com.example.app' }); + }); + + test('omits the request input when no app is named', () => { + expect( + settingsDaemonWriter(settingsCliReader(['animations', 'off'], flags())).input, + ).toBeUndefined(); + }); + + test('accepts --app for its app-scoped settings', () => { + expect(settingsCommandFacet.cliSchema.allowedFlags).toEqual(['targetApp']); + }); + + test('carries an app named through the client input to the daemon request input', () => { + expect( + settingsDaemonWriter({ + setting: 'permission', + state: 'grant', + permission: 'camera', + app: 'com.example.app', + }), + ).toMatchObject({ input: { app: 'com.example.app' } }); + }); }); describe('settings CLI text-size', () => { diff --git a/src/commands/capture/settings.ts b/src/commands/capture/settings.ts index 1fe2997d3b..3b99f7e8a2 100644 --- a/src/commands/capture/settings.ts +++ b/src/commands/capture/settings.ts @@ -15,11 +15,12 @@ import type { CommandSchemaOverride } from '@agent-device/command-registry/comma import type { CliFlags } from '@agent-device/contracts/command'; import { AppError } from '@agent-device/kernel/errors'; import { readLocationCoordinate } from '@agent-device/kernel/location-coordinates'; +import { compactRecord } from '../input-readers.ts'; import { enumField, numberField, requiredField, stringField } from '../command-input.ts'; import { - direct, isOneOf, optionalString, + request, selectionOptionsFromFlags, setOf, } from '../cli-grammar/common.ts'; @@ -41,7 +42,9 @@ const settingsCommandMetadata = defineFieldCommandMetadata( // value the target holds. Every other setting still requires one, which the command's own parse // and the daemon enforce per setting rather than the shared field map. state: stringField(), - app: stringField(), + app: stringField( + 'App the change targets, by bundle id or package name: the app `clear-app-state` clears, or the app a `permission` change (or an iOS-simulator `location on|off` privacy change) lands on. An app named here needs no session and need not be running (Android `deny|reset` of a held permission does kill a running one); without it those changes use the session app, and a target whose change is device-wide (Android location toggle, macOS permissions) refuses one. The CLI consumes it only when this invocation passes it: an `AGENT_DEVICE_TARGET_APP` or config `targetApp` never retargets a settings mutation.', + ), latitude: numberField(), longitude: numberField(), permission: stringField(), @@ -53,20 +56,53 @@ const settingsCliSchema = { usageOverride: SETTINGS_USAGE_OVERRIDE, listUsageOverride: 'settings [area] [options]', positionalArgs: ['setting', 'state?', 'target?', 'mode?'], + // `--app` names the app an app-scoped change lands on (`settings permission grant camera --app + // com.example.app`), shared with `doctor`. It rides the request's input payload, not a positional, + // because the setting's own positionals already carry the permission target and mode. + allowedFlags: ['targetApp'], + // A settings mutation changes state on the app it resolves, so the app must come only from this + // invocation: `doctor` reads `--app` to check someone's default app, and the same env or config + // key must never redirect a `grant` or a destructive `clear-app-state` away from the session app. + explicitOnlyFlags: ['targetApp'], } as const satisfies CommandSchemaOverride; -export const settingsCliReader: CliReader = (positionals, flags) => - readSettingsOptionsFromPositionals(positionals, flags); +export const settingsCliReader: CliReader = (positionals, flags) => { + const options = readSettingsOptionsFromPositionals(positionals, flags); + // `clear-app-state` already names its app positionally, so `--app` fills the slot only for the + // settings whose app has no positional. Whether a mutation can consume an app at all is the + // daemon's scope table to decide once it knows the resolved target. + return options.setting === 'clear-app-state' || flags.targetApp === undefined + ? options + : { ...options, app: flags.targetApp }; +}; -export const settingsDaemonWriter: DaemonWriter = direct(PUBLIC_COMMANDS.settings, (input) => - settingsPositionals(input as SettingsUpdateOptions), -); +export const settingsDaemonWriter: DaemonWriter = (input) => { + const options = input as SettingsUpdateOptions; + // The app-scoped `app` has no positional of its own and `app` is no CLI flag key, so it crosses + // the daemon boundary as request input the way a gesture payload does: read back by the handler, + // never reconstructed from the command line. `clear-app-state` keeps carrying its app + // positionally, as it always has. + const settingsInput = compactRecord({ app: settingsInputApp(options) }); + return request( + PUBLIC_COMMANDS.settings, + settingsPositionals(options), + input, + Object.keys(settingsInput).length === 0 ? undefined : settingsInput, + ); +}; + +/** The settings whose `app` reaches the daemon through request input rather than a positional. */ +function settingsInputApp(input: SettingsUpdateOptions): string | undefined { + if (input.setting === 'permission') return input.app; + if (input.setting === 'location' && input.state !== 'set') return input.app; + return undefined; +} export const settingsCommandFacet = defineCommandFacet({ name: SETTINGS_COMMAND_NAME, text: { summary: 'Change OS settings and app permissions', - cliDetail: `macOS supports only settings appearance and settings ${SETTINGS_MACOS_PERMISSION_USAGE}; wifi|airplane|location|animations|text-size remain unsupported on macOS. Mobile permission actions use the active session app. On Android, deny|reset of a permission the app currently holds kills a running app; the response reports priorGrantState (granted|not_granted|unknown) and warns for granted and unknown, with open --relaunch to restore it. Permission changes require a resolvable foreground user and fail without mutating if adb cannot report one. Android settings airplane on|off is applied by the connectivity service (Android 11+) and reports the airplaneMode that service holds; older builds fail without changing device state. settings reset-keychain clear is iOS-simulator-only and resets the whole simulator keychain, not just the selected app: simctl exposes no per-app keychain reset, so every app on that simulator loses its keychain-backed credentials (e.g. Firebase auth). clear-app-state does not touch the keychain, so a full fresh-install reset needs both; relaunch the app afterward to observe the signed-out state. settings text-size reads the preferred text size the target holds and settings text-size applies one, on iPhone and iPad simulators (simctl content size) and on Android targets (system font_scale); tvOS and visionOS simulators, physical Apple devices, and the macOS host refuse it. Android has no category ladder of its own, so the read names the nearest rung and reports the exact multiplier as platformValue; an already-running app adopts a changed size at its next configuration change, so relaunch the app under test to observe it.`, + cliDetail: `macOS supports only settings appearance and settings ${SETTINGS_MACOS_PERMISSION_USAGE}; wifi|airplane|location|animations|text-size remain unsupported on macOS. Mobile permission actions default to the active session app; pass --app (or the app input) to aim a permission change, or an iOS-simulator location on|off, at an installed app no session has opened; no app needs to be running, and the CLI consumes the app only when this invocation names it, never from AGENT_DEVICE_TARGET_APP or config targetApp. A device-wide change refuses an app: the Android location toggle writes the device's location_mode, and a macOS permission is a host-level TCC grant. On Android, deny|reset of a permission the app currently holds kills a running app; the response reports priorGrantState (granted|not_granted|unknown) and warns for granted and unknown, with open --relaunch to restore it. Permission changes require a resolvable foreground user and fail without mutating if adb cannot report one. Android settings airplane on|off is applied by the connectivity service (Android 11+) and reports the airplaneMode that service holds; older builds fail without changing device state. settings reset-keychain clear is iOS-simulator-only and resets the whole simulator keychain, not just the selected app: simctl exposes no per-app keychain reset, so every app on that simulator loses its keychain-backed credentials (e.g. Firebase auth). clear-app-state does not touch the keychain, so a full fresh-install reset needs both; relaunch the app afterward to observe the signed-out state. settings text-size reads the preferred text size the target holds and settings text-size applies one, on iPhone and iPad simulators (simctl content size) and on Android targets (system font_scale); tvOS and visionOS simulators, physical Apple devices, and the macOS host refuse it. Android has no category ladder of its own, so the read names the nearest rung and reports the exact multiplier as platformValue; an already-running app adopts a changed size at its next configuration change, so relaunch the app under test to observe it.`, }, metadata: settingsCommandMetadata, run: (client, input) => client.settings.update(input as SettingsUpdateOptions), diff --git a/src/daemon/handlers/__tests__/snapshot-handler.fixtures.ts b/src/daemon/handlers/__tests__/snapshot-handler.fixtures.ts index 205feaf5f5..121b5f1a34 100644 --- a/src/daemon/handlers/__tests__/snapshot-handler.fixtures.ts +++ b/src/daemon/handlers/__tests__/snapshot-handler.fixtures.ts @@ -92,7 +92,7 @@ export const providerIosDevice: SessionState['device'] = { export function snapshotRequest( sessionName: string, command: DaemonRequest['command'], - options: Partial> = {}, + options: Partial> = {}, ): DaemonRequest { return { token: 't', session: sessionName, command, positionals: [], flags: {}, ...options }; } diff --git a/src/daemon/handlers/__tests__/snapshot-settings-handler.test.ts b/src/daemon/handlers/__tests__/snapshot-settings-handler.test.ts index e81fa71bb5..74cef3a60a 100644 --- a/src/daemon/handlers/__tests__/snapshot-settings-handler.test.ts +++ b/src/daemon/handlers/__tests__/snapshot-settings-handler.test.ts @@ -18,6 +18,7 @@ import { snapshotRuntimeFixture, } from '../../__tests__/snapshot-runtime-fixture.ts'; import { + androidDevice, iosSimulatorDevice, macOsDevice, makeSession, @@ -376,3 +377,248 @@ test('settings on macOS rejects wifi before dispatch with explicit subset guidan ); } }); + +// #3179: an app-scoped change can name an app the session never opened. The app rides the request +// input; the owner receives it as the resolved app id whether or not a session is bound. +test('settings permission grant targets an explicit app with no session app bound', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'ios-permission-explicit-app'; + sessionStore.publish(sessionName, makeSession(sessionName, iosSimulatorDevice)); + + const response = await handleSnapshotCommands({ + req: snapshotRequest(sessionName, 'settings', { + positionals: ['permission', 'grant', 'camera'], + input: { app: 'com.example.app' }, + }), + sessionName, + logPath: '/tmp/daemon.log', + sessionStore, + }); + + expect(response?.ok).toBe(true); + expect(fixtureSettingsMutations.at(-1)).toMatchObject({ + setting: 'permission', + state: 'grant', + appBundleId: 'com.example.app', + options: { permissionTarget: 'camera' }, + }); +}); + +test('settings permission grant keeps the session app when no app is named', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'ios-permission-session-app'; + sessionStore.publish( + sessionName, + makeSession(sessionName, iosSimulatorDevice, { + appBundleId: 'com.session.app', + }), + ); + + const response = await handleSnapshotCommands({ + req: snapshotRequest(sessionName, 'settings', { + positionals: ['permission', 'grant', 'camera'], + }), + sessionName, + logPath: '/tmp/daemon.log', + sessionStore, + }); + + expect(response?.ok).toBe(true); + expect(fixtureSettingsMutations.at(-1)).toMatchObject({ appBundleId: 'com.session.app' }); +}); + +test('settings permission grant prefers an explicit app over the session app', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'ios-permission-explicit-over-session'; + sessionStore.publish( + sessionName, + makeSession(sessionName, iosSimulatorDevice, { + appBundleId: 'com.session.app', + }), + ); + + const response = await handleSnapshotCommands({ + req: snapshotRequest(sessionName, 'settings', { + positionals: ['permission', 'grant', 'camera'], + input: { app: 'com.other.app' }, + }), + sessionName, + logPath: '/tmp/daemon.log', + sessionStore, + }); + + expect(response?.ok).toBe(true); + expect(fixtureSettingsMutations.at(-1)).toMatchObject({ appBundleId: 'com.other.app' }); +}); + +test('settings location on targets an explicit app on an iOS simulator', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'ios-location-explicit-app'; + sessionStore.publish(sessionName, makeSession(sessionName, iosSimulatorDevice)); + + const response = await handleSnapshotCommands({ + req: snapshotRequest(sessionName, 'settings', { + positionals: ['location', 'on'], + input: { app: 'com.example.app' }, + }), + sessionName, + logPath: '/tmp/daemon.log', + sessionStore, + }); + + expect(response?.ok).toBe(true); + expect(fixtureSettingsMutations.at(-1)).toMatchObject({ + setting: 'location', + state: 'on', + appBundleId: 'com.example.app', + }); +}); + +test('settings Android permission grant targets an explicit app with no session app', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'android-permission-explicit-app'; + sessionStore.publish(sessionName, makeSession(sessionName, androidDevice)); + + const response = await handleSnapshotCommands({ + req: snapshotRequest(sessionName, 'settings', { + positionals: ['permission', 'grant', 'camera'], + input: { app: 'com.example.app' }, + }), + sessionName, + logPath: '/tmp/daemon.log', + sessionStore, + }); + + expect(response?.ok).toBe(true); + expect(fixtureSettingsMutations.at(-1)).toMatchObject({ + setting: 'permission', + state: 'grant', + appBundleId: 'com.example.app', + }); +}); + +// Android's on/off location writes the device-wide location_mode, so a named app names nothing it +// can grant: the refusal lands before the owner is reached and before the session ref frame is spent. +test('settings Android location on refuses a named app before reaching the owner', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'android-location-app-refused'; + sessionStore.publish(sessionName, makeSession(sessionName, androidDevice)); + + const response = await handleSnapshotCommands({ + req: snapshotRequest(sessionName, 'settings', { + positionals: ['location', 'on'], + input: { app: 'com.example.app' }, + }), + sessionName, + logPath: '/tmp/daemon.log', + sessionStore, + }); + + expect(response?.ok).toBe(false); + expect(fixtureSettingsMutations).toHaveLength(0); + if (response && !response.ok) { + expect(response.error.code).toBe('INVALID_ARGS'); + expect(response.error.message).toMatch(/location on applies to the target itself/); + expect(response.error.details?.reason).toBe('setting_app_not_consumed'); + expect(response.error.details?.dispatched).toBe('no'); + } +}); + +// A boolean alias is the same toggle to the owner's parser, so it must carry the app the same way: +// classifying `1` as unknown used to grant location to the session app while reporting success. +test('settings iOS location 1 targets an explicit app over the session app', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'ios-location-alias-explicit-app'; + sessionStore.publish( + sessionName, + makeSession(sessionName, iosSimulatorDevice, { + appBundleId: 'com.session.app', + }), + ); + + const response = await handleSnapshotCommands({ + req: snapshotRequest(sessionName, 'settings', { + positionals: ['location', '1'], + input: { app: 'com.example.app' }, + }), + sessionName, + logPath: '/tmp/daemon.log', + sessionStore, + }); + + expect(response?.ok).toBe(true); + expect(fixtureSettingsMutations.at(-1)).toMatchObject({ + setting: 'location', + state: '1', + appBundleId: 'com.example.app', + }); +}); + +test('settings Android location true refuses a named app before reaching the owner', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'android-location-alias-refused'; + sessionStore.publish(sessionName, makeSession(sessionName, androidDevice)); + + const response = await handleSnapshotCommands({ + req: snapshotRequest(sessionName, 'settings', { + positionals: ['location', 'true'], + input: { app: 'com.example.app' }, + }), + sessionName, + logPath: '/tmp/daemon.log', + sessionStore, + }); + + expect(response?.ok).toBe(false); + expect(fixtureSettingsMutations).toHaveLength(0); + if (response && !response.ok) { + expect(response.error.details?.reason).toBe('setting_app_not_consumed'); + expect(response.error.details?.app).toBe('com.example.app'); + } +}); + +test('settings Android location 0 refuses a named app before reaching the owner', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'android-location-zero-refused'; + sessionStore.publish(sessionName, makeSession(sessionName, androidDevice)); + + const response = await handleSnapshotCommands({ + req: snapshotRequest(sessionName, 'settings', { + positionals: ['location', '0'], + input: { app: 'com.example.app' }, + }), + sessionName, + logPath: '/tmp/daemon.log', + sessionStore, + }); + + expect(response?.ok).toBe(false); + expect(fixtureSettingsMutations).toHaveLength(0); + if (response && !response.ok) { + expect(response.error.details?.reason).toBe('setting_app_not_consumed'); + } +}); + +// A macOS permission is a host-level TCC grant, so naming an app is refused rather than dropped. +test('settings macOS permission grant refuses a named app before reaching the owner', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'macos-permission-app-refused'; + sessionStore.publish(sessionName, makeSession(sessionName, macOsDevice)); + + const response = await handleSnapshotCommands({ + req: snapshotRequest(sessionName, 'settings', { + positionals: ['permission', 'grant', 'screen-recording'], + input: { app: 'com.example.app' }, + }), + sessionName, + logPath: '/tmp/daemon.log', + sessionStore, + }); + + expect(response?.ok).toBe(false); + expect(fixtureSettingsMutations).toHaveLength(0); + if (response && !response.ok) { + expect(response.error.code).toBe('INVALID_ARGS'); + expect(response.error.details?.reason).toBe('setting_app_not_consumed'); + } +}); diff --git a/src/daemon/handlers/snapshot-settings.ts b/src/daemon/handlers/snapshot-settings.ts index bb0d73f5b3..59ace7433f 100644 --- a/src/daemon/handlers/snapshot-settings.ts +++ b/src/daemon/handlers/snapshot-settings.ts @@ -12,8 +12,11 @@ import { isMacOsSettingSupported, invalidTextSizeMessage, readTextSizeCategory, + settingsAppNotConsumedRefusal, + settingsAppScope, SETTINGS_INVALID_ARGS_MESSAGE, type ReadableSetting, + type SettingsTargetFamily, type SettingOptions, } from '@agent-device/contracts/settings'; import type { SetSettingInput } from '@agent-device/contracts/settings-runtime'; @@ -266,6 +269,8 @@ async function executeSettingsWrite( const { setting, state } = parsed; const refusal = settingsRequestRefusal(device, setting); if (refusal !== undefined) return refusal; + const appRefusal = settingsAppRefusal(device, req, setting, state); + if (appRefusal !== undefined) return appRefusal; const admission = await admitRuntimeUse({ command: 'settings', device, @@ -276,7 +281,7 @@ async function executeSettingsWrite( }); if (admission.type === 'response') return admission.response; session = ref ? sessionStore.requireCurrent(ref) : undefined; - const appBundleId = settingsWriteAppId(req, parsed, session); + const appBundleId = settingsWriteAppId(req, parsed, session, device); const writeRefusal = settingsWriteRefusal(parsed, appBundleId); if (writeRefusal !== undefined) return writeRefusal; // ADR 0014 side-effect seam: a settings mutation changes device state; expire the frame before @@ -341,16 +346,67 @@ function settingsRequestRefusal( } /** - * The app a mutation targets: the explicit positional wins, then the Maestro adapter's daemon-internal - * `settingsAppBundleId`, which aims one request at another app than the session carries, and last the - * app the session is bound to. + * The app a request names outside its positionals: the `app` of the request input, which the CLI + * writes from `--app` and the client and MCP surfaces write from their `app` option. Read as a + * non-empty string or nothing, because an empty `--app` was never a request to grant anything. + */ +function settingsRequestApp(req: DaemonRequest): string | undefined { + const app = req.input?.app; + if (typeof app !== 'string') return undefined; + const trimmed = app.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +/** + * The device fact the app-scope table is keyed on, answered with the kernel's own predicates so the + * legacy Apple leaf platforms settle exactly where the predicates do. + */ +function settingsTargetFamily(device: SessionState['device']): SettingsTargetFamily { + if (isMacOs(device)) return 'macos-host'; + return isApplePlatform(device.platform) ? 'apple' : 'mobile'; +} + +/** + * The refusal for naming an app on a mutation whose target consumes none: Android's on/off `location` + * writes the global `location_mode`, and a macOS permission is a TCC grant to the host process. It + * runs before admission because a request this surface refuses must not bind a runtime, and it stays + * silent for `unknown`, where the owner's own fact answers the setting rather than the app. + */ +function settingsAppRefusal( + device: SessionState['device'], + req: DaemonRequest, + setting: string, + state: string, +): DaemonResponse | undefined { + const app = settingsRequestApp(req); + if (app === undefined) return undefined; + if (settingsAppScope(settingsTargetFamily(device), setting, state) !== 'device-level') { + return undefined; + } + const refusal = settingsAppNotConsumedRefusal(setting, state, app); + return errorResponse(refusal.code, refusal.message, refusal.details, { hint: refusal.hint }); +} + +/** + * The app a mutation targets: the explicit positional wins, then the request's explicit `app`, then + * the Maestro adapter's daemon-internal `settingsAppBundleId`, which aims one request at another app + * than the session carries, and last the app the session is bound to. The request's `app` is only + * consumed where the scope table says the mutation can carry one; elsewhere it was already refused or + * belongs to an owner that never reads it. */ function settingsWriteAppId( req: DaemonRequest, parsed: ParsedSettingsArgs, session: SessionState | undefined, + device: SessionState['device'], ): string | undefined { - return parsed.appBundleId ?? req.internal?.settingsAppBundleId ?? session?.appBundleId; + const explicitApp = + settingsAppScope(settingsTargetFamily(device), parsed.setting, parsed.state) === 'app-scoped' + ? settingsRequestApp(req) + : undefined; + return ( + parsed.appBundleId ?? explicitApp ?? req.internal?.settingsAppBundleId ?? session?.appBundleId + ); } /** The refusal a mutation adds on top of the shared one: an app the session may not carry. */ diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 82cec170ef..5ca5e59610 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -762,6 +762,8 @@ agent-device settings permission grant camera agent-device settings permission deny microphone agent-device settings permission grant photos limited agent-device settings permission reset notifications +agent-device settings permission grant camera --app com.example.app +agent-device settings location on --app com.example.app agent-device settings permission grant accessibility --platform macos agent-device settings permission reset screen-recording --platform macos ``` @@ -782,7 +784,7 @@ agent-device settings permission reset screen-recording --platform macos - Android `settings airplane on|off` is applied by the connectivity service (`cmd connectivity airplane-mode`, Android 11+), which drives the radios rather than only writing the `airplane_mode_on` setting. The response reports the `airplaneMode` that service holds after the change, and Android builds without that command fail without changing device state. Connectivity takes a moment to settle after the switch, so poll the app under test rather than asserting offline behavior immediately. - Fingerprint simulation is supported on Android targets where `cmd fingerprint` or `adb emu finger` is available. On physical Android devices, only `cmd fingerprint` is attempted. -- On iOS and Android, permission actions are scoped to the active session app. With no app bound to the session, `settings permission` refuses with `INVALID_ARGS` and `error.details.reason: session_app_required` and `dispatched: no`: `open ` and retry the same command. On iOS, an on/off `settings location` has the same refusal and recovery; on Android, on/off `settings location` changes the global location mode and needs no app. macOS permission actions are system-level and need no session app. +- On iOS and Android, permission actions default to the active session app. Pass `--app ` (or the client `app` option) to aim a `permission` change — grant, deny, or reset — or an iOS simulator `location on|off` privacy change, at an installed app without opening it or binding a session — for example, grant a permission before the app's first launch. The app must be installed on the target; no app has to be running. The CLI reads `--app` for `settings` only when you pass it on the command line: an `AGENT_DEVICE_TARGET_APP` environment variable or a `targetApp` in `~/.agent-device/config.json` (both useful for `doctor`) never retargets a settings mutation, which falls back to the session app. With no app bound to the session and none named, `settings permission` refuses with `INVALID_ARGS` and `error.details.reason: session_app_required` and `dispatched: no`: `open ` and retry, or name the app with `--app`. On iOS, an on/off `settings location` behaves the same. A change that is device-wide refuses a named app instead of dropping it, with `error.details.reason: setting_app_not_consumed`: the Android `location on|off` toggle writes the device's `location_mode`, and a macOS permission is a host-level TCC grant that needs no session app. - iOS permission targets: `all`, `camera`, `microphone`, `photos` (`full` or `limited`), `contacts`, `contacts-limited`, `notifications`, `calendar`, `location`, `location-always`, `media-library`, `motion`, `reminders`, `siri`. `all` travels as one `simctl privacy … all` call. - On iOS, which of those services a runtime actually changes is `simctl privacy`'s own verdict, not its help text: Xcode 26 omits `camera` from the list while granting it. A service the runtime refuses fails with `UNSUPPORTED_OPERATION` naming the service; on current runtimes that is `notifications`, which a targeted change cannot reach and `all` leaves untouched. - Android permission targets: `all`, `calendar`, `camera`, `contacts`, `location`, `media-library`, `microphone`, `notifications`, `photos`. `contacts` fans out to `READ_CONTACTS`+`WRITE_CONTACTS`, `location` to `FINE`+`COARSE`, `calendar` to `READ`+`WRITE`; named multi-id targets intersect the package's declared permissions so a coarse-only or read-only app still succeeds, while a target declaring none of its ids fails loudly. `all` resolves against the package's declared permissions instead. Every response reports `permission` as the requested target and `permissions: string[]` as the ids actually mutated, in the order applied (`all` folds its applied ids into `permissions` the same way). From 58f310beba469e9f9904f2bc457d0846c8c77cc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 4 Oct 2026 09:52:22 +0200 Subject: [PATCH 2/3] chore(gates): retire the settings.app undescribed-input pin now that the field is documented --- src/mcp/__tests__/command-tools-input-docs.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/mcp/__tests__/command-tools-input-docs.test.ts b/src/mcp/__tests__/command-tools-input-docs.test.ts index a699c126b3..8476be0299 100644 --- a/src/mcp/__tests__/command-tools-input-docs.test.ts +++ b/src/mcp/__tests__/command-tools-input-docs.test.ts @@ -101,7 +101,6 @@ const UNDESCRIBED_TOOL_INPUTS = new Set([ 'screenshot.stabilize', 'screenshot.surface', 'scroll.direction', - 'settings.app', 'settings.latitude', 'settings.longitude', 'settings.mode', From 05b1b4c54a212b98e415988e0135a196194e2273 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 5 Oct 2026 06:45:53 +0200 Subject: [PATCH 3/3] fix(settings): refuse a named app for location set too (r4176656835) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `settingsInputApp` dropped `--app` for `location set`, so `settings location set --app X` ignored X without a word — a dropped argument, not a wrong-app change, and a contradiction this PR's own rule forbids ("a device-wide change refuses a named app instead of dropping it"). The daemon's scope table already classifies `location set` as device-level for every family; the writer just never forwarded the app for that refusal to land. Forward every `location` state's app so the daemon's `setting_app_not_consumed` refusal answers it before dispatch, and widen the client `location set` leg to carry `app` so the writer can. Co-Authored-By: opencode --- packages/contracts/src/client-settings.ts | 5 ++++ src/commands/capture/settings.test.ts | 6 ++-- src/commands/capture/settings.ts | 9 ++++-- .../snapshot-settings-handler.test.ts | 28 +++++++++++++++++++ website/docs/docs/commands.md | 2 +- 5 files changed, 44 insertions(+), 6 deletions(-) diff --git a/packages/contracts/src/client-settings.ts b/packages/contracts/src/client-settings.ts index ff98b1e0c7..1a6f9bd769 100644 --- a/packages/contracts/src/client-settings.ts +++ b/packages/contracts/src/client-settings.ts @@ -43,11 +43,16 @@ export type SettingsUpdateOptions = state: 'on' | 'off'; app?: string; }) + /** + * `set` moves the device's own location for every target, so naming an app here is a contradiction + * the daemon refuses with `setting_app_not_consumed` rather than a value it silently drops. + */ | (DeviceCommandBaseOptions & { setting: 'location'; state: 'set'; latitude: number; longitude: number; + app?: string; }) | (DeviceCommandBaseOptions & { setting: 'animations'; diff --git a/src/commands/capture/settings.test.ts b/src/commands/capture/settings.test.ts index 95757f0933..0274ae5623 100644 --- a/src/commands/capture/settings.test.ts +++ b/src/commands/capture/settings.test.ts @@ -181,14 +181,16 @@ describe('settings CLI permission vocabulary', () => { }); }); - test('keeps a location set device-wide and drops the app it never reads', () => { + // r4176656835: the writer used to drop the app, so `location set ... --app X` ignored X silently. + // It now forwards, and the daemon's device-level refusal answers it (handler test below). + test('forwards the app on a location set to the device-level refusal', () => { const input = settingsCliReader(['location', 'set', '37.7', '-122.4'], { targetApp: 'com.example.app', } as CliFlags); expect(settingsDaemonWriter(input)).toMatchObject({ positionals: ['location', 'set', '37.7', '-122.4'], + input: { app: 'com.example.app' }, }); - expect(settingsDaemonWriter(input).input).toBeUndefined(); }); // The flag bag carries a configured default app the parser strips for settings; the reader still diff --git a/src/commands/capture/settings.ts b/src/commands/capture/settings.ts index 3b99f7e8a2..771cb8f19f 100644 --- a/src/commands/capture/settings.ts +++ b/src/commands/capture/settings.ts @@ -43,7 +43,7 @@ const settingsCommandMetadata = defineFieldCommandMetadata( // and the daemon enforce per setting rather than the shared field map. state: stringField(), app: stringField( - 'App the change targets, by bundle id or package name: the app `clear-app-state` clears, or the app a `permission` change (or an iOS-simulator `location on|off` privacy change) lands on. An app named here needs no session and need not be running (Android `deny|reset` of a held permission does kill a running one); without it those changes use the session app, and a target whose change is device-wide (Android location toggle, macOS permissions) refuses one. The CLI consumes it only when this invocation passes it: an `AGENT_DEVICE_TARGET_APP` or config `targetApp` never retargets a settings mutation.', + 'App the change targets, by bundle id or package name: the app `clear-app-state` clears, or the app a `permission` change (or an iOS-simulator `location on|off` privacy change) lands on. An app named here needs no session and need not be running (Android `deny|reset` of a held permission does kill a running one); without it those changes use the session app, and a change that is device-wide refuses one with `setting_app_not_consumed` (`location set` on any target, the Android `location on|off` toggle, macOS permissions). The CLI consumes it only when this invocation passes it: an `AGENT_DEVICE_TARGET_APP` or config `targetApp` never retargets a settings mutation.', ), latitude: numberField(), longitude: numberField(), @@ -94,7 +94,10 @@ export const settingsDaemonWriter: DaemonWriter = (input) => { /** The settings whose `app` reaches the daemon through request input rather than a positional. */ function settingsInputApp(input: SettingsUpdateOptions): string | undefined { if (input.setting === 'permission') return input.app; - if (input.setting === 'location' && input.state !== 'set') return input.app; + // Every location state forwards the app: `on|off` consumes it on Apple, and `set` is device-wide + // for everyone, so the daemon's scope table refuses the named app instead of the writer dropping + // it without a word. + if (input.setting === 'location') return input.app; return undefined; } @@ -102,7 +105,7 @@ export const settingsCommandFacet = defineCommandFacet({ name: SETTINGS_COMMAND_NAME, text: { summary: 'Change OS settings and app permissions', - cliDetail: `macOS supports only settings appearance and settings ${SETTINGS_MACOS_PERMISSION_USAGE}; wifi|airplane|location|animations|text-size remain unsupported on macOS. Mobile permission actions default to the active session app; pass --app (or the app input) to aim a permission change, or an iOS-simulator location on|off, at an installed app no session has opened; no app needs to be running, and the CLI consumes the app only when this invocation names it, never from AGENT_DEVICE_TARGET_APP or config targetApp. A device-wide change refuses an app: the Android location toggle writes the device's location_mode, and a macOS permission is a host-level TCC grant. On Android, deny|reset of a permission the app currently holds kills a running app; the response reports priorGrantState (granted|not_granted|unknown) and warns for granted and unknown, with open --relaunch to restore it. Permission changes require a resolvable foreground user and fail without mutating if adb cannot report one. Android settings airplane on|off is applied by the connectivity service (Android 11+) and reports the airplaneMode that service holds; older builds fail without changing device state. settings reset-keychain clear is iOS-simulator-only and resets the whole simulator keychain, not just the selected app: simctl exposes no per-app keychain reset, so every app on that simulator loses its keychain-backed credentials (e.g. Firebase auth). clear-app-state does not touch the keychain, so a full fresh-install reset needs both; relaunch the app afterward to observe the signed-out state. settings text-size reads the preferred text size the target holds and settings text-size applies one, on iPhone and iPad simulators (simctl content size) and on Android targets (system font_scale); tvOS and visionOS simulators, physical Apple devices, and the macOS host refuse it. Android has no category ladder of its own, so the read names the nearest rung and reports the exact multiplier as platformValue; an already-running app adopts a changed size at its next configuration change, so relaunch the app under test to observe it.`, + cliDetail: `macOS supports only settings appearance and settings ${SETTINGS_MACOS_PERMISSION_USAGE}; wifi|airplane|location|animations|text-size remain unsupported on macOS. Mobile permission actions default to the active session app; pass --app (or the app input) to aim a permission change, or an iOS-simulator location on|off, at an installed app no session has opened; no app needs to be running, and the CLI consumes the app only when this invocation names it, never from AGENT_DEVICE_TARGET_APP or config targetApp. A device-wide change refuses an app with setting_app_not_consumed: location set moves the device's own coordinates on every target, the Android location toggle writes the device's location_mode, and a macOS permission is a host-level TCC grant. On Android, deny|reset of a permission the app currently holds kills a running app; the response reports priorGrantState (granted|not_granted|unknown) and warns for granted and unknown, with open --relaunch to restore it. Permission changes require a resolvable foreground user and fail without mutating if adb cannot report one. Android settings airplane on|off is applied by the connectivity service (Android 11+) and reports the airplaneMode that service holds; older builds fail without changing device state. settings reset-keychain clear is iOS-simulator-only and resets the whole simulator keychain, not just the selected app: simctl exposes no per-app keychain reset, so every app on that simulator loses its keychain-backed credentials (e.g. Firebase auth). clear-app-state does not touch the keychain, so a full fresh-install reset needs both; relaunch the app afterward to observe the signed-out state. settings text-size reads the preferred text size the target holds and settings text-size applies one, on iPhone and iPad simulators (simctl content size) and on Android targets (system font_scale); tvOS and visionOS simulators, physical Apple devices, and the macOS host refuse it. Android has no category ladder of its own, so the read names the nearest rung and reports the exact multiplier as platformValue; an already-running app adopts a changed size at its next configuration change, so relaunch the app under test to observe it.`, }, metadata: settingsCommandMetadata, run: (client, input) => client.settings.update(input as SettingsUpdateOptions), diff --git a/src/daemon/handlers/__tests__/snapshot-settings-handler.test.ts b/src/daemon/handlers/__tests__/snapshot-settings-handler.test.ts index 74cef3a60a..e3d89abb81 100644 --- a/src/daemon/handlers/__tests__/snapshot-settings-handler.test.ts +++ b/src/daemon/handlers/__tests__/snapshot-settings-handler.test.ts @@ -599,6 +599,34 @@ test('settings Android location 0 refuses a named app before reaching the owner' } }); +// `location set` moves the device's own location for every family, so a named app contradicts the +// mutation everywhere. The writer forwards it (r4176656835) and this refusal answers it; the +// coordinates stay unparsed because the request must never reach the owner. +test('settings iOS location set refuses a named app before reaching the owner', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'ios-location-set-refused'; + sessionStore.publish(sessionName, makeSession(sessionName, iosSimulatorDevice)); + + const response = await handleSnapshotCommands({ + req: snapshotRequest(sessionName, 'settings', { + positionals: ['location', 'set', '37.7', '-122.4'], + input: { app: 'com.example.app' }, + }), + sessionName, + logPath: '/tmp/daemon.log', + sessionStore, + }); + + expect(response?.ok).toBe(false); + expect(fixtureSettingsMutations).toHaveLength(0); + if (response && !response.ok) { + expect(response.error.code).toBe('INVALID_ARGS'); + expect(response.error.message).toMatch(/location set applies to the target itself/); + expect(response.error.details?.reason).toBe('setting_app_not_consumed'); + expect(response.error.details?.dispatched).toBe('no'); + } +}); + // A macOS permission is a host-level TCC grant, so naming an app is refused rather than dropped. test('settings macOS permission grant refuses a named app before reaching the owner', async () => { const sessionStore = makeSessionStore(); diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 5ca5e59610..b5da425180 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -784,7 +784,7 @@ agent-device settings permission reset screen-recording --platform macos - Android `settings airplane on|off` is applied by the connectivity service (`cmd connectivity airplane-mode`, Android 11+), which drives the radios rather than only writing the `airplane_mode_on` setting. The response reports the `airplaneMode` that service holds after the change, and Android builds without that command fail without changing device state. Connectivity takes a moment to settle after the switch, so poll the app under test rather than asserting offline behavior immediately. - Fingerprint simulation is supported on Android targets where `cmd fingerprint` or `adb emu finger` is available. On physical Android devices, only `cmd fingerprint` is attempted. -- On iOS and Android, permission actions default to the active session app. Pass `--app ` (or the client `app` option) to aim a `permission` change — grant, deny, or reset — or an iOS simulator `location on|off` privacy change, at an installed app without opening it or binding a session — for example, grant a permission before the app's first launch. The app must be installed on the target; no app has to be running. The CLI reads `--app` for `settings` only when you pass it on the command line: an `AGENT_DEVICE_TARGET_APP` environment variable or a `targetApp` in `~/.agent-device/config.json` (both useful for `doctor`) never retargets a settings mutation, which falls back to the session app. With no app bound to the session and none named, `settings permission` refuses with `INVALID_ARGS` and `error.details.reason: session_app_required` and `dispatched: no`: `open ` and retry, or name the app with `--app`. On iOS, an on/off `settings location` behaves the same. A change that is device-wide refuses a named app instead of dropping it, with `error.details.reason: setting_app_not_consumed`: the Android `location on|off` toggle writes the device's `location_mode`, and a macOS permission is a host-level TCC grant that needs no session app. +- On iOS and Android, permission actions default to the active session app. Pass `--app ` (or the client `app` option) to aim a `permission` change — grant, deny, or reset — or an iOS simulator `location on|off` privacy change, at an installed app without opening it or binding a session — for example, grant a permission before the app's first launch. The app must be installed on the target; no app has to be running. The CLI reads `--app` for `settings` only when you pass it on the command line: an `AGENT_DEVICE_TARGET_APP` environment variable or a `targetApp` in `~/.agent-device/config.json` (both useful for `doctor`) never retargets a settings mutation, which falls back to the session app. With no app bound to the session and none named, `settings permission` refuses with `INVALID_ARGS` and `error.details.reason: session_app_required` and `dispatched: no`: `open ` and retry, or name the app with `--app`. On iOS, an on/off `settings location` behaves the same. A change that is device-wide refuses a named app instead of dropping it, with `error.details.reason: setting_app_not_consumed`: `location set` moves the device's own coordinates on every target, the Android `location on|off` toggle writes the device's `location_mode`, and a macOS permission is a host-level TCC grant that needs no session app. - iOS permission targets: `all`, `camera`, `microphone`, `photos` (`full` or `limited`), `contacts`, `contacts-limited`, `notifications`, `calendar`, `location`, `location-always`, `media-library`, `motion`, `reminders`, `siri`. `all` travels as one `simctl privacy … all` call. - On iOS, which of those services a runtime actually changes is `simctl privacy`'s own verdict, not its help text: Xcode 26 omits `camera` from the list while granting it. A service the runtime refuses fails with `UNSUPPORTED_OPERATION` naming the service; on current runtimes that is `notifications`, which a targeted change cannot reach and `all` leaves untouched. - Android permission targets: `all`, `calendar`, `camera`, `contacts`, `location`, `media-library`, `microphone`, `notifications`, `photos`. `contacts` fans out to `READ_CONTACTS`+`WRITE_CONTACTS`, `location` to `FINE`+`COARSE`, `calendar` to `READ`+`WRITE`; named multi-id targets intersect the package's declared permissions so a coarse-only or read-only app still succeeds, while a target declaring none of its ids fails loudly. `all` resolves against the package's declared permissions instead. Every response reports `permission` as the requested target and `permissions: string[]` as the ids actually mutated, in the order applied (`all` folds its applied ids into `permissions` the same way).