fix(adb): scope package uninstall requests to user zero

Send shell pm uninstall --user 0 through the shared quoted command boundary. Align tool guidance and Evidence limitations with the selected request scope while preserving device refusal diagnostics.

Validation: two provider controls failed before the fix and pass afterward; three public CLI/MCP scenarios capture exact arguments and equivalent success facts; nineteen command/SDK contract checks, check:fast and docs:check pass. No real device, multi-user data preservation, vendor/APEX behavior or performance measurement. Refs #4673.
This commit is contained in:
morluto committed 2026-10-17 13:30:29 +08:00
1 parent c360cfcd6d
commit be0b2d138c
5 files changed
+130 -7

No files matched your search

+1 -1
View File
@@ -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 |
+3 -3
View File
@@ -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), {
+9 -2
View File
@@ -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",
],
},
),
+1 -1
View File
@@ -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),
@@ -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]);
},
);
}