fix(native): report why a selected plist cannot be inspected

`inspect_plist` turned a missing selected plist, a directory, or a file
that is not a property list into a generic execution failure that
advised retrying and running `rea doctor`. A bare Mach-O without
Contents/Info.plist failed the same way.

Check the resolved path before running plutil and keep plutil's decode
diagnostic. A caller-selected path now fails as invalid input naming
the path and the reason; a missing default Info.plist is unavailable for
the target. The CLI no longer sends Contents/Info.plist as an explicit
selection, so its default matches the MCP default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Iwakurakaoru0822 2026-10-06 23:16:13 -07:00
parent cfd2b0c75d
commit 0674facd46
3 changed files with 163 additions and 4 deletions

View File

@ -135,8 +135,10 @@ const registerNativeCommands = (cli: CliInstance, logger: Logger): void => {
options: z.object({
relativePath: z
.string()
.default("Contents/Info.plist")
.describe("Plist path relative to the app root"),
.optional()
.describe(
"Plist path relative to the app root (default: Contents/Info.plist)",
),
}),
alias: { relativePath: "relative-path" },
run: ({ args, options }) =>
@ -144,7 +146,9 @@ const registerNativeCommands = (cli: CliInstance, logger: Logger): void => {
runProviderAnalysis(
args.path,
"inspect_plist",
{ path: options.relativePath },
options.relativePath === undefined
? {}
: { path: options.relativePath },
logger,
),
),

View File

@ -28,6 +28,7 @@ import type { EvidenceLocation } from "../domain/evidence.js";
import {
AnalysisCancelledError,
AnalysisCapabilityUnavailableError,
AnalysisInputError,
AnalysisOutputError,
} from "../domain/analysisErrorCore.js";
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
@ -419,6 +420,8 @@ class NativeMacOSClient implements AnalysisClient {
);
const plist = await resolvePlistPath(this.target, requested);
if (!plist.ok) return plist;
const selected = await requireRegularPlist(plist.value, requested);
if (!selected.ok) return selected;
const classified = await this.#run(
"inspect_plist",
"file",
@ -445,9 +448,17 @@ class NativeMacOSClient implements AnalysisClient {
"inspect_plist",
"plutil",
["-convert", "xml1", "-o", "-", "--", plist.value],
{ signal },
{ signal, acceptNonZero: !jsonDecoded },
);
if (xml !== undefined && !xml.ok) return xml;
if (xml !== undefined && xml.value.exitCode !== 0)
return err(
unreadablePlist(
plist.value,
requested,
`plutil could not decode it as a property list (${plutilDiagnostic(xml.value.stderr, plist.value)})`,
),
);
const parsed =
xml === undefined
? parsePlistJson(json.value.stdout)
@ -637,6 +648,57 @@ const parseEntitlements = (output: string): JsonValue | null => {
);
};
/**
* Report a plist that cannot be decoded as the caller's selection, or as the
* target's missing default, rather than as a failed tool run.
*/
const unreadablePlist = (
path: string,
requested: string | undefined,
reason: string,
): AnalysisError => {
const sentence = reason.endsWith(".") ? reason : `${reason}.`;
return requested === undefined
? new AnalysisCapabilityUnavailableError(
IDENTITY.id,
"inspect_plist",
`The target has no readable Contents/Info.plist at ${path}: ${sentence} Pass path to inspect another plist.`,
)
: new AnalysisInputError("inspect_plist", undefined, [
{
path: ["path"],
reason: "invalid_value",
message: `Cannot inspect ${path}: ${sentence}`,
},
]);
};
/** plutil prefixes its diagnostic with the path the message already names. */
const plutilDiagnostic = (stderr: string, path: string): string => {
const text = stderr.trim();
return text.startsWith(`${path}: `) ? text.slice(path.length + 2) : text;
};
const requireRegularPlist = async (
path: string,
requested: string | undefined,
): Promise<Result<null, AnalysisError>> => {
try {
if ((await stat(path)).isFile()) return ok(null);
return err(unreadablePlist(path, requested, "it is not a regular file"));
} catch (cause: unknown) {
if (
cause instanceof Error &&
"code" in cause &&
(cause.code === "ENOENT" || cause.code === "ENOTDIR")
)
return err(unreadablePlist(path, requested, "no file exists there"));
return err(
new ProviderAdapterError(IDENTITY.id, "inspect_plist", { cause }),
);
}
};
const resolvePlistPath = async (
target: BinaryTarget,
requested: string | undefined,

View File

@ -0,0 +1,93 @@
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect } from "vitest";
import { thinMach } from "../../../src/domain/binaryTarget.fixture.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
const environment = {
REA_LOG_LEVEL: "silent",
REA_ANALYSIS_PROVIDER: "auto",
} as const;
/** An app bundle whose Contents holds a directory and a non-plist file. */
const fixtureApp = async () => {
const app = join(await createTestTempDirectory("rea-plist-fail-"), "A.app");
const contents = join(app, "Contents");
await mkdir(join(contents, "MacOS"), { recursive: true });
await mkdir(join(contents, "Folder.plist"));
await writeFile(join(contents, "MacOS/App"), thinMach(0xfeedfacf, 0x100000c));
await writeFile(
join(contents, "Info.plist"),
"<plist><dict><key>CFBundleExecutable</key><string>App</string></dict></plist>",
);
await writeFile(join(contents, "Text.plist"), "not a property list\n");
return app;
};
describe.skipIf(process.platform !== "darwin")(
"native plist CLI selection failures",
() => {
cliTest.for([
["Contents/Missing.plist", "no file exists there."],
["Contents/Folder.plist", "it is not a regular file."],
["Contents/Text.plist", "plutil could not decode it as a property list"],
] as const)(
"reports why the selected %s cannot be inspected",
async ([selected, reason], { cli }) => {
const app = await fixtureApp();
const result = await cli.run({
arguments: [
"inspect-plist",
app,
"--relative-path",
selected,
"--json",
],
environment,
});
expect(result.exitCode).toBe(1);
expect(result.json).toMatchObject({
code: "invalid_request",
details: {
operation: "inspect_plist",
issues: [
{
path: ["path"],
reason: "invalid_value",
message: expect.stringContaining(
`Cannot inspect ${join(app, selected)}: ${reason}`,
),
},
],
},
});
},
);
cliTest(
"reports a missing default Info.plist as unavailable for the target",
async ({ cli }) => {
const directory = await createTestTempDirectory("rea-plist-bare-");
const program = join(directory, "tool");
await writeFile(program, thinMach(0xfeedfacf, 0x100000c));
const result = await cli.run({
arguments: ["inspect-plist", program, "--json"],
environment,
});
expect(result.exitCode).toBe(1);
expect(result.json).toMatchObject({
code: "capability_unavailable",
details: {
operation: "inspect_plist",
reason: expect.stringContaining(
`no readable Contents/Info.plist at ${join(directory, "Contents/Info.plist")}: no file exists there.`,
),
},
});
},
);
},
);