Compare commits

...
Author SHA1 Message Date
Kaoru0822andClaude Opus 5.5 92fddf3176 fix(adb): share host configuration refusals with apktool
The ADB provider, which the apktool provider mirrors, had the same two
defects: a relative REA_ADB_PATH escaped as UNKNOWN because selection
ran before the try that maps configuration failures, and a missing adb
binary projected generic target advice instead of its reason.

Move the configuration-failure-to-refusal mapping into one
providerConfigurationRefusal helper used by both providers, resolve the
ADB selection inside the mapped try, and share the CLI refusal assertion.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:05:33 +00:00
Kaoru0822andClaude Opus 5.5 9655845bbc fix(apktool): name host requirements in configuration refusals
A missing apktool launcher projected the generic "unavailable for the
current target" message and remediation, leaving the actual reason only
in details.reason. A relative REA_APKTOOL_COMMAND escaped as UNKNOWN
because command selection ran before the try that maps configuration
failures.

Resolve the selection inside that try for both execution and
availability, and pass the refusal reason as userMessage, as JADX and
the adapters fixed in #1224 do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 19:35:57 +00:00
6 changed files with 112 additions and 33 deletions
+6 -8
View File
@@ -10,12 +10,12 @@ import type {
} from "../application/AnalysisProvider.js";
import {
createAnalysisExecution,
providerConfigurationRefusal,
type AnalysisExecution,
} from "../application/AnalysisProvider.js";
import type { AdbRequest } from "../domain/adb/adbDeviceAnalysis.js";
import {
AnalysisArtifactChangedError,
AnalysisCapabilityUnavailableError,
AnalysisCancelledError,
AnalysisInputError,
AnalysisOutputError,
@@ -417,8 +417,8 @@ export class AdbProvider {
async inspectAvailability(
signal?: AbortSignal,
): Promise<ProviderAvailability> {
const selection = this.selection();
try {
const selection = this.selection();
await inspectAdbClient(selection, signal);
return {
status: "available",
@@ -555,21 +555,19 @@ export class AdbProvider {
const signal = options?.signal;
if (signal?.aborted === true)
return err(new AnalysisCancelledError(request.operation));
const selection = this.selection();
let selection: AdbBinarySelection;
let identity: AdbClientIdentity;
try {
selection = this.selection();
identity = await inspectAdbClient(selection, signal);
} catch (cause) {
if (cause instanceof AnalysisCancelledError) return err(cause);
if (cause instanceof AdbConfigurationFailure)
return err(
new AnalysisCapabilityUnavailableError(
providerConfigurationRefusal(
ADB_PROVIDER_IDENTITY.id,
request.operation,
typeof cause.diagnostics.remediation === "string"
? `${cause.message} ${cause.diagnostics.remediation}`
: cause.message,
{ cause },
cause,
),
);
return err(
+9 -9
View File
@@ -22,6 +22,7 @@ import type {
} from "../application/AnalysisProvider.js";
import {
createAnalysisExecution,
providerConfigurationRefusal,
type AnalysisExecution,
} from "../application/AnalysisProvider.js";
import type { ApktoolRequest } from "../domain/apktool/apktoolResourceAnalysis.js";
@@ -153,12 +154,13 @@ const runApktool = async (
invocation = apktoolInvocation(selection, arguments_, hostEnvironment);
} catch (cause) {
if (!(cause instanceof WindowsBatchCommandError)) throw cause;
const reason = `The apktool batch launcher cannot receive REA's decode paths through cmd.exe: ${cause.message}`;
return err(
new AnalysisCapabilityUnavailableError(
APKTOOL_PROVIDER_IDENTITY.id,
operation,
`The apktool batch launcher cannot receive REA's decode paths through cmd.exe: ${cause.message}`,
{ cause },
reason,
{ cause, userMessage: reason },
),
);
}
@@ -345,8 +347,8 @@ export class ApktoolProvider {
async #inspectAvailability(
signal: AbortSignal,
): Promise<ProviderAvailability> {
const selection = this.selection();
try {
const selection = this.selection();
await inspectApktoolClient(selection, signal, this.environment);
throwIfCancelled("inspect_apktool_client", signal);
return {
@@ -397,9 +399,10 @@ export class ApktoolProvider {
): Promise<Result<AnalysisExecution, AnalysisError>> {
if (signal?.aborted === true)
return err(new AnalysisCancelledError(request.operation));
const selection = this.selection();
let selection: ApktoolCommandSelection;
let identity: ApktoolClientIdentity;
try {
selection = this.selection();
identity = await inspectApktoolClient(
selection,
signal,
@@ -409,13 +412,10 @@ export class ApktoolProvider {
if (cause instanceof AnalysisCancelledError) return err(cause);
if (cause instanceof ApktoolConfigurationFailure)
return err(
new AnalysisCapabilityUnavailableError(
providerConfigurationRefusal(
APKTOOL_PROVIDER_IDENTITY.id,
request.operation,
typeof cause.diagnostics.remediation === "string"
? `${cause.message} ${cause.diagnostics.remediation}`
: cause.message,
{ cause },
cause,
),
);
return err(
+26
View File
@@ -5,6 +5,7 @@ import type { NativeToolName } from "../contracts/native/nativeToolContracts.js"
import type { ArtifactAnalysisOperation } from "../contracts/artifactToolContracts.js";
import type { ManagedToolName } from "../contracts/managed/managedToolContracts.js";
import type { AnalysisError } from "../domain/analysisErrorBase.js";
import { AnalysisCapabilityUnavailableError } from "../domain/analysisErrorCore.js";
import type { AnalysisProfileCommitment } from "../domain/analysisProfile.js";
import type { JsonValue } from "../domain/jsonValue.js";
import type { Result } from "../domain/result.js";
@@ -210,6 +211,31 @@ export type ProviderAvailability =
readonly diagnostics: Readonly<Record<string, JsonValue>>;
};
/** A host tool configuration failure observed before any target work. */
export interface ProviderConfigurationFailure extends Error {
readonly diagnostics: Readonly<Record<string, JsonValue>>;
}
/**
* Refuse an operation because the host tool is misconfigured. The reason,
* not the generic target advice, tells the caller what to change.
*/
export const providerConfigurationRefusal = (
providerId: string,
operation: string,
failure: ProviderConfigurationFailure,
): AnalysisCapabilityUnavailableError => {
const remediation = failure.diagnostics.remediation;
const reason =
typeof remediation === "string"
? `${failure.message}. ${remediation}.`
: `${failure.message}.`;
return new AnalysisCapabilityUnavailableError(providerId, operation, reason, {
cause: failure,
userMessage: reason,
});
};
/** Provider-owned target support observation before profile resolution. */
export type ProviderTargetSupport =
| {
+27 -8
View File
@@ -1,6 +1,9 @@
import { expect, it } from "vitest";
import { serveCli as serve } from "./cliServeHarness.js";
import {
expectConfigurationRefusal,
serveCli as serve,
} from "./cliServeHarness.js";
it.each([
["inspect-adb-client"],
@@ -34,13 +37,29 @@ it.each([
expect(schema).toBeTruthy();
});
it("reports a missing adb binary with recovery guidance", async () => {
const result = await serve(["inspect-adb-client", "--json"], {
REA_ADB_PATH: "/nonexistent/adb",
});
expect(result.stdout).toContain("capability_unavailable");
expect(result.stdout).toContain("REA_ADB_PATH");
});
it.each([
[
["inspect-adb-client", "--json"],
"/nonexistent/adb",
"The selected adb binary was not found: /nonexistent/adb. Set REA_ADB_PATH",
],
[
["list-adb-devices", "--json"],
"/nonexistent/adb",
"The selected adb binary was not found: /nonexistent/adb. Set REA_ADB_PATH",
],
[
["list-adb-devices", "--json"],
"relative/adb",
"REA_ADB_PATH must be an absolute path to the adb binary.",
],
])(
"%j with REA_ADB_PATH=%s names the host requirement",
async (argv, binary, reason) => {
const result = await serve(argv, { REA_ADB_PATH: binary });
expectConfigurationRefusal(result, reason);
},
);
it("validates device serials before contacting any device", async () => {
const result = await serve(["inspect-adb-device", "bad serial", "--json"]);
+27 -8
View File
@@ -1,6 +1,9 @@
import { expect, it } from "vitest";
import { serveCli as serve } from "./cliServeHarness.js";
import {
expectConfigurationRefusal,
serveCli as serve,
} from "./cliServeHarness.js";
it.each(["inspect-apktool-client", "decode-android-resources"])(
"%s advertises a schema through the public CLI",
@@ -12,13 +15,29 @@ it.each(["inspect-apktool-client", "decode-android-resources"])(
},
);
it("reports a missing apktool launcher with recovery guidance", async () => {
const result = await serve(["inspect-apktool-client", "--json"], {
REA_APKTOOL_COMMAND: "/nonexistent/apktool",
});
expect(result.stdout).toContain("capability_unavailable");
expect(result.stdout).toContain("REA_APKTOOL_COMMAND");
});
it.each([
[
["inspect-apktool-client", "--json"],
"/nonexistent/apktool",
"The selected apktool launcher was not found: /nonexistent/apktool. Set REA_APKTOOL_COMMAND",
],
[
["decode-android-resources", "/targets/app.apk", "--json"],
"/nonexistent/apktool",
"The selected apktool launcher was not found: /nonexistent/apktool. Set REA_APKTOOL_COMMAND",
],
[
["decode-android-resources", "/targets/app.apk", "--json"],
"relative/apktool",
"REA_APKTOOL_COMMAND must be an absolute path to the apktool launcher.",
],
])(
"%j with REA_APKTOOL_COMMAND=%s names the host requirement",
async (argv, command, reason) => {
const result = await serve(argv, { REA_APKTOOL_COMMAND: command });
expectConfigurationRefusal(result, reason);
},
);
it("validates the locale option before launching apktool", async () => {
const result = await serve([
+17
View File
@@ -1,3 +1,5 @@
import { expect } from "vitest";
import { createCli } from "../../../src/cli.js";
/** Captured CLI invocation for boundary tests. */
@@ -25,3 +27,18 @@ export const serveCli = async (
});
return { stdout, exitCode };
};
/** A host tool configuration refusal names what to configure, not the target. */
export const expectConfigurationRefusal = (
result: ServedCli,
reason: string,
): void => {
const output = JSON.parse(result.stdout) as {
code?: unknown;
message?: unknown;
remediation?: { action?: unknown };
};
expect(output.code).toBe("capability_unavailable");
expect(output.message).toEqual(expect.stringContaining(reason));
expect(output.remediation?.action).toEqual(expect.stringContaining(reason));
};