Files
software-mansion__argent/packages/tool-server/test/settings-permissions.test.ts
Hubert Gancarczyk fcf0ede624 feat(tool-server): settings-permissions tool to grant/deny/reset app permissions (#462)
## What

Adds a `settings-permissions` MCP tool that edits an app's runtime
permissions **out-of-band**, without navigating the system Settings UI.
It's a test-setup / out-of-band tool — the default way to change a
permission is still through the app (in-app toggle or the app-triggered
dialog). Reach for this only when the app can't get there:

- **pre-authorize** (`grant`) or **deny** a permission before the app
asks, so no dialog interrupts the flow
- **re-enable** one the user already denied (iOS never re-prompts once
denied)
- **reset** it so the first-run dialog reappears on next launch

Supported permissions: camera, microphone, photos, contacts,
notifications, calendar, location, location-always, media-library,
motion, reminders.

## How

**iOS simulator** — `xcrun simctl privacy grant|revoke|reset <service>
[bundleId]`.
- `notifications` has no simctl service → surfaced as an unsupported
error.
- An "invalid service" failure (e.g. `camera` on an Xcode that doesn't
model it) is wrapped as unsupported with a hint to list supported
services.
- A shutdown simulator error carries a `boot-device` hint.

**Android** — `pm grant` / `pm revoke` (plus `clear-permission-flags` on
reset) over adb.
- One abstract permission fans out to the concrete
`android.permission.*` set (fine/coarse location, per-media reads);
`location-always` grant also grants foreground location since background
alone is unusable.
- A `pm path` preflight rejects an uninstalled package (`pm
grant`/`revoke` exit 0 for a missing package, which would otherwise
report a false success). A transport-level adb failure is rethrown
rather than mislabeled as "not installed" (`isTerminalAdbError` is now
exported for this).
- Partial results land in `skipped`; the action fails only when pm
rejects **every** mapped permission.

## Supporting changes

- New failure codes: `ANDROID_SETTINGS_PERMISSION_FAILED`,
`IOS_SETTINGS_PERMISSION_FAILED`, `SETTINGS_PERMISSION_UNSUPPORTED`.
- New `argent-settings-permissions` skill, plus cross-references from
`argent-device-interact`, `argent-test-ui-flow`, and the argent rule
(with a strong "prefer the app dialog over this tool" steer).
- `bundleId` is regex-validated (same shell-injection guard as
launch-app/restart-app) and schema-required for grant/deny.

## Testing

`vitest` — **37/37 pass** (schema validation incl. shell-injection
payloads, iOS branch, Android branch incl. partial/total pm failures and
the preflight). `tsc --noEmit` clean for tool-server + registry + tests;
prettier clean.

---------

Co-authored-by: Filip131311 <contact@filipkaminski.com>
2026-07-13 22:45:06 +02:00

988 lines
43 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
const execFileMock = vi.fn();
vi.mock("node:child_process", async () => {
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
return { ...actual, execFile: (...args: unknown[]) => execFileMock(...args) };
});
vi.mock("../src/utils/adb", async (importOriginal) => {
const actual = await importOriginal<typeof import("../src/utils/adb")>();
return {
adbShell: vi.fn(async () => ""),
shellQuote: actual.shellQuote,
// Real classifier — the Android handler uses it to tell a transport/timeout
// failure (propagate) from a pm rejection (fold into `skipped`).
isTerminalAdbError: actual.isTerminalAdbError,
};
});
import type { DeviceInfo } from "@argent/registry";
import {
FAILURE_CODES,
FailureError,
getFailureSignal,
zodObjectToJsonSchema,
} from "@argent/registry";
import { settingsPermissionsTool } from "../src/tools/settings-permissions";
import { iosImpl } from "../src/tools/settings-permissions/platforms/ios";
import { androidImpl } from "../src/tools/settings-permissions/platforms/android";
import type { SettingsPermissionsParams } from "../src/tools/settings-permissions/types";
import { adbShell } from "../src/utils/adb";
import { __primeDepCacheForTests, __resetDepCacheForTests } from "../src/utils/check-deps";
const mockAdbShell = vi.mocked(adbShell);
const IOS_UDID = "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE";
const ANDROID_SERIAL = "emulator-5554";
const iosDevice: DeviceInfo = { id: IOS_UDID, platform: "ios", kind: "simulator" };
const androidDevice: DeviceInfo = { id: ANDROID_SERIAL, platform: "android", kind: "emulator" };
// FailureError attaches its FailureSignal under a non-enumerable symbol, so
// toMatchObject can't see it — assert through the public accessor instead.
// The `typeof code === "string"` guard is load-bearing: if a FAILURE_CODES
// member ever resolves to `undefined` (e.g. vitest loading a stale @argent/
// registry dist that predates a new code), the matcher would otherwise degrade
// to `undefined === undefined` and pass for *any* rejection — masking real
// failures. The string check turns that into an always-false matcher so the
// affected assertions fail loudly instead. (See the FAILURE_CODES type test.)
function failsWith(code: string): (err: unknown) => boolean {
return (err) => typeof code === "string" && getFailureSignal(err)?.error_code === code;
}
// The promisified execFile mock: resolve = success, reject-style = call with error.
// Resolves with a `{ stdout, stderr }` object so a consumer that reads `.stdout`
// (sim-remote's `run`, used by the ios-remote branch) sees the same shape the
// real execFile custom-promisify yields; handlers that ignore the return
// (local xcrun path) are unaffected.
function execFileSucceeds(): void {
execFileMock.mockImplementation(
(_cmd: string, _args: string[], _opts: unknown, cb: (err: unknown, out?: unknown) => void) => {
cb(null, { stdout: "", stderr: "" });
}
);
}
function execFileFails(message: string): void {
execFileMock.mockImplementation(
(_cmd: string, _args: string[], _opts: unknown, cb: (err: unknown) => void) => {
cb(Object.assign(new Error(message), { code: 1, stderr: message }));
}
);
}
// Default adb behavior: the `pm list packages` existence preflight finds the
// package (it prints a `package:<name>` line and exits 0), and every mutating pm
// command succeeds silently (pm's real success shape).
function adbDefaults(overrides?: (cmd: string) => string | Promise<string> | undefined): void {
mockAdbShell.mockImplementation(async (_serial, cmd) => {
const overridden = overrides?.(cmd);
if (overridden !== undefined) return overridden;
if (cmd.startsWith("pm list packages")) return "package:com.example.app";
return "";
});
}
beforeEach(() => {
execFileMock.mockReset();
mockAdbShell.mockReset();
adbDefaults();
});
describe("settings-permissions failure codes are defined", () => {
// Guards the whole suite: `failsWith` compares against these constants, and a
// stale @argent/registry dist that predates them would resolve them to
// `undefined`, silently defanging every `failsWith` assertion. Assert they are
// real strings so a missing code fails here loudly instead of hiding elsewhere.
it("resolves the three settings-permissions codes to strings", () => {
for (const code of [
"SETTINGS_PERMISSION_UNSUPPORTED",
"IOS_SETTINGS_PERMISSION_FAILED",
"ANDROID_SETTINGS_PERMISSION_FAILED",
] as const) {
expect(typeof FAILURE_CODES[code], code).toBe("string");
}
});
});
describe("settings-permissions schema", () => {
const schema = settingsPermissionsTool.zodSchema!;
it("accepts grant with a bundleId", () => {
const parsed = schema.safeParse({
udid: IOS_UDID,
action: "grant",
permission: "camera",
bundleId: "com.example.app",
});
expect(parsed.success).toBe(true);
});
it("rejects grant without a bundleId", () => {
const parsed = schema.safeParse({ udid: IOS_UDID, action: "grant", permission: "camera" });
expect(parsed.success).toBe(false);
});
it("rejects deny without a bundleId", () => {
const parsed = schema.safeParse({ udid: IOS_UDID, action: "deny", permission: "photos" });
expect(parsed.success).toBe(false);
});
it("rejects reset without a bundleId (bundleId is required for every action)", () => {
// Device-wide reset is gone: simctl's no-bundleId reset silently leaves
// existing per-app grants intact on recent iOS, so the tool is per-app only.
const parsed = schema.safeParse({ udid: IOS_UDID, action: "reset", permission: "location" });
expect(parsed.success).toBe(false);
});
it("rejects unknown permissions and actions", () => {
expect(
schema.safeParse({
udid: IOS_UDID,
action: "grant",
permission: "bluetooth",
bundleId: "com.example.app",
}).success
).toBe(false);
expect(
schema.safeParse({
udid: IOS_UDID,
action: "revoke",
permission: "camera",
bundleId: "com.example.app",
}).success
).toBe(false);
});
it("rejects an empty udid", () => {
const parsed = schema.safeParse({
udid: "",
action: "grant",
permission: "camera",
bundleId: "com.example.app",
});
expect(parsed.success).toBe(false);
});
// Same attack surface as launch-app/restart-app: bundleId is interpolated
// into an `adb shell` string, so shell metacharacters must never validate.
const injectionPayloads = [
"com.foo;rm -rf /sdcard",
"com.foo`touch /sdcard/owned`",
"com.foo$(touch /sdcard/owned)",
"com.foo && reboot",
"com.foo\nmalicious",
"com.foo'; id; echo '",
"--user",
"-X",
];
for (const payload of injectionPayloads) {
it(`rejects bundleId with shell metachars: ${JSON.stringify(payload)}`, () => {
const parsed = schema.safeParse({
udid: ANDROID_SERIAL,
action: "grant",
permission: "camera",
bundleId: payload,
});
expect(parsed.success).toBe(false);
});
}
it("derives a sane MCP JSON schema with bundleId required for every action", () => {
// bundleId is a plain required field now (no superRefine), so it must appear
// in the JSON schema's `required` list — pin the derivation so a zod upgrade
// can't silently drop it and let a bundleId-less call reach the handler.
const json = zodObjectToJsonSchema(schema) as {
required?: string[];
properties?: Record<string, { pattern?: string; enum?: string[] }>;
};
expect(json.required).toEqual(["udid", "action", "permission", "bundleId"]);
expect(json.properties?.bundleId?.pattern).toBe("^[A-Za-z_][A-Za-z0-9._-]*$");
expect(json.properties?.action?.enum).toEqual(["grant", "deny", "reset"]);
expect(json.properties?.permission?.enum).toHaveLength(11);
});
});
describe("settings-permissions iOS branch", () => {
function params(overrides: Partial<SettingsPermissionsParams>): SettingsPermissionsParams {
return {
udid: IOS_UDID,
action: "grant",
permission: "microphone",
bundleId: "com.example.app",
...overrides,
};
}
it("grant runs `simctl privacy <udid> grant <service> <bundleId>`", async () => {
execFileSucceeds();
const result = await iosImpl.handler({}, params({}), iosDevice);
expect(execFileMock).toHaveBeenCalledTimes(1);
const [cmd, args] = execFileMock.mock.calls[0]!;
expect(cmd).toBe("xcrun");
expect(args).toEqual(["simctl", "privacy", IOS_UDID, "grant", "microphone", "com.example.app"]);
expect(result).toEqual({
action: "grant",
permission: "microphone",
bundleId: "com.example.app",
applied: ["microphone"],
});
});
it("deny maps to simctl's `revoke`", async () => {
execFileSucceeds();
await iosImpl.handler({}, params({ action: "deny", permission: "photos" }), iosDevice);
const [, args] = execFileMock.mock.calls[0]!;
expect(args).toEqual(["simctl", "privacy", IOS_UDID, "revoke", "photos", "com.example.app"]);
});
it("reset is per-app: `simctl privacy <udid> reset <service> <bundleId>`", async () => {
// A device-wide reset (no bundleId) is intentionally not supported — it is a
// no-op for existing per-app grants on recent iOS, so reset always targets
// the one app and echoes its bundleId back.
execFileSucceeds();
const result = await iosImpl.handler(
{},
params({ action: "reset", permission: "location-always" }),
iosDevice
);
const [, args] = execFileMock.mock.calls[0]!;
expect(args).toEqual([
"simctl",
"privacy",
IOS_UDID,
"reset",
"location-always",
"com.example.app",
]);
expect(result.bundleId).toBe("com.example.app");
});
it("notifications is rejected as unsupported without calling simctl", async () => {
await expect(
iosImpl.handler({}, params({ permission: "notifications" }), iosDevice)
).rejects.toSatisfy(failsWith(FAILURE_CODES.SETTINGS_PERMISSION_UNSUPPORTED));
expect(execFileMock).not.toHaveBeenCalled();
});
it("a camera failure surfaces IOS_SETTINGS_PERMISSION_FAILED with a list-services hint", async () => {
// simctl rejects a service it doesn't model with a generic CoreSimulator
// NSError, indistinguishable from any other failure — so instead of parsing
// its wording, camera (the one service simulators may lack) always carries a
// hint to list the supported services.
execFileFails(
"An error was encountered processing the command (domain=NSPOSIXErrorDomain, code=1):\n" +
"Simulator device failed to complete the requested operation.\nOperation not permitted\n" +
"Underlying error (domain=NSPOSIXErrorDomain, code=1):\n\tFailed to set access"
);
const rejection = expect(
iosImpl.handler({}, params({ permission: "camera" }), iosDevice)
).rejects;
await rejection.toSatisfy(failsWith(FAILURE_CODES.IOS_SETTINGS_PERMISSION_FAILED));
await rejection.toThrow(/run `xcrun simctl privacy` to list the services it supports/);
});
it("a non-camera failure does not get the camera hint", async () => {
execFileFails("Simulator device failed to complete the requested operation.");
await expect(
iosImpl.handler({}, params({ permission: "microphone" }), iosDevice)
).rejects.not.toThrow(/list the services it supports/);
});
it("other simctl failures surface as IOS_SETTINGS_PERMISSION_FAILED", async () => {
execFileFails("Invalid device: nope");
await expect(iosImpl.handler({}, params({}), iosDevice)).rejects.toSatisfy(
failsWith(FAILURE_CODES.IOS_SETTINGS_PERMISSION_FAILED)
);
});
it("a shutdown-simulator failure carries a boot-device hint", async () => {
execFileFails(
"An error was encountered processing the command (domain=NSCocoaErrorDomain, code=405):\nUnable to lookup in current state: Shutdown"
);
await expect(iosImpl.handler({}, params({}), iosDevice)).rejects.toThrow(
/must be booted first — use boot-device/
);
});
it("a camera failure on a shutdown simulator gets the boot-device hint, not the camera hint", async () => {
// cameraHint is gated on `!shutdownHint`, so a shutdown error must win even
// for camera — otherwise the agent is told to list services when the real
// fix is to boot the device. Pins that guard (the camera-hint and shutdown
// tests above never pair the two).
execFileFails(
"An error was encountered processing the command (domain=NSCocoaErrorDomain, code=405):\nUnable to lookup in current state: Shutdown"
);
const rejection = expect(
iosImpl.handler({}, params({ permission: "camera" }), iosDevice)
).rejects;
await rejection.toThrow(/must be booted first — use boot-device/);
await rejection.not.toThrow(/list the services it supports/);
});
it("maps each supported permission to its simctl privacy service (identity)", async () => {
// Lock IOS_SERVICES: microphone/photos/location-always are asserted above,
// this pins the rest so an accidental empty/typo (which would surface a false
// "unsupported") is caught — notably `reminders`, the sole iOS-only service.
execFileSucceeds();
const cases: Array<[SettingsPermissionsParams["permission"], string]> = [
["contacts", "contacts"],
["calendar", "calendar"],
["location", "location"],
["media-library", "media-library"],
["motion", "motion"],
["reminders", "reminders"],
];
for (const [permission, service] of cases) {
execFileMock.mockClear();
const result = await iosImpl.handler({}, params({ permission }), iosDevice);
// `location` grant runs a `get_app_container` install probe first, so the
// privacy call isn't necessarily calls[0] — find it by verb.
const privacyCall = execFileMock.mock.calls.find((c) => (c[1] as string[])[1] === "privacy");
expect(privacyCall?.[1], permission).toEqual([
"simctl",
"privacy",
IOS_UDID,
"grant",
service,
"com.example.app",
]);
expect(result.applied, permission).toEqual([service]);
}
});
it("photos fans out to the `photos` and `photos-add` TCC services", async () => {
// add-only access lives in the separate `photos-add` service, so a deny/reset
// that touched only `photos` would leave a surviving add-only grant. Both are
// targeted; `photos-add` is best-effort (see the next test).
execFileSucceeds();
for (const action of ["grant", "deny", "reset"] as const) {
execFileMock.mockClear();
const result = await iosImpl.handler({}, params({ action, permission: "photos" }), iosDevice);
const services = execFileMock.mock.calls.map((c) => (c[1] as string[])[4]);
expect(services, action).toEqual(["photos", "photos-add"]);
expect(result.applied, action).toEqual(["photos", "photos-add"]);
}
});
it("a runtime that rejects the best-effort `photos-add` service still succeeds on `photos`", async () => {
// `photos` (the primary) must succeed; a `photos-add` this runtime doesn't
// model must be skipped silently, not fail the whole action.
execFileMock.mockImplementation(
(_cmd: string, args: string[], _opts: unknown, cb: (err: unknown, out?: string) => void) => {
if (args[4] === "photos-add") {
cb(Object.assign(new Error("Failed to set access"), { code: 1 }));
} else {
cb(null, "");
}
}
);
const result = await iosImpl.handler({}, params({ permission: "photos" }), iosDevice);
expect(result.applied).toEqual(["photos"]);
});
it("granting location to an uninstalled app fails instead of a false success", async () => {
// location auth isn't TCC-backed and doesn't persist pre-install, so a grant
// to a missing app records nothing — verify install first (get_app_container
// exits non-zero for a missing app) and reject rather than return applied.
execFileMock.mockImplementation(
(_cmd: string, args: string[], _opts: unknown, cb: (err: unknown, out?: string) => void) => {
if (args[0] === "simctl" && args[1] === "get_app_container") {
cb(Object.assign(new Error("No such file or directory"), { code: 1 }));
} else {
cb(null, "");
}
}
);
for (const permission of ["location", "location-always"] as const) {
const rejection = expect(
iosImpl.handler({}, params({ action: "grant", permission }), iosDevice)
).rejects;
await rejection.toSatisfy(failsWith(FAILURE_CODES.IOS_SETTINGS_PERMISSION_FAILED));
await rejection.toThrow(/the app is not installed/);
}
// The privacy grant must never have run for the missing app.
const ranPrivacy = execFileMock.mock.calls.some((c) => (c[1] as string[])[1] === "privacy");
expect(ranPrivacy).toBe(false);
});
it("granting location to an installed app runs the privacy grant", async () => {
// get_app_container succeeds (installed) → the grant proceeds normally.
execFileSucceeds();
const result = await iosImpl.handler(
{},
params({ action: "grant", permission: "location" }),
iosDevice
);
expect(result.applied).toEqual(["location"]);
const privacyCall = execFileMock.mock.calls.find((c) => (c[1] as string[])[1] === "privacy");
expect(privacyCall?.[1]).toEqual([
"simctl",
"privacy",
IOS_UDID,
"grant",
"location",
"com.example.app",
]);
});
it("denying or resetting location does NOT require the app to be installed", async () => {
// The install guard is grant-only: a deny/reset of location for a missing
// app is a harmless no-op on device, so it must not probe install state.
execFileSucceeds();
for (const action of ["deny", "reset"] as const) {
execFileMock.mockClear();
await iosImpl.handler({}, params({ action, permission: "location" }), iosDevice);
const probed = execFileMock.mock.calls.some(
(c) => (c[1] as string[])[1] === "get_app_container"
);
expect(probed, action).toBe(false);
}
});
it("granting location on a shutdown simulator surfaces the boot hint, not a false 'not installed'", async () => {
// get_app_container needs a booted sim: on a shutdown sim it fails with
// "Unable to lookup in current state: Shutdown" for installed AND missing
// apps alike, so the install guard must NOT read that as "not installed".
// It must fall through to the privacy grant, which fails the same way and
// gets the boot hint. Regression guard: a blanket catch→false previously
// mislabeled an installed app as uninstalled on a shutdown sim and steered
// the agent to reinstall instead of boot.
execFileMock.mockImplementation(
(_cmd: string, _args: string[], _opts: unknown, cb: (err: unknown) => void) => {
cb(
Object.assign(new Error("Unable to lookup in current state: Shutdown"), {
code: 149,
})
);
}
);
const rejection = expect(
iosImpl.handler({}, params({ action: "grant", permission: "location" }), iosDevice)
).rejects;
await rejection.toThrow(/must be booted first — use boot-device/);
await rejection.not.toThrow(/the app is not installed/);
});
it("an unrecognized install-probe failure is not a 'not installed' verdict — the grant falls through", async () => {
// The probe's contract reserves `false` for a definitive "not installed"
// (the shapes get_app_container emits for a missing app) and `undefined`
// for "couldn't answer". A stale/deleted UDID ("Invalid device") or a probe
// killed at its timeout (no recognizable text) must NOT become a definitive
// verdict: the guard is skipped and the privacy call decides. Regression
// guard: a blanket catch→false steered the agent to install an app that was
// already installed whenever the probe itself broke.
for (const probeError of [
`Invalid device: ${IOS_UDID}`,
"Command failed: xcrun simctl get_app_container", // killed at timeout — no stderr text
]) {
execFileMock.mockImplementation(
(
_cmd: string,
args: string[],
_opts: unknown,
cb: (err: unknown, out?: unknown) => void
) => {
if (args[0] === "simctl" && args[1] === "get_app_container") {
cb(Object.assign(new Error(probeError), { code: 1 }));
} else {
cb(null, { stdout: "", stderr: "" });
}
}
);
execFileMock.mockClear();
const result = await iosImpl.handler(
{},
params({ action: "grant", permission: "location" }),
iosDevice
);
expect(result.applied, probeError).toEqual(["location"]);
const ranPrivacy = execFileMock.mock.calls.some((c) => (c[1] as string[])[1] === "privacy");
expect(ranPrivacy, probeError).toBe(true);
}
});
it("both definitive missing-app probe shapes reject the location grant", async () => {
// get_app_container's two real missing-app shapes stay definitive verdicts.
for (const probeError of ["No such file or directory", "com.example.app is not installed"]) {
execFileMock.mockImplementation(
(
_cmd: string,
args: string[],
_opts: unknown,
cb: (err: unknown, out?: unknown) => void
) => {
if (args[0] === "simctl" && args[1] === "get_app_container") {
cb(Object.assign(new Error(probeError), { code: 1 }));
} else {
cb(null, { stdout: "", stderr: "" });
}
}
);
execFileMock.mockClear();
await expect(
iosImpl.handler({}, params({ action: "grant", permission: "location" }), iosDevice)
).rejects.toThrow(/the app is not installed/);
}
});
});
describe("settings-permissions Android branch", () => {
function params(overrides: Partial<SettingsPermissionsParams>): SettingsPermissionsParams {
return {
udid: ANDROID_SERIAL,
action: "grant",
permission: "camera",
bundleId: "com.example.app",
...overrides,
};
}
it("grant runs `pm grant` for the mapped permission", async () => {
const result = await androidImpl.handler({}, params({}), androidDevice);
expect(mockAdbShell).toHaveBeenCalledWith(
ANDROID_SERIAL,
"pm grant 'com.example.app' android.permission.CAMERA",
expect.anything()
);
expect(result.applied).toEqual(["android.permission.CAMERA"]);
expect(result.skipped).toBeUndefined();
});
it("deny runs `pm revoke`", async () => {
await androidImpl.handler({}, params({ action: "deny" }), androidDevice);
expect(mockAdbShell).toHaveBeenCalledWith(
ANDROID_SERIAL,
"pm revoke 'com.example.app' android.permission.CAMERA",
expect.anything()
);
});
it("reset revokes and clears the user-set permission flags", async () => {
await androidImpl.handler({}, params({ action: "reset" }), androidDevice);
const commands = mockAdbShell.mock.calls.map((c) => c[1]);
expect(commands).toEqual([
"pm list packages 'com.example.app'",
"pm revoke 'com.example.app' android.permission.CAMERA",
"pm clear-permission-flags 'com.example.app' android.permission.CAMERA user-set user-fixed",
]);
});
it("reset skips clear-permission-flags when the revoke itself fails", async () => {
adbDefaults((cmd) => {
if (cmd.startsWith("pm revoke")) throw new Error("pm revoke exited with code 255");
return undefined;
});
await expect(
androidImpl.handler({}, params({ action: "reset" }), androidDevice)
).rejects.toSatisfy(failsWith(FAILURE_CODES.ANDROID_SETTINGS_PERMISSION_FAILED));
const commands = mockAdbShell.mock.calls.map((c) => c[1]);
expect(commands.some((c) => c.startsWith("pm clear-permission-flags"))).toBe(false);
});
it("reset still counts a revoked permission whose flags could not be cleared", async () => {
// clear-permission-flags is best-effort: it first appears in Android 13
// (API 33) and can't undo the revoke, so its failure must NOT demote a
// permission the revoke already changed. Revoke succeeded here -> applied,
// not skipped, no error (previously this reported a misleading total failure).
adbDefaults((cmd) => {
if (cmd.startsWith("pm clear-permission-flags")) {
throw new Error("pm clear-permission-flags exited with code 255");
}
return undefined;
});
const result = await androidImpl.handler({}, params({ action: "reset" }), androidDevice);
expect(result.applied).toEqual(["android.permission.CAMERA"]);
expect(result.skipped).toBeUndefined();
});
it("a transport-level adb failure at the preflight is rethrown, not mislabeled as not-installed", async () => {
adbDefaults((cmd) => {
if (cmd.startsWith("pm list packages")) {
throw new Error("adb: device 'emulator-5554' not found");
}
return undefined;
});
const rejection = expect(androidImpl.handler({}, params({}), androidDevice)).rejects;
await rejection.toThrow(/device 'emulator-5554' not found/);
await rejection.not.toThrow(/not installed/);
});
it("a slow/unavailable package manager at the preflight is rethrown, not mislabeled as not-installed", async () => {
// `pm list packages` exits 0 for a missing package, so any THROW here is a
// real failure (timeout/kill, or pm not up yet right after boot) — it must
// surface the real cause, not "not installed" (the old `pm path` probe's bug).
adbDefaults((cmd) => {
if (cmd.startsWith("pm list packages")) {
throw new Error("Could not access the Package Manager. Is the system running?");
}
return undefined;
});
const rejection = expect(androidImpl.handler({}, params({}), androidDevice)).rejects;
await rejection.toThrow(/Could not access the Package Manager/);
await rejection.not.toThrow(/not installed/);
});
it("a package pm cannot resolve fails with not-found instead of a silent success", async () => {
// `pm list packages <pkg>` exits 0 with NO output for a missing package
// (verified on API 36), so the handler detects the absent `package:` line
// and never issues a grant/revoke (which would exit 0 for a missing package).
mockAdbShell.mockImplementation(async (_serial, cmd) => {
if (cmd.startsWith("pm list packages")) return "";
return "";
});
await expect(androidImpl.handler({}, params({}), androidDevice)).rejects.toSatisfy(
failsWith(FAILURE_CODES.ANDROID_SETTINGS_PERMISSION_FAILED)
);
const commands = mockAdbShell.mock.calls.map((c) => c[1]);
expect(commands).toEqual(["pm list packages 'com.example.app'"]);
});
it("does not treat a substring package match as installed", async () => {
// `pm list packages` filters by substring, so a request for `com.example.app`
// can return only `com.example.app.helper`; the exact-line check must reject
// that rather than operate on the wrong package.
mockAdbShell.mockImplementation(async (_serial, cmd) => {
if (cmd.startsWith("pm list packages")) return "package:com.example.app.helper";
return "";
});
await expect(androidImpl.handler({}, params({}), androidDevice)).rejects.toSatisfy(
failsWith(FAILURE_CODES.ANDROID_SETTINGS_PERMISSION_FAILED)
);
});
it("finds the target package among substring siblings", async () => {
// The realistic installed shape: `pm list packages <pkg>` filters by
// substring, so an installed app usually returns several lines — the target
// plus every package whose name contains it (e.g. `com.google.android.gms`
// returns both `...gms.supervision` and `...gms`). The per-line `.some(...)`
// must match the exact target line among the siblings, in any order; a switch
// to whole-output or first-line matching would read a real installed app as
// "not installed" while every other Android test stayed green.
mockAdbShell.mockImplementation(async (_serial, cmd) => {
if (cmd.startsWith("pm list packages")) {
return "package:com.example.app.helper\npackage:com.example.app\npackage:com.example.app.debug";
}
return "";
});
const result = await androidImpl.handler({}, params({}), androidDevice);
expect(result.applied).toEqual(["android.permission.CAMERA"]);
expect(mockAdbShell).toHaveBeenCalledWith(
ANDROID_SERIAL,
"pm grant 'com.example.app' android.permission.CAMERA",
expect.anything()
);
});
it("location fans out to fine + coarse", async () => {
const result = await androidImpl.handler({}, params({ permission: "location" }), androidDevice);
expect(result.applied).toEqual([
"android.permission.ACCESS_FINE_LOCATION",
"android.permission.ACCESS_COARSE_LOCATION",
]);
});
it("granting location-always also grants the foreground location permissions", async () => {
// Background location alone is unusable on Android — the OS requires the
// foreground permissions to be granted too.
const result = await androidImpl.handler(
{},
params({ permission: "location-always" }),
androidDevice
);
expect(result.applied).toEqual([
"android.permission.ACCESS_FINE_LOCATION",
"android.permission.ACCESS_COARSE_LOCATION",
"android.permission.ACCESS_BACKGROUND_LOCATION",
]);
});
it("denying location-always touches only the background permission", async () => {
const result = await androidImpl.handler(
{},
params({ action: "deny", permission: "location-always" }),
androidDevice
);
expect(result.applied).toEqual(["android.permission.ACCESS_BACKGROUND_LOCATION"]);
});
it("maps each abstract permission to its concrete android.permission.* set", async () => {
// Lock the ANDROID_PERMISSIONS table: camera/photos/location(-always) are
// covered above, this pins the rest so an accidental emptying/typo of a
// mapping surfaces here instead of silently granting the wrong permission or
// reporting "unsupported". Notably notifications→POST_NOTIFICATIONS (a
// primary Android use case) and the two-permission contacts/calendar/
// media-library fan-outs, none of which had a mapping assertion.
const cases: Array<[SettingsPermissionsParams["permission"], string[]]> = [
["microphone", ["android.permission.RECORD_AUDIO"]],
["contacts", ["android.permission.READ_CONTACTS", "android.permission.WRITE_CONTACTS"]],
["notifications", ["android.permission.POST_NOTIFICATIONS"]],
["calendar", ["android.permission.READ_CALENDAR", "android.permission.WRITE_CALENDAR"]],
[
"media-library",
["android.permission.READ_MEDIA_AUDIO", "android.permission.READ_EXTERNAL_STORAGE"],
],
["motion", ["android.permission.ACTIVITY_RECOGNITION"]],
];
for (const [permission, expected] of cases) {
const result = await androidImpl.handler({}, params({ permission }), androidDevice);
expect(result.applied, permission).toEqual(expected);
}
});
it("partial pm failures (a real device throws on exit 255) land in `skipped`, not an error", async () => {
// photos → READ_MEDIA_IMAGES ok, READ_MEDIA_VIDEO ok, READ_EXTERNAL_STORAGE
// rejected. On a real device (verified on API 34) `pm grant` for a
// non-changeable permission exits 255 and writes the SecurityException to
// stderr, so adbShell THROWS and the failure is handled in runPm's catch
// branch (not the exit-0 stdout inspection). Exercise that realistic path.
adbDefaults((cmd) => {
if (cmd.includes("READ_EXTERNAL_STORAGE")) {
throw new Error(
"adb -s emulator-5554 shell pm grant 'com.example.app' android.permission.READ_EXTERNAL_STORAGE failed: " +
"java.lang.SecurityException: Permission android.permission.READ_EXTERNAL_STORAGE requested by com.example.app is not a changeable permission type\n" +
"\tat com.android.server.pm.permission.PermissionManagerServiceImpl.grantRuntimePermissionInternal(PermissionManagerServiceImpl.java:1423)"
);
}
return undefined;
});
const result = await androidImpl.handler({}, params({ permission: "photos" }), androidDevice);
expect(result.applied).toEqual([
"android.permission.READ_MEDIA_IMAGES",
"android.permission.READ_MEDIA_VIDEO",
"android.permission.READ_MEDIA_VISUAL_USER_SELECTED",
]);
expect(result.skipped).toEqual(["android.permission.READ_EXTERNAL_STORAGE"]);
});
it("photos includes READ_MEDIA_VISUAL_USER_SELECTED so partial-access is cleared", async () => {
// On API 34+ the platform auto-adds USER_SELECTED alongside READ_MEDIA_*, and
// the partial-access dialog grants it persistently. Omitting it would let
// `deny photos` leave the app passing its partial-access check. Pin that the
// mapping fans out to it (pre-34 devices reject it → it lands in `skipped`).
const result = await androidImpl.handler(
{},
params({ action: "deny", permission: "photos" }),
androidDevice
);
expect(result.applied).toContain("android.permission.READ_MEDIA_VISUAL_USER_SELECTED");
expect(mockAdbShell).toHaveBeenCalledWith(
ANDROID_SERIAL,
"pm revoke 'com.example.app' android.permission.READ_MEDIA_VISUAL_USER_SELECTED",
expect.anything()
);
});
it("pm rejecting every mapped permission (thrown on exit 255) raises the failure and strips Java stack frames", async () => {
// The realistic API 34+ path: pm exits 255, adbShell throws, and the message
// (built from adb's stderr) carries the exception plus its Java stack frames.
// runPm's catch branch runs stripStackFrames on the THROWN err.message.
adbDefaults((cmd) => {
if (cmd.startsWith("pm grant")) {
throw new Error(
"adb -s emulator-5554 shell pm grant 'com.example.app' android.permission.CAMERA failed: " +
"java.lang.SecurityException: Package com.example.app has not requested permission android.permission.CAMERA\n" +
"\tat com.android.server.pm.permission.PermissionManagerServiceImpl.grantRuntimePermissionInternal(PermissionManagerServiceImpl.java:1423)\n" +
"\tat android.os.Binder.execTransact(Binder.java:1275)"
);
}
return undefined;
});
const rejection = expect(androidImpl.handler({}, params({}), androidDevice)).rejects;
await rejection.toSatisfy(failsWith(FAILURE_CODES.ANDROID_SETTINGS_PERMISSION_FAILED));
await rejection.toSatisfy(
(err: unknown) =>
err instanceof Error &&
err.message.includes("has not requested permission") &&
!err.message.includes("at com.android.server") &&
!err.message.includes("at android.os.Binder")
);
});
it("pm reporting a failure as exit-0 stdout is still treated as a failure", async () => {
// Some pm builds print the SecurityException to stdout and exit 0; runPm's
// stdout inspection must still classify a non-`Success` output as a failure.
adbDefaults((cmd) =>
cmd.startsWith("pm grant")
? "Exception occurred while executing 'grant':\njava.lang.SecurityException: Package com.example.app has not requested permission android.permission.CAMERA"
: undefined
);
await expect(androidImpl.handler({}, params({}), androidDevice)).rejects.toSatisfy(
failsWith(FAILURE_CODES.ANDROID_SETTINGS_PERMISSION_FAILED)
);
});
it("reminders is rejected as unsupported (no Android equivalent)", async () => {
await expect(
androidImpl.handler({}, params({ permission: "reminders" }), androidDevice)
).rejects.toSatisfy(failsWith(FAILURE_CODES.SETTINGS_PERMISSION_UNSUPPORTED));
expect(mockAdbShell).not.toHaveBeenCalled();
});
it("a device dropping mid-fan-out propagates the transport error, not a `skipped` entry", async () => {
// If the device disconnects after one permission already applied, the
// remaining pm calls are not manifest rejections — the transport is dead.
// The handler must surface adb's real cause (a terminal "device not found"),
// not return success with the dead-device permissions in `skipped`.
adbDefaults((cmd) => {
if (cmd.includes("READ_MEDIA_VIDEO"))
throw new Error("adb: device 'emulator-5554' not found");
return undefined;
});
const rejection = expect(
androidImpl.handler({}, params({ permission: "photos" }), androidDevice)
).rejects;
await rejection.toThrow(/device 'emulator-5554' not found/);
await rejection.not.toThrow(/every mapped runtime permission was rejected/);
});
it("an adb daemon-leg failure mid-fan-out propagates as transport, not a `skipped` entry", async () => {
// The adb client↔daemon leg (server restarting / daemon down) fails with
// shapes `isTerminalAdbError` doesn't match — `Connection reset by peer`,
// `cannot connect to daemon`. They still mean the pm call never ran, so the
// handler must propagate them, not fold the lost permission into `skipped`
// under a success result (which would report a revoke that never happened).
adbDefaults((cmd) => {
if (cmd.includes("READ_MEDIA_VIDEO"))
throw new Error("adb: protocol fault (couldn't read status): Connection reset by peer");
return undefined;
});
const rejection = expect(
androidImpl.handler({}, params({ action: "deny", permission: "photos" }), androidDevice)
).rejects;
await rejection.toThrow(/Connection reset by peer/);
await rejection.not.toThrow(/every mapped runtime permission was rejected/);
});
it("a timed-out pm call propagates the classified FailureError with its telemetry intact", async () => {
// adbShell classifies a killed/timed-out call as a FailureError with
// error_kind "timeout" + subprocess metadata. A wedged device must keep that
// classification, not be relabelled as a generic manifest failure — the iOS
// branch forwards the same metadata, and sibling Android tools propagate it.
const timeoutErr = new FailureError("pm grant ... (killed=true signal=SIGKILL)", {
error_code: FAILURE_CODES.ANDROID_ADB_COMMAND_FAILED,
failure_stage: "android_adb_command",
failure_area: "tool_server",
error_kind: "timeout",
});
adbDefaults((cmd) => {
if (cmd.startsWith("pm grant")) throw timeoutErr;
return undefined;
});
await expect(androidImpl.handler({}, params({}), androidDevice)).rejects.toSatisfy(
(err: unknown) => err === timeoutErr && getFailureSignal(err)?.error_kind === "timeout"
);
});
it("resetting location-always touches only the background permission (not fine/coarse)", async () => {
// The grant→foreground fan-out is grant-only: a reset must not also revoke
// ACCESS_FINE/COARSE_LOCATION ("taking away 'always' shouldn't strip 'while
// in use'"). Pins that the fan-out condition stays `action === "grant"` — a
// loosening to `action !== "deny"` would revoke fine+coarse here.
await androidImpl.handler(
{},
params({ action: "reset", permission: "location-always" }),
androidDevice
);
const commands = mockAdbShell.mock.calls.map((c) => c[1]);
expect(commands).toEqual([
"pm list packages 'com.example.app'",
"pm revoke 'com.example.app' android.permission.ACCESS_BACKGROUND_LOCATION",
"pm clear-permission-flags 'com.example.app' android.permission.ACCESS_BACKGROUND_LOCATION user-set user-fixed",
]);
});
});
describe("settings-permissions dispatch wiring (through tool.execute)", () => {
// The per-branch tests above call iosImpl/androidImpl directly, so they can't
// catch a mis-wired dispatch table (e.g. `ios: androidImpl, android: iosImpl`,
// which typechecks and would run `pm` against iOS UDIDs). These drive the real
// `execute` with shaped udids and assert each platform reaches its OWN binary
// — xcrun for iOS, adb for Android, sim-remote for ios-remote — so any swap of
// the branches fails here. Dep cache is primed so `ensureDeps` doesn't shell
// out to `command -v` and perturb `execFileMock` call counts.
beforeEach(() => {
__resetDepCacheForTests();
__primeDepCacheForTests(["xcrun", "adb", "sim-remote"]);
});
it("an iOS udid runs `xcrun simctl privacy`, never adb", async () => {
execFileSucceeds();
const result = await settingsPermissionsTool.execute(
{},
{ udid: IOS_UDID, action: "grant", permission: "microphone", bundleId: "com.example.app" }
);
expect(result.applied).toEqual(["microphone"]);
const [cmd, args] = execFileMock.mock.calls[0]!;
expect(cmd).toBe("xcrun");
expect((args as string[]).slice(0, 2)).toEqual(["simctl", "privacy"]);
expect(mockAdbShell).not.toHaveBeenCalled();
});
it("an Android serial runs `pm` over adb, never xcrun", async () => {
// Give execFile a resolving impl so an accidental ios/android swap fails via
// a clean assertion (wrong `applied` shape) instead of hanging on an
// unconfigured mock that never invokes its callback.
execFileSucceeds();
const result = await settingsPermissionsTool.execute(
{},
{ udid: ANDROID_SERIAL, action: "grant", permission: "camera", bundleId: "com.example.app" }
);
expect(result.applied).toEqual(["android.permission.CAMERA"]);
expect(mockAdbShell).toHaveBeenCalledWith(
ANDROID_SERIAL,
"pm grant 'com.example.app' android.permission.CAMERA",
expect.anything()
);
expect(execFileMock).not.toHaveBeenCalled();
});
it("a `remote:` udid routes to the ios-remote branch (sim-remote)", async () => {
// The ios-remote branch + appleRemote capability let a sim-remote setup
// pre-set permissions, matching the launch-app / open-url family. sim-remote
// shells out via the same execFile, so assert the `sim-remote` invocation.
execFileSucceeds();
const result = await settingsPermissionsTool.execute(
{},
{
udid: `remote:${IOS_UDID}`,
action: "grant",
permission: "microphone",
bundleId: "com.example.app",
}
);
expect(result.applied).toEqual(["microphone"]);
const [cmd, args] = execFileMock.mock.calls[0]!;
expect(cmd).toBe("sim-remote");
expect((args as string[]).slice(0, 2)).toEqual(["simctl", "privacy"]);
expect(mockAdbShell).not.toHaveBeenCalled();
});
it("`grant location` on a `remote:` udid skips the install probe (sim-remote has no app-container verb)", async () => {
// The remote backend omits `isInstalled`, so the location pre-grant guard is
// skipped there by design — a pre-install grant reports success while
// recording nothing, which the SKILL/tool description scope to local sims.
// Pin the skip so a future remote install probe (or a guard change that
// starts requiring one) shows up as a deliberate contract change here.
execFileSucceeds();
const result = await settingsPermissionsTool.execute(
{},
{
udid: `remote:${IOS_UDID}`,
action: "grant",
permission: "location",
bundleId: "com.example.app",
}
);
expect(result.applied).toEqual(["location"]);
const probed = execFileMock.mock.calls.some((c) =>
(c[1] as string[]).includes("get_app_container")
);
expect(probed).toBe(false);
const [cmd, args] = execFileMock.mock.calls[0]!;
expect(cmd).toBe("sim-remote");
expect((args as string[]).slice(0, 2)).toEqual(["simctl", "privacy"]);
});
});