Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
8d08026
feat: support Maestro setPermissions
Rohit3523 Sep 6, 2026
4de150a
fix(maestro): apply launchApp permissions before launch, deny on loca…
Rohit3523 Sep 6, 2026
7285bb1
fix(maestro): reset notifications via reset-all fallback, declare per…
Rohit3523 Sep 6, 2026
cf1f91d
fix(maestro): resolve all in the backends, harden fan-out, deduplicat…
Rohit3523 Sep 6, 2026
682d43d
fix(maestro): fail explicit notifications reset instead of reset-all …
Rohit3523 Sep 6, 2026
752b88d
fix(maestro): fail targeted notifications reset, propagate operationa…
Rohit3523 Sep 7, 2026
e656ff1
Merge remote-tracking branch 'origin/main' into feat/maestro-setPermi…
Rohit3523 Sep 15, 2026
9f187e6
Merge remote-tracking branch 'origin/main' into feat/maestro-setPermi…
Rohit3523 Sep 15, 2026
c6f0705
fix(maestro): resolve e656ff1 review — declared-intersection named ta…
Rohit3523 Sep 15, 2026
39c7030
Merge branch 'main' into feat/maestro-setPermissions
Rohit3523 Sep 15, 2026
eba8e0b
docs(changelog): phrase permission all as Maestro setPermissions
Rohit3523 Sep 15, 2026
e4dd9a4
fix(maestro): clear fallow gates and script dumpsys in provider harness
Rohit3523 Sep 15, 2026
efe33f4
fix(maestro): scope granular values to iOS, unify dumpsys reads, pin …
Rohit3523 Sep 16, 2026
c3e47b3
Merge branch 'main' into feat/maestro-setPermissions
Rohit3523 Sep 16, 2026
a3e35d2
fix(maestro): single-source Maestro hints, unexport test-only parser,…
Rohit3523 Sep 16, 2026
1ffc404
Merge remote-tracking branch 'origin/main' into feat/maestro-setPermi…
Rohit3523 Sep 18, 2026
aa9186d
fix(maestro): lazy-load set-permissions mapping to keep daemon-port e…
Rohit3523 Sep 18, 2026
a7131ca
docs(maestro): note iOS all does not cover notifications
Rohit3523 Sep 18, 2026
68dac57
refactor(maestro): keep permission support in the backends
thymikee Sep 19, 2026
12d00dd
refactor(android): keep the package-permissions parser module-private
thymikee Sep 19, 2026
ab84e0d
fix(ios): read privacy support from simctl, not its help text
thymikee Sep 19, 2026
55466f9
Merge branch 'main' into feat/maestro-setPermissions
Rohit3523 Sep 20, 2026
c0df8ee
Merge remote-tracking branch 'origin/main' into rebase/maestro-setper…
thymikee Sep 21, 2026
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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,20 @@
answers pixels instead of PNG bytes. The `--out` diff image stays PNG, as do the crop, overlay, and
resize passes that rewrite a screenshot in place and could not survive a lossy container.

- Added (maestro): `setPermissions` and `launchApp.permissions` support `all: allow|deny|unset`
on iOS simulators and Android, with specific entries overriding `all`. Both accept a
Maestro-style permissions map. Entries apply in order until the selected platform refuses one,
and the error then names what landed. Android's only allow level is while-in-use, so
`location: inuse|never` apply `allow`/`deny` there; `location: always` and `photos: limited`
stay Apple-only.
- Fixed (ios): `settings permission` no longer refuses a privacy service that `simctl privacy`
accepts but omits from its own help text — Xcode 26 does that for `camera`, so
`settings permission grant camera` and a Maestro `camera: allow` failed as unsupported while
the raw command worked. Support is now `simctl privacy`'s own verdict per runtime, and a
service it refuses fails with `UNSUPPORTED_OPERATION`. This also drops the cached
`simctl privacy help` probe and the `privacy help` spawn before the first permission change.
iOS `all` still does not cover notifications: current runtimes have no notifications service,
so a targeted notifications change fails loudly and `all` leaves it unchanged.
- Added (limrun): `record start` and `record stop` on Limrun iOS and Android direct sessions. The
runtime declared recording unavailable although the Limrun SDK exposes a server-side recorder.
Start asks the instance to record (`--quality medium` maps to Limrun quality 5, `high` to 8);
Expand Down
4 changes: 3 additions & 1 deletion packages/contracts/src/client-settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { SettingsUpdateOptions } from './client-settings.ts';
type Permission = Extract<SettingsUpdateOptions, { setting: 'permission' }>;

const MOBILE_TARGETS = [
'all',
'camera',
'microphone',
'photos',
Expand All @@ -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<Permission['permission']>().toEqualTypeOf<
Expand All @@ -31,7 +34,6 @@ describe('public client permission vocabulary', () => {
});

test('does not name a permission the vocabulary does not declare', () => {
expectTypeOf<'all'>().not.toMatchTypeOf<Permission['permission']>();
expectTypeOf<'bluetooth'>().not.toMatchTypeOf<Permission['permission']>();
});

Expand Down
6 changes: 6 additions & 0 deletions packages/contracts/src/replay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,4 +286,10 @@ export type ReplayDispatchOptions = Readonly<{
gestureViewport?: Rect;
/** Maestro-compat execution profile for timed coordinate swipes projected to `gesture pan`. */
gestureExecutionProfile?: GestureExecutionProfile;
/**
* 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.
*/
settingsAppBundleId?: string;
}>;
5 changes: 3 additions & 2 deletions packages/contracts/src/settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -61,7 +64,6 @@ const NORMALIZATIONS = [

const REJECTED_TARGETS = [
...MACOS_ONLY_TARGETS,
'all',
'bluetooth',
'camera-x',
'camera limited',
Expand Down Expand Up @@ -172,6 +174,5 @@ describe('permission vocabulary types', () => {
expectTypeOf<'accessibility'>().not.toMatchTypeOf<PermissionTarget>();
expectTypeOf<'screen-recording'>().not.toMatchTypeOf<PermissionTarget>();
expectTypeOf<'input-monitoring'>().not.toMatchTypeOf<PermissionTarget>();
expectTypeOf<'all'>().not.toMatchTypeOf<PermissionTarget>();
});
});
1 change: 1 addition & 0 deletions packages/contracts/src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import assert from 'node:assert/strict';
import { expect, test } from 'vitest';
import type { MaestroDaemonOperationRequest } from '../daemon-runtime-public-operation.ts';
import { createDaemonMaestroRuntimePort } from '../daemon-runtime-port.ts';
import { makeDependencies, makeRuntimeEnvelope } from './daemon-runtime-port-fixtures.ts';

function makePort(requests: MaestroDaemonOperationRequest[], platform: 'ios' | 'android') {
return createDaemonMaestroRuntimePort({
...makeRuntimeEnvelope({ 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: MaestroDaemonOperationRequest[] = [];
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(({ dispatch }) => dispatch?.settingsAppBundleId === 'com.example.app'),
).toBe(true);
});

test('a mid-sequence backend rejection names what already landed', async () => {
const requests: MaestroDaemonOperationRequest[] = [];
let calls = 0;
const port = createDaemonMaestroRuntimePort({
...makeRuntimeEnvelope({ 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<string, unknown> }).details?.appliedPermissionMutations,
['deny all'],
);
assert.equal(
(failure as { details?: Record<string, unknown> }).details?.failedPermissionMutation,
'reset notifications',
);
});

test('launchApp applies permissions after clearing but before launch', async () => {
const requests: MaestroDaemonOperationRequest[] = [];
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]?.dispatch?.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: MaestroDaemonOperationRequest[] = [];
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: MaestroDaemonOperationRequest[] = [];
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/i);
expect(requests).toEqual([]);
});

test('setPermissions without an appId leaves targeting to the session app', async () => {
const requests: MaestroDaemonOperationRequest[] = [];
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('dispatch');
});
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { buildGesturePlan } from '@agent-device/contracts/gesture-plan';
import assert from 'node:assert/strict';
import { describe, expect, test } from 'vitest';
import {
mapMaestroSetPermissions,
projectMaestroPublicOperation,
type MaestroPublicOperation,
} from '../daemon-runtime-public-operation.ts';
Expand Down Expand Up @@ -184,6 +185,31 @@ describe('Maestro public operation projection', () => {
dispatch: { observationOnly: true },
},
},
{
operation: {
kind: 'settingsPermission',
appId: 'com.example',
state: 'grant',
permission: 'camera',
},
expected: {
command: 'settings',
positionals: ['permission', 'grant', 'camera'],
dispatch: { 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);
});
Expand Down Expand Up @@ -225,3 +251,67 @@ describe('Maestro public operation projection', () => {
assert.equal(plan.durationMs, 400);
});
});

describe('Maestro setPermissions mapping', () => {
test('maps plain values and sends all first so specific entries override it', () => {
expect(
mapMaestroSetPermissions({ notifications: 'unset', ALL: 'deny', camera: 'allow' }),
).toEqual([
{ state: 'deny', permission: 'all' },
{ state: 'reset', permission: 'notifications' },
{ state: 'grant', permission: 'camera' },
]);
});

test('maps the granular values and the medialibrary spelling', () => {
expect(
mapMaestroSetPermissions({
location: 'always',
photos: 'limited',
medialibrary: 'allow',
}),
).toEqual([
{ state: 'grant', permission: 'location-always' },
{ state: 'grant', permission: 'photos', mode: 'limited' },
{ state: 'grant', permission: 'media-library' },
]);
expect(mapMaestroSetPermissions({ location: 'inuse' })).toEqual([
{ state: 'grant', permission: 'location' },
]);
expect(mapMaestroSetPermissions({ location: 'never' })).toEqual([
{ state: 'deny', permission: 'location' },
]);
});

test.each([
'bluetooth',
'speech',
'health',
'contacts-limited',
'location-always',
'constructor',
'android.permission.MANAGE_EXTERNAL_STORAGE',
])('refuses the name %s before any mutation', (name) => {
expect(() => mapMaestroSetPermissions({ camera: 'allow', [name]: 'allow' })).toThrow(
expect.objectContaining({
code: 'UNSUPPORTED_OPERATION',
message: `Maestro permission "${name.toLowerCase()}" is not supported.`,
}),
);
});

test.each([
['camera', 'always', 'Use allow|deny|unset.'],
['all', 'never', 'Use allow|deny|unset.'],
['photos', 'always', 'Use allow|deny|unset|limited.'],
['location', 'toString', 'Use allow|deny|unset|always|inuse|never.'],
])('refuses %s: %s', (name, value, hint) => {
expect(() => mapMaestroSetPermissions({ [name]: value })).toThrow(
expect.objectContaining({ code: 'INVALID_ARGS', details: { hint } }),
);
});

test('refuses an empty map', () => {
expect(() => mapMaestroSetPermissions({})).toThrow(/at least one permission/);
});
});
Loading
Loading