diff --git a/docs/adb-device-analysis.md b/docs/adb-device-analysis.md index 3921cbe74..68a82f09d 100644 --- a/docs/adb-device-analysis.md +++ b/docs/adb-device-analysis.md @@ -98,7 +98,7 @@ existing analysis families directly, for example | `collect_adb_bugreport` | read | Device-generated bugreport zip, digested | | `push_adb_file` | **mutates device** | One local file to a device path (e.g. pushing tools into `/data/local/tmp`); checks for existing targets and refuses them without explicit overwrite (the check is not atomic); compares digests when device `sha256sum` is available | | `install_adb_package` | **mutates device** | Local APK via `adb install [-r]`; preserves its Success/Failure verdict, records `replace_requested`, and leaves actual replacement `null` because prior package state is not observed | -| `uninstall_adb_package` | **mutates device** | Removes a package and its data for user 0 | +| `uninstall_adb_package` | **mutates device** | Requests package removal for user 0 via `adb shell pm uninstall --user 0`, without data retention | | `start_adb_app` | **mutates device** | Resolves the package's launcher activity (`cmd package resolve-activity --brief`) and starts it (`am start -n`) | | `start_adb_activity` | **mutates device** | Starts an intent (`am start -a` with optional URI, component, and up to 16 typed extras); preserves the activity manager's verdict | | `stop_adb_app` | **mutates device** | Force-stops every process of a package, ending a dynamic-analysis run | diff --git a/src/adb/AdbProvider.test.ts b/src/adb/AdbProvider.test.ts index dfe81d510..2566c86b8 100644 --- a/src/adb/AdbProvider.test.ts +++ b/src/adb/AdbProvider.test.ts @@ -1515,7 +1515,7 @@ exit 1`, it("uninstalls a package and reports the device's refusal for absent ones", async () => { const { binary } = await writeAdbStub( () => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi -if [ "$1" = "-s" ] && [ "$3" = "uninstall" ]; then printf 'Success\\n'; exit 0; fi +if [ "$#" = 8 ] && [ "$1" = "-s" ] && [ "$3" = "shell" ] && [ "$4" = "pm" ] && [ "$5" = "uninstall" ] && [ "$6" = "--user" ] && [ "$7" = 0 ]; then printf 'Success\\n'; exit 0; fi exit 1`, ); const removed = await evidenceResult(providerOver(binary), { @@ -1526,7 +1526,7 @@ exit 1`, const { binary: refusing } = await writeAdbStub( () => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi -if [ "$1" = "-s" ] && [ "$3" = "uninstall" ]; then printf 'Failure [not installed for 0]\\n'; exit 0; fi +if [ "$#" = 8 ] && [ "$1" = "-s" ] && [ "$3" = "shell" ] && [ "$4" = "pm" ] && [ "$5" = "uninstall" ] && [ "$6" = "--user" ] && [ "$7" = 0 ]; then printf 'Failure [not installed for 0]\\n'; exit 0; fi exit 1`, ); const projected = await refusedBy(providerOver(refusing), { @@ -1570,7 +1570,7 @@ exit 1`, it("preserves the device's uninstall refusal when adb exits nonzero", async () => { const { binary } = await writeAdbStub( () => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi -if [ "$1" = "-s" ] && [ "$3" = "uninstall" ]; then printf 'Failure [not installed for 0]\\n'; exit 1; fi +if [ "$#" = 8 ] && [ "$1" = "-s" ] && [ "$3" = "shell" ] && [ "$4" = "pm" ] && [ "$5" = "uninstall" ] && [ "$6" = "--user" ] && [ "$7" = 0 ]; then printf 'Failure [not installed for 0]\\n'; exit 1; fi exit 1`, ); const projected = await refusedBy(providerOver(binary), { diff --git a/src/adb/AdbProvider.ts b/src/adb/AdbProvider.ts index 59b5aba71..0061528e3 100644 --- a/src/adb/AdbProvider.ts +++ b/src/adb/AdbProvider.ts @@ -2624,7 +2624,14 @@ export class AdbProvider { operation, input.serial, selection.binary, - adbArguments(input.serial, ["uninstall", input.package]), + adbArguments(input.serial, [ + "shell", + "pm", + "uninstall", + "--user", + "0", + input.package, + ]), { timeoutMs: PACKAGE_LIST_TIMEOUT_MS, maxOutputBytes: PULL_MAX_OUTPUT_BYTES, @@ -2655,7 +2662,7 @@ export class AdbProvider { provider, { limitations: [ - "Uninstall removes the package and its data for user 0; keeping data (-k) is not exposed", + "Uninstall explicitly requests user 0 without data retention (-k); device enforcement is not independently verified", ], }, ), diff --git a/src/contracts/adb/adbToolContracts.ts b/src/contracts/adb/adbToolContracts.ts index df3d3e897..1d1e4a0c6 100644 --- a/src/contracts/adb/adbToolContracts.ts +++ b/src/contracts/adb/adbToolContracts.ts @@ -311,7 +311,7 @@ export const ADB_TOOL_CONTRACTS = [ name: "uninstall_adb_package", ...toolContractMetadata("uninstall_adb_package"), description: - "Uninstall one package from the device (`adb uninstall`), removing its data for user 0. A device-mutating tool; absent packages report the device's own refusal reason.", + "Request package removal for user 0 (`adb shell pm uninstall --user 0`) without data retention. A device-mutating tool; absent packages report the device's own refusal reason.", kind: "adb-provider", inputSchema: adbInputSchemas.uninstall_adb_package, outputSchema: evidenceResultOf(adbResultSchemas.uninstall_adb_package), diff --git a/tests/boundary/cli/adbUninstallScope.test.ts b/tests/boundary/cli/adbUninstallScope.test.ts new file mode 100644 index 000000000..ffa868323 --- /dev/null +++ b/tests/boundary/cli/adbUninstallScope.test.ts @@ -0,0 +1,116 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { expect, onTestFinished } from "vitest"; +import { AdbProvider } from "../../../src/adb/AdbProvider.js"; +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { createApplicationMcpHarness } from "../../fixtures/applicationMcpHarness.js"; +import { advertisedMcpToolSchemas } from "../../fixtures/advertisedMcpTool.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +for (const { verdict, exit, succeeds } of [ + { verdict: "Success", exit: 0, succeeds: true }, + { verdict: "Failure [not installed for 0]", exit: 0, succeeds: false }, + { verdict: "Failure [not installed for 0]", exit: 1, succeeds: false }, +]) { + cliTest.skipIf(process.platform === "win32")( + `selects user 0 through CLI and MCP for ${verdict}, exit ${String(exit)}`, + async ({ cli }) => { + const root = await createTestTempDirectory("rea-adb-uninstall-scope-"); + const binary = join(root, "fake-adb.mjs"); + await writeFile( + binary, + `#!/usr/bin/env node +import { appendFileSync } from 'node:fs'; +const args = process.argv.slice(2); +appendFileSync(process.argv[1] + '.calls', JSON.stringify(args) + '\\n'); +if (args.length === 1 && args[0] === 'version') { + console.log('Android Debug Bridge version 1.0.41\\nVersion 34.0.5-fixture'); +} else { + console.log(${JSON.stringify(verdict)}); + process.exitCode = ${String(exit)}; +} +`, + { mode: 0o700 }, + ); + const environment = { + REA_ADB_PATH: binary, + HOME: root, + TMPDIR: root, + PATH: `${dirname(process.execPath)}:/usr/bin:/bin`, + }; + const input = { serial: "emulator-5554", package: "com.example.app" }; + const output = await cli.run({ + arguments: [ + "uninstall-adb-package", + input.serial, + input.package, + "--json", + ], + environment, + }); + expect(output.exitCode).toBe(succeeds ? 0 : 1); + expect(output.stderr).toBe(""); + const harness = await createApplicationMcpHarness({ + serverOptions: { adbDeviceAnalysis: new AdbProvider({ environment }) }, + }); + onTestFinished(() => harness.close()); + const schemas = await advertisedMcpToolSchemas( + harness.client, + "uninstall_adb_package", + ); + const response = await harness.client.callTool({ + name: schemas.name, + arguments: input, + }); + if (succeeds) { + const evidence = parseEvidence(output.json); + expect(evidence.normalized_result).toMatchObject({ + serial: input.serial, + package_name: input.package, + }); + expect(evidence.limitations).toContain( + "Uninstall explicitly requests user 0 without data retention (-k); device enforcement is not independently verified", + ); + expect(response.isError).not.toBe(true); + expect( + schemas.ajv.validate( + schemas.outputSchema, + response.structuredContent, + ), + ).toBe(true); + const mcpEvidence = parseEvidence(response.structuredContent); + expect(mcpEvidence.parameters).toEqual(evidence.parameters); + expect(mcpEvidence.normalized_result).toEqual( + evidence.normalized_result, + ); + expect(mcpEvidence.limitations).toEqual(evidence.limitations); + } else { + expect(output.json).toMatchObject({ + code: "unsupported_target", + details: { reason: "not installed for 0" }, + }); + expect(parseMcpToolError(response).error).toMatchObject({ + code: "unsupported_target", + details: { reason: "not installed for 0" }, + }); + } + const calls = (await readFile(binary + ".calls", "utf8")) + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + const scoped = [ + "-s", + input.serial, + "shell", + "pm", + "uninstall", + "--user", + "0", + input.package, + ]; + expect(calls).toEqual([["version"], scoped, ["version"], scoped]); + }, + ); +}