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
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ agent-device scrollintoview @e42
- `logs path`, `logs start`, `logs stop`, `logs clear`, `logs clear --restart`, `logs doctor`, `logs mark` (session app log file for grep; iOS simulator + iOS device + Android)
- `settings wifi|airplane|location on|off`
- `settings faceid match|nonmatch|enroll|unenroll` (iOS simulator only)
- `settings permission grant|deny|reset camera|microphone|photos|contacts|notifications [full|limited]`
- `appstate`, `apps`, `devices`, `session list`

## iOS Snapshots
Expand Down Expand Up @@ -291,7 +292,11 @@ Settings helpers:
- `settings wifi on|off`
- `settings airplane on|off`
- `settings location on|off` (iOS uses per-app permission for the current session app)
- `settings permission grant|deny|reset <camera|microphone|photos|contacts|notifications> [full|limited]` (session app required)
Note: iOS supports these only on simulators. iOS wifi/airplane toggles status bar indicators, not actual network state. Airplane off clears status bar overrides.
- iOS permission targets map to `simctl privacy`: `camera`, `microphone`, `photos` (`full` => `photos`, `limited` => `photos-add`), `contacts`, `notifications`.
- Android permission targets: `camera`, `microphone`, `photos`, `contacts` use `pm grant|revoke` (`reset` maps to `pm revoke`); `notifications` uses `appops set POST_NOTIFICATION allow|deny|default`.
- `full|limited` mode is valid only for iOS `photos`; other targets reject mode.

App state:
- `appstate` shows the foreground app/activity (Android).
Expand Down
5 changes: 5 additions & 0 deletions skills/agent-device/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ agent-device is visible 'id="anchor"'
agent-device appstate
agent-device get text @e1
agent-device screenshot out.png
agent-device settings permission grant notifications
agent-device settings permission reset camera
agent-device trace start
agent-device trace stop ./trace.log
```
Expand All @@ -106,6 +108,9 @@ agent-device batch --steps-file /tmp/batch-steps.json --json
- Use `fill` for clear-then-type semantics; use `type` for focused append typing.
- iOS `appstate` is session-scoped; Android `appstate` is live foreground state.
- iOS settings helpers are simulator-only; use faceid `match|nonmatch|enroll|unenroll`.
- Permission settings are app-scoped and require an active session app:
`settings permission <grant|deny|reset> <camera|microphone|photos|contacts|notifications> [full|limited]`
- `full|limited` mode applies only to iOS `photos`; other targets reject mode.
- If using `--save-script`, prefer explicit path syntax (`--save-script=flow.ad` or `./flow.ad`).

## Security and Trust Notes
Expand Down
15 changes: 12 additions & 3 deletions src/core/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -412,21 +412,30 @@ export async function dispatchCommand(
return { action: 'app-switcher' };
}
case 'settings': {
const [setting, state, appBundleId] = positionals;
const [setting, state, target, mode, appBundleId] = positionals;
const permissionOptions =
setting === 'permission'
? {
permissionTarget: target,
permissionMode: mode,
}
: undefined;
emitDiagnostic({
level: 'debug',
phase: 'settings_apply',
data: {
setting,
state,
target,
mode,
platform: device.platform,
},
});
if (device.platform === 'ios') {
await setIosSetting(device, setting, state, appBundleId ?? context?.appBundleId);
await setIosSetting(device, setting, state, appBundleId ?? context?.appBundleId, permissionOptions);
return { setting, state };
}
await setAndroidSetting(device, setting, state);
await setAndroidSetting(device, setting, state, appBundleId ?? context?.appBundleId, permissionOptions);
return { setting, state };
}
case 'snapshot': {
Expand Down
1 change: 1 addition & 0 deletions src/daemon/handlers/__tests__/snapshot-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ test('settings usage hint documents canonical faceid states', async () => {
if (response && !response.ok) {
assert.equal(response.error.code, 'INVALID_ARGS');
assert.match(response.error.message, /match\|nonmatch\|enroll\|unenroll/);
assert.match(response.error.message, /grant\|deny\|reset/);
assert.doesNotMatch(response.error.message, /validate\|unvalidate/);
}
});
Expand Down
20 changes: 15 additions & 5 deletions src/daemon/handlers/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -382,15 +382,20 @@ export async function handleSnapshotCommands(params: {
}

if (command === 'settings') {
const setting = req.positionals?.[0];
const state = req.positionals?.[1];
if (!setting || !state) {
const setting = req.positionals?.[0]?.toLowerCase();
const state = req.positionals?.[1]?.toLowerCase();
const permissionTarget = req.positionals?.[2]?.toLowerCase();
if (
!setting ||
!state ||
(setting === 'permission' && !permissionTarget)
) {
return {
ok: false,
error: {
code: 'INVALID_ARGS',
message:
'settings requires <wifi|airplane|location> <on|off> or faceid <match|nonmatch|enroll|unenroll>',
'settings requires <wifi|airplane|location> <on|off>, faceid <match|nonmatch|enroll|unenroll>, or permission <grant|deny|reset> <camera|microphone|photos|contacts|notifications> [full|limited]',
},
};
}
Expand All @@ -406,10 +411,15 @@ export async function handleSnapshotCommands(params: {
}
return await withSessionlessRunnerCleanup(session, device, async () => {
const appBundleId = session?.appBundleId;
// Settings positional layout for dispatch: setting, state, [target, mode], appBundleId.
const positionals =
setting === 'permission'
? [setting, state, permissionTarget, req.positionals?.[3] ?? '', appBundleId ?? '']
: [setting, state, appBundleId ?? ''];
const data = await dispatchCommand(
device,
'settings',
[setting, state, appBundleId ?? ''],
positionals,
req.flags?.out,
{
...contextFromFlags(logPath, req.flags, appBundleId, session?.trace?.outPath),
Expand Down
144 changes: 143 additions & 1 deletion src/platforms/android/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,55 @@ import assert from 'node:assert/strict';
import { promises as fs } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { inferAndroidAppName, listAndroidApps, openAndroidApp, parseAndroidLaunchComponent, swipeAndroid } from '../index.ts';
import {
inferAndroidAppName,
listAndroidApps,
openAndroidApp,
parseAndroidLaunchComponent,
setAndroidSetting,
swipeAndroid,
} from '../index.ts';
import type { DeviceInfo } from '../../../utils/device.ts';
import { AppError } from '../../../utils/errors.ts';
import { findBounds, parseUiHierarchy } from '../ui-hierarchy.ts';

async function withMockedAdb(
tempPrefix: string,
script: string,
run: (ctx: { argsLogPath: string; device: DeviceInfo }) => Promise<void>,
): Promise<void> {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), tempPrefix));
const adbPath = path.join(tmpDir, 'adb');
const argsLogPath = path.join(tmpDir, 'args.log');
await fs.writeFile(adbPath, script, 'utf8');
await fs.chmod(adbPath, 0o755);

const previousPath = process.env.PATH;
const previousArgsFile = process.env.AGENT_DEVICE_TEST_ARGS_FILE;
process.env.PATH = `${tmpDir}${path.delimiter}${previousPath ?? ''}`;
process.env.AGENT_DEVICE_TEST_ARGS_FILE = argsLogPath;

const device: DeviceInfo = {
platform: 'android',
id: 'emulator-5554',
name: 'Pixel',
kind: 'emulator',
booted: true,
};

try {
await run({ argsLogPath, device });
} finally {
process.env.PATH = previousPath;
if (previousArgsFile === undefined) {
delete process.env.AGENT_DEVICE_TEST_ARGS_FILE;
} else {
process.env.AGENT_DEVICE_TEST_ARGS_FILE = previousArgsFile;
}
await fs.rm(tmpDir, { recursive: true, force: true });
}
}

test('parseUiHierarchy reads double-quoted Android node attributes', () => {
const xml =
'<hierarchy><node class="android.widget.TextView" text="Hello" content-desc="Greeting" resource-id="com.demo:id/title" bounds="[10,20][110,60]" clickable="true" enabled="true"/></hierarchy>';
Expand Down Expand Up @@ -272,3 +316,101 @@ test('swipeAndroid invokes adb input swipe with duration', async () => {
await fs.rm(tmpDir, { recursive: true, force: true });
}
});

test('setAndroidSetting permission grant camera uses pm grant', async () => {
await withMockedAdb(
'agent-device-android-permission-camera-',
'#!/bin/sh\nprintf "__CMD__\\n" >> "$AGENT_DEVICE_TEST_ARGS_FILE"\nprintf "%s\\n" "$@" >> "$AGENT_DEVICE_TEST_ARGS_FILE"\nexit 0\n',
async ({ argsLogPath, device }) => {
await setAndroidSetting(device, 'permission', 'grant', 'com.example.app', {
permissionTarget: 'camera',
});
const logged = await fs.readFile(argsLogPath, 'utf8');
assert.match(logged, /shell\npm\ngrant\ncom\.example\.app\nandroid\.permission\.CAMERA/);
},
);
});

test('setAndroidSetting permission deny notifications uses appops', async () => {
await withMockedAdb(
'agent-device-android-permission-notifications-',
'#!/bin/sh\nprintf "__CMD__\\n" >> "$AGENT_DEVICE_TEST_ARGS_FILE"\nprintf "%s\\n" "$@" >> "$AGENT_DEVICE_TEST_ARGS_FILE"\nexit 0\n',
async ({ argsLogPath, device }) => {
await setAndroidSetting(device, 'permission', 'deny', 'com.example.app', {
permissionTarget: 'notifications',
});
const logged = await fs.readFile(argsLogPath, 'utf8');
assert.match(logged, /shell\nappops\nset\ncom\.example\.app\nPOST_NOTIFICATION\ndeny/);
},
);
});

test('setAndroidSetting permission reset camera maps to pm revoke', async () => {
await withMockedAdb(
'agent-device-android-permission-reset-',
'#!/bin/sh\nprintf "__CMD__\\n" >> "$AGENT_DEVICE_TEST_ARGS_FILE"\nprintf "%s\\n" "$@" >> "$AGENT_DEVICE_TEST_ARGS_FILE"\nexit 0\n',
async ({ argsLogPath, device }) => {
await setAndroidSetting(device, 'permission', 'reset', 'com.example.app', {
permissionTarget: 'camera',
});
const logged = await fs.readFile(argsLogPath, 'utf8');
assert.match(logged, /shell\npm\nrevoke\ncom\.example\.app\nandroid\.permission\.CAMERA/);
},
);
});

test('setAndroidSetting permission rejects mode argument', async () => {
const device: DeviceInfo = {
platform: 'android',
id: 'emulator-5554',
name: 'Pixel',
kind: 'emulator',
booted: true,
};
await assert.rejects(
() =>
setAndroidSetting(device, 'permission', 'grant', 'com.example.app', {
permissionTarget: 'camera',
permissionMode: 'limited',
}),
(error: unknown) => {
assert.equal(error instanceof AppError, true);
assert.equal((error as AppError).code, 'INVALID_ARGS');
assert.match((error as AppError).message, /mode is only supported for photos/i);
return true;
},
);
});

test('setAndroidSetting permission grant photos falls back to legacy permission on older SDK', async () => {
await withMockedAdb(
'agent-device-android-permission-photos-fallback-',
[
'#!/bin/sh',
'printf "__CMD__\\n" >> "$AGENT_DEVICE_TEST_ARGS_FILE"',
'printf "%s\\n" "$@" >> "$AGENT_DEVICE_TEST_ARGS_FILE"',
'if [ "$1" = "-s" ]; then',
' shift',
' shift',
'fi',
'if [ "$1" = "shell" ] && [ "$2" = "getprop" ] && [ "$3" = "ro.build.version.sdk" ]; then',
' echo "32"',
' exit 0',
'fi',
'if [ "$1" = "shell" ] && [ "$2" = "pm" ] && [ "$3" = "grant" ] && [ "$5" = "android.permission.READ_EXTERNAL_STORAGE" ]; then',
' exit 0',
'fi',
'echo "unexpected args: $@" >&2',
'exit 1',
'',
].join('\n'),
async ({ argsLogPath, device }) => {
await setAndroidSetting(device, 'permission', 'grant', 'com.example.app', {
permissionTarget: 'photos',
});
const logged = await fs.readFile(argsLogPath, 'utf8');
assert.match(logged, /shell\ngetprop\nro\.build\.version\.sdk/);
assert.match(logged, /shell\npm\ngrant\ncom\.example\.app\nandroid\.permission\.READ_EXTERNAL_STORAGE/);
},
);
});
Loading