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:
1 parent
c360cfcd6d
commit
be0b2d138c
5 files changed
+130
-7
No files matched your search
@@ -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 |
|
||||
|
||||
@@ -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), {
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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]);
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user