Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions packages/command-registry/src/command-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,15 @@ export type CommandSchema = {
*/
flagsByAction?: Readonly<Record<string, readonly FlagKey[]>>;
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
Expand Down
3 changes: 2 additions & 1 deletion packages/command-registry/src/flag-definitions-target.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,8 @@ export const TARGET_FLAG_DEFINITIONS: readonly FlagDefinition[] = [
names: ['--app', '--target-app'],
type: 'string',
usageLabel: '--app <id-or-name>',
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,
},
Expand Down
25 changes: 24 additions & 1 deletion packages/contracts/src/client-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,30 @@ 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;
})
/**
* `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';
Expand Down Expand Up @@ -68,4 +84,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;
});
79 changes: 79 additions & 0 deletions packages/contracts/src/settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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');
});
});
122 changes: 120 additions & 2 deletions packages/contracts/src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
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 = '<wifi|airplane|location> <on|off>';
const SETTINGS_LOCATION_SET_USAGE = 'location set <lat> <lon>';
const SETTINGS_ANIMATIONS_USAGE = 'animations <on|off>';
Expand Down Expand Up @@ -299,10 +405,22 @@ export function parseAppearanceAction(state: string): AppearanceAction {
);
}

/** The boolean a `settings <setting> <state>` positional spells, in any casing. */
export function parseSettingState(state: string): boolean {
/**
* The boolean a `settings <setting> <state>` 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 <setting> <state>` 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}`);
}
17 changes: 17 additions & 0 deletions src/cli/parser/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,7 @@ export function finalizeParsedArgs(
delete (flags as Record<string, unknown>)[key];
}
}
stripFlagsTheCommandTreatsAsExplicitOnly(parsed, flags);
assertNoConflictingBackModeFlags(parsed);
applyCommandDefaults(parsed.command, flags);
const normalized = normalizeParsedCommandAliases({
Expand Down Expand Up @@ -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<string, unknown>)[key];
}
}

/**
* The typed options the selected action of a command cannot read.
*
Expand Down
37 changes: 37 additions & 0 deletions src/cli/resolve-cli-options.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
Loading
Loading