mirror of
https://github.com/morluto/rea.git
synced 2026-11-08 15:56:35 +00:00
chore: merge upstream main into feat/add-freebuff-agent-support
Resolve conflicts by keeping both sides: the PR's ownership/readiness evidence options alongside upstream's provider request budget resolver in client registration options and the doctor host; both removeClient call arguments; upstream's RegularFileCleanupFailure outcome model combined with the PR's retained/failed applicability distinction on read failure; the 6.5.0 client list plus freebuff in installation docs. Validation: typecheck, lint and doc-facts checks green; freebuff/zcode/updateMaintenance/clientLifecycle/clientRegistrationStatus/claudeDesktop suites match the pre-merge baseline exactly (8 host failures, all symlink EPERM or stale-vs-aligned on Windows, unchanged); clientConfigurationDialects fails the same 19 host cases before and after the merge, with the 7 upstream-added cases passing.
This commit is contained in:
commit
28be234f4b
1583 files changed
+295339
-68753
No files matched your search
@@ -0,0 +1,174 @@
|
||||
import { expect, it, vi } from "vitest";
|
||||
import {
|
||||
ExecFileOutputCancelledError,
|
||||
execFileBytes,
|
||||
} from "../process/ExecFileOutput.js";
|
||||
import { probeAdbPathExistence, runAdbCapture } from "./AdbCommand.js";
|
||||
|
||||
vi.mock("../process/ExecFileOutput.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../process/ExecFileOutput.js")>()),
|
||||
execFileBytes: vi.fn(),
|
||||
}));
|
||||
|
||||
const probe = (signal?: AbortSignal) =>
|
||||
probeAdbPathExistence(
|
||||
"push_adb_file",
|
||||
"device",
|
||||
"/device/path",
|
||||
"/host/adb",
|
||||
{
|
||||
timeoutMs: 100,
|
||||
maxOutputBytes: 1024,
|
||||
},
|
||||
signal,
|
||||
);
|
||||
|
||||
it("recognizes only the completed empty false result as absence", async () => {
|
||||
vi.mocked(execFileBytes).mockRejectedValueOnce(
|
||||
Object.assign(new Error("test false"), {
|
||||
code: 1,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
signal: null,
|
||||
killed: false,
|
||||
}),
|
||||
);
|
||||
await expect(probe()).resolves.toEqual({ ok: true, value: false });
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ code: 2 },
|
||||
{ code: "ENOENT" },
|
||||
{ signal: "SIGTERM" },
|
||||
{ collectionFailure: true },
|
||||
{ stdout: "unexpected" },
|
||||
{ stderr: " " },
|
||||
])("keeps nonstandard false completion unknown: %j", async (extra) => {
|
||||
vi.mocked(execFileBytes).mockRejectedValueOnce(
|
||||
Object.assign(new Error("probe failed"), {
|
||||
code: 1,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
signal: null,
|
||||
killed: false,
|
||||
...extra,
|
||||
}),
|
||||
);
|
||||
const result = await probe();
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "AnalysisProtocolError",
|
||||
diagnostics: { destination_existence: "unknown" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ killed: true }, "AnalysisTimeoutError"],
|
||||
[{ outputTruncated: true }, "AnalysisResourceConstraintError"],
|
||||
] as const)(
|
||||
"preserves a stopped or incomplete probe: %j",
|
||||
async (extra, tag) => {
|
||||
vi.mocked(execFileBytes).mockRejectedValueOnce(
|
||||
Object.assign(new Error("probe failed"), {
|
||||
code: 1,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
signal: null,
|
||||
killed: false,
|
||||
...extra,
|
||||
}),
|
||||
);
|
||||
await expect(probe()).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: tag },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("recognizes an empty successful probe as existence", async () => {
|
||||
vi.mocked(execFileBytes).mockResolvedValueOnce({
|
||||
stdout: Buffer.alloc(0),
|
||||
stderr: Buffer.alloc(0),
|
||||
});
|
||||
await expect(probe()).resolves.toEqual({ ok: true, value: true });
|
||||
});
|
||||
|
||||
it.each(["stdout", "stderr"] as const)(
|
||||
"retains unexpected successful %s as unknown",
|
||||
async (stream) => {
|
||||
vi.mocked(execFileBytes).mockResolvedValueOnce({
|
||||
stdout: Buffer.alloc(0),
|
||||
stderr: Buffer.alloc(0),
|
||||
[stream]: Buffer.from("unexpected"),
|
||||
});
|
||||
await expect(probe()).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "AnalysisProtocolError",
|
||||
capturedOutput: { [stream]: "unexpected", exit_code: 0 },
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each([null, "caller stopped", new Error("caller stopped")] as const)(
|
||||
"uses the command owner's exact %s cancellation reason",
|
||||
async (reason) => {
|
||||
const cancelled = Object.assign(new ExecFileOutputCancelledError(reason), {
|
||||
stdout: "partial",
|
||||
stderr: "diagnostic",
|
||||
stdoutBytes: 7,
|
||||
stderrBytes: 10,
|
||||
});
|
||||
vi.mocked(execFileBytes).mockRejectedValueOnce(cancelled);
|
||||
const result = await probe();
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisCancelledError" },
|
||||
});
|
||||
if (result.ok) throw new Error("Expected owned cancellation");
|
||||
expect(result.error.cause).toBe(reason);
|
||||
expect(result.error.capturedOutput).toMatchObject({
|
||||
stdout: "partial",
|
||||
stderr: "diagnostic",
|
||||
stdout_bytes: 7,
|
||||
stderr_bytes: 10,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("does not infer cancellation from a foreign ABORT_ERR code", async () => {
|
||||
const failure = Object.assign(new Error("foreign code"), {
|
||||
code: "ABORT_ERR",
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
});
|
||||
vi.mocked(execFileBytes).mockRejectedValueOnce(failure);
|
||||
expect(await probe()).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisProtocolError" },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a physical failure even when the later abort reason is that same error", async () => {
|
||||
const controller = new AbortController();
|
||||
const failure = new Error("physical command failure");
|
||||
vi.mocked(execFileBytes).mockImplementationOnce(async () => {
|
||||
controller.abort(failure);
|
||||
throw failure;
|
||||
});
|
||||
const result = await runAdbCapture(
|
||||
"inspect_adb_device",
|
||||
"device",
|
||||
"/host/adb",
|
||||
[],
|
||||
{ timeoutMs: 100, maxOutputBytes: 1024 },
|
||||
controller.signal,
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisProtocolError", cause: failure },
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,260 @@
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisProtocolError,
|
||||
AnalysisResourceConstraintError,
|
||||
AnalysisTimeoutError,
|
||||
AnalysisUnsupportedTargetError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import type {
|
||||
AnalysisCapturedOutput,
|
||||
AnalysisError,
|
||||
} from "../domain/analysisErrorBase.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import {
|
||||
execFileBytes,
|
||||
ExecFileOutputCancelledError,
|
||||
execFileOutputFailure,
|
||||
type ExecFileOutputFailure,
|
||||
} from "../process/ExecFileOutput.js";
|
||||
import { packageChangeFailureReason } from "./AdbShellOutput.js";
|
||||
|
||||
const STDERR_EXCERPT_BYTES = 512;
|
||||
|
||||
/** Project parsed command-owner facts without reinterpreting the rejection. */
|
||||
export const adbCommandCapturedOutput = (
|
||||
failure: ExecFileOutputFailure,
|
||||
): AnalysisCapturedOutput => ({
|
||||
stdout: failure.stdout,
|
||||
stderr: failure.stderr,
|
||||
...(failure.stdoutBytes === undefined
|
||||
? {}
|
||||
: { stdout_bytes: failure.stdoutBytes }),
|
||||
...(failure.stderrBytes === undefined
|
||||
? {}
|
||||
: { stderr_bytes: failure.stderrBytes }),
|
||||
exit_code: typeof failure.code === "number" ? failure.code : null,
|
||||
signal: failure.signal,
|
||||
truncated: failure.outputTruncated,
|
||||
});
|
||||
|
||||
/** Parsed command failure owned by this adapter, separate from its public tag. */
|
||||
class AdbProtocolError extends AnalysisProtocolError {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly commandFailure: ExecFileOutputFailure,
|
||||
cause: unknown,
|
||||
) {
|
||||
super(message, { cause });
|
||||
}
|
||||
}
|
||||
|
||||
/** adb joins shell operands into a remote POSIX command; quote at that boundary. */
|
||||
const quoteShellOperand = (value: string): string =>
|
||||
/^[A-Za-z0-9_./:=@+,-]+$/u.test(value)
|
||||
? value
|
||||
: "'" + value.replaceAll("'", "'\\''") + "'";
|
||||
|
||||
export const adbArguments = (
|
||||
serial: string | undefined,
|
||||
arguments_: readonly string[],
|
||||
): readonly string[] => {
|
||||
const operands =
|
||||
arguments_[0] === "shell"
|
||||
? ["shell", ...arguments_.slice(1).map(quoteShellOperand)]
|
||||
: [...arguments_];
|
||||
return serial === undefined ? operands : ["-s", serial, ...operands];
|
||||
};
|
||||
|
||||
export interface AdbRunLimits {
|
||||
readonly timeoutMs: number;
|
||||
readonly maxOutputBytes: number;
|
||||
}
|
||||
|
||||
/** Run one bounded adb invocation and keep its exact failure shape. */
|
||||
export const runAdbCapture = async (
|
||||
operation: string,
|
||||
targetPath: string,
|
||||
binary: string,
|
||||
arguments_: readonly string[],
|
||||
limits: AdbRunLimits,
|
||||
signal: AbortSignal | undefined,
|
||||
): Promise<Result<{ stdout: Buffer; stderr: Buffer }, AnalysisError>> => {
|
||||
if (signal?.aborted === true)
|
||||
return err(new AnalysisCancelledError(operation, { cause: signal.reason }));
|
||||
try {
|
||||
return ok(
|
||||
await execFileBytes(binary, arguments_, {
|
||||
timeout: limits.timeoutMs,
|
||||
maxBuffer: limits.maxOutputBytes,
|
||||
stopSignal: "SIGTERM",
|
||||
...(signal === undefined ? {} : { signal }),
|
||||
}),
|
||||
);
|
||||
} catch (cause) {
|
||||
const failure = execFileOutputFailure(cause);
|
||||
if (cause instanceof ExecFileOutputCancelledError)
|
||||
return err(
|
||||
new AnalysisCancelledError(operation, {
|
||||
cause: cause.cause,
|
||||
...(failure === undefined
|
||||
? {}
|
||||
: { capturedOutput: adbCommandCapturedOutput(failure) }),
|
||||
}),
|
||||
);
|
||||
if (failure === undefined)
|
||||
return err(
|
||||
new AnalysisProtocolError(`adb failed for ${operation}`, { cause }),
|
||||
);
|
||||
if (failure.outputTruncated)
|
||||
return err(
|
||||
new AnalysisResourceConstraintError(
|
||||
operation,
|
||||
"memory",
|
||||
`adb output exceeded the ${String(limits.maxOutputBytes)}-byte capture budget`,
|
||||
{ max_output_bytes: limits.maxOutputBytes },
|
||||
{ cause },
|
||||
),
|
||||
);
|
||||
const excerpt = failure.stderr.trim().slice(0, STDERR_EXCERPT_BYTES);
|
||||
if (/unauthorized/iu.test(excerpt))
|
||||
return err(
|
||||
new AnalysisUnsupportedTargetError(operation, targetPath, excerpt, {
|
||||
cause,
|
||||
}),
|
||||
);
|
||||
if (
|
||||
/device[^\n]*not found|device offline|still authorizing|more than one device/iu.test(
|
||||
excerpt,
|
||||
)
|
||||
)
|
||||
return err(
|
||||
new AnalysisUnsupportedTargetError(operation, targetPath, excerpt, {
|
||||
cause,
|
||||
}),
|
||||
);
|
||||
if (
|
||||
/No such file or directory|Permission denied|read: Permission denied/iu.test(
|
||||
excerpt,
|
||||
)
|
||||
)
|
||||
return err(
|
||||
new AnalysisUnsupportedTargetError(operation, targetPath, excerpt, {
|
||||
cause,
|
||||
}),
|
||||
);
|
||||
if (failure.killed)
|
||||
return err(
|
||||
new AnalysisTimeoutError(operation, limits.timeoutMs, { cause }),
|
||||
);
|
||||
return err(
|
||||
new AdbProtocolError(
|
||||
excerpt === ""
|
||||
? `adb exited with code ${String(failure.code)} for ${operation}`
|
||||
: excerpt,
|
||||
failure,
|
||||
cause,
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/** Decode textual adb protocols after the shared byte capture and error boundary. */
|
||||
export const runAdb = async (
|
||||
...arguments_: Parameters<typeof runAdbCapture>
|
||||
): Promise<
|
||||
Result<{ stdout: string; stderr: string; stdoutBytes: number }, AnalysisError>
|
||||
> => {
|
||||
const captured = await runAdbCapture(...arguments_);
|
||||
if (!captured.ok) return captured;
|
||||
return ok({
|
||||
stdout: captured.value.stdout.toString("utf8"),
|
||||
stderr: captured.value.stderr.toString("utf8"),
|
||||
// Byte count of the captured output before UTF-8 decoding, so malformed
|
||||
// sequences replaced by U+FFFD still report their true captured size.
|
||||
stdoutBytes: captured.value.stdout.byteLength,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* adb exits nonzero when the device refuses a package change, printing the
|
||||
* `Failure [...]` verdict to stderr (install) or stdout (uninstall). Read the
|
||||
* refusal from that failed run so it is not reported as unreadable output.
|
||||
*/
|
||||
export const packageChangeRefusal = (
|
||||
operation: string,
|
||||
serial: string,
|
||||
error: AnalysisError,
|
||||
): Result<never, AnalysisError> | undefined => {
|
||||
if (!(error instanceof AdbProtocolError)) return undefined;
|
||||
const failure = error.commandFailure;
|
||||
const reason = packageChangeFailureReason(
|
||||
`${failure.stdout}\n${failure.stderr}`,
|
||||
);
|
||||
return reason === null
|
||||
? undefined
|
||||
: err(
|
||||
new AnalysisUnsupportedTargetError(operation, serial, reason, {
|
||||
cause: error,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
/** Only the clean false result of test -e establishes absence. */
|
||||
export const probeAdbPathExistence = async (
|
||||
operation: string,
|
||||
serial: string,
|
||||
path: string,
|
||||
binary: string,
|
||||
limits: AdbRunLimits,
|
||||
signal: AbortSignal | undefined,
|
||||
): Promise<Result<boolean, AnalysisError>> => {
|
||||
const probed = await runAdbCapture(
|
||||
operation,
|
||||
path,
|
||||
binary,
|
||||
adbArguments(serial, ["shell", "test", "-e", path]),
|
||||
limits,
|
||||
signal,
|
||||
);
|
||||
if (probed.ok) {
|
||||
if (probed.value.stdout.length === 0 && probed.value.stderr.length === 0)
|
||||
return ok(true);
|
||||
return err(
|
||||
new AnalysisProtocolError(
|
||||
"The ADB path existence probe returned unexpected output; destination existence is unknown",
|
||||
{
|
||||
diagnostics: { destination_existence: "unknown" },
|
||||
capturedOutput: {
|
||||
stdout: probed.value.stdout.toString("utf8"),
|
||||
stderr: probed.value.stderr.toString("utf8"),
|
||||
stdout_bytes: probed.value.stdout.length,
|
||||
stderr_bytes: probed.value.stderr.length,
|
||||
exit_code: 0,
|
||||
signal: null,
|
||||
truncated: false,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
const error = probed.error;
|
||||
if (!(error instanceof AdbProtocolError)) return probed;
|
||||
const failure = error.commandFailure;
|
||||
if (
|
||||
failure.code === 1 &&
|
||||
failure.signal === null &&
|
||||
!failure.killed &&
|
||||
!failure.outputTruncated &&
|
||||
failure.collectionFailure !== true &&
|
||||
failure.stdout === "" &&
|
||||
failure.stderr === ""
|
||||
)
|
||||
return ok(false);
|
||||
return err(
|
||||
new AnalysisProtocolError(error.message, {
|
||||
cause: error.cause,
|
||||
diagnostics: { destination_existence: "unknown" },
|
||||
capturedOutput: adbCommandCapturedOutput(failure),
|
||||
}),
|
||||
);
|
||||
};
|
||||
@@ -55,4 +55,12 @@ describe("parseAdbVersionOutput", () => {
|
||||
installedPath: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not accept arbitrary successful output as an adb version", () => {
|
||||
expect(
|
||||
parseAdbVersionOutput(
|
||||
"Android Debug Bridge version tomorrow\nVersion unknown\n",
|
||||
),
|
||||
).toMatchObject({ version: null });
|
||||
});
|
||||
});
|
||||
+70
-11
@@ -1,13 +1,19 @@
|
||||
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisResourceConstraintError,
|
||||
AnalysisTimeoutError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { isAbsolute } from "node:path";
|
||||
|
||||
import {
|
||||
execFileOutput,
|
||||
ExecFileOutputCancelledError,
|
||||
execFileOutputFailure,
|
||||
} from "../process/ExecFileOutput.js";
|
||||
import type { ProviderAvailability } from "../application/AnalysisProvider.js";
|
||||
import type { ProviderRejectionCode } from "../contracts/providerSelection.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import { adbCommandCapturedOutput } from "./AdbCommand.js";
|
||||
|
||||
/** Environment key selecting the caller's adb binary. */
|
||||
export const ADB_BINARY_ENV = "REA_ADB_PATH";
|
||||
@@ -67,7 +73,9 @@ export interface AdbClientIdentity {
|
||||
* `Version ` the build version (no colon), and `Installed as` the binary path.
|
||||
*/
|
||||
export const parseAdbVersionOutput = (stdout: string): AdbClientIdentity => {
|
||||
const build = /^Version\s+(\S.*)$/mu.exec(stdout);
|
||||
const build = /^Version\s+(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)\s*$/mu.exec(
|
||||
stdout,
|
||||
);
|
||||
const installed = /^Installed as\s+(.+)$/mu.exec(stdout);
|
||||
return {
|
||||
version: build === null ? null : build[1]!.trim(),
|
||||
@@ -84,17 +92,68 @@ export const inspectAdbClient = async (
|
||||
signal?: AbortSignal,
|
||||
): Promise<AdbClientIdentity> => {
|
||||
try {
|
||||
const { stdout } = await execFileOutput(selection.binary, ["version"], {
|
||||
timeout: VERSION_TIMEOUT_MS,
|
||||
stopSignal: "SIGTERM",
|
||||
maxBuffer: VERSION_MAX_OUTPUT_BYTES,
|
||||
...(signal === undefined ? {} : { signal }),
|
||||
});
|
||||
return parseAdbVersionOutput(stdout);
|
||||
const { stdout, stderr } = await execFileOutput(
|
||||
selection.binary,
|
||||
["version"],
|
||||
{
|
||||
timeout: VERSION_TIMEOUT_MS,
|
||||
stopSignal: "SIGTERM",
|
||||
maxBuffer: VERSION_MAX_OUTPUT_BYTES,
|
||||
...(signal === undefined ? {} : { signal }),
|
||||
},
|
||||
);
|
||||
const identity = parseAdbVersionOutput(stdout);
|
||||
const hasRecognizedProtocolVersion =
|
||||
/^Android Debug Bridge version\s+\d+\.\d+\.\d+\s*$/mu.test(stdout);
|
||||
if (identity.version === null && !hasRecognizedProtocolVersion)
|
||||
throw new AdbConfigurationFailure(
|
||||
"version_unresolved",
|
||||
"The selected adb binary exited successfully but did not report a recognizable protocol or build version",
|
||||
{
|
||||
selected_binary: selection.binary,
|
||||
path_source: selection.pathSource,
|
||||
version_probe: "unrecognized_output",
|
||||
stdout,
|
||||
stderr,
|
||||
},
|
||||
);
|
||||
return identity;
|
||||
} catch (cause) {
|
||||
if (cause instanceof AdbConfigurationFailure) throw cause;
|
||||
const failure = execFileOutputFailure(cause);
|
||||
if (signal?.aborted || failure?.code === "ABORT_ERR")
|
||||
throw new AnalysisCancelledError("inspect_adb_client", { cause });
|
||||
if (cause instanceof ExecFileOutputCancelledError)
|
||||
throw new AnalysisCancelledError("inspect_adb_client", {
|
||||
cause: cause.cause,
|
||||
...(failure === undefined
|
||||
? {}
|
||||
: { capturedOutput: adbCommandCapturedOutput(failure) }),
|
||||
});
|
||||
const diagnostics = {
|
||||
selected_binary: selection.binary,
|
||||
path_source: selection.pathSource,
|
||||
...(failure === undefined ? {} : { exit_code: failure.code }),
|
||||
};
|
||||
const options = {
|
||||
cause,
|
||||
diagnostics,
|
||||
...(failure === undefined
|
||||
? {}
|
||||
: { capturedOutput: adbCommandCapturedOutput(failure) }),
|
||||
};
|
||||
if (failure?.outputTruncated === true)
|
||||
throw new AnalysisResourceConstraintError(
|
||||
"inspect_adb_client",
|
||||
"memory",
|
||||
`adb version output exceeded the ${String(VERSION_MAX_OUTPUT_BYTES)}-byte capture budget`,
|
||||
{ max_output_bytes: VERSION_MAX_OUTPUT_BYTES },
|
||||
options,
|
||||
);
|
||||
if (failure?.code === "ETIMEDOUT")
|
||||
throw new AnalysisTimeoutError(
|
||||
"inspect_adb_client",
|
||||
VERSION_TIMEOUT_MS,
|
||||
options,
|
||||
);
|
||||
if (failure?.code === "ENOENT")
|
||||
throw new AdbConfigurationFailure(
|
||||
"binary_missing",
|
||||
|
||||
@@ -56,7 +56,9 @@ describe("parseAdbDevicesOutput", () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ADB daemon startup notices", () => {
|
||||
it("recognizes daemon startup notices instead of failing the listing", () => {
|
||||
const output = [
|
||||
"* daemon not running; starting now at tcp:5037",
|
||||
@@ -70,6 +72,67 @@ describe("parseAdbDevicesOutput", () => {
|
||||
expect(devices).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("recognizes daemon startup notices emitted on stderr", () => {
|
||||
const stdout = [
|
||||
"List of devices attached",
|
||||
"emulator-5554 device transport_id:1",
|
||||
"",
|
||||
].join("\n");
|
||||
const stderr = [
|
||||
"* daemon not running; starting now at tcp:5037",
|
||||
"* daemon started successfully",
|
||||
"",
|
||||
].join("\n");
|
||||
const { devices, daemonStarted, unparsedLines } = parseAdbDevicesOutput(
|
||||
stdout,
|
||||
stderr,
|
||||
);
|
||||
expect(daemonStarted).toBe(true);
|
||||
expect(unparsedLines).toEqual([]);
|
||||
expect(devices).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("recognizes the legacy daemon notice with its trailing marker and CRLF", () => {
|
||||
const { daemonStarted, unparsedLines } = parseAdbDevicesOutput(
|
||||
"List of devices attached\r\n",
|
||||
"* daemon started successfully *\r\n",
|
||||
);
|
||||
expect(daemonStarted).toBe(true);
|
||||
expect(unparsedLines).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not treat a daemon startup error containing the success phrase as success", () => {
|
||||
const { daemonStarted, unparsedLines } = parseAdbDevicesOutput(
|
||||
"List of devices attached\r\n",
|
||||
"* daemon not started successfully\r\n",
|
||||
);
|
||||
expect(daemonStarted).toBe(false);
|
||||
expect(unparsedLines).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores non-startup lines and device strings on stderr", () => {
|
||||
const stdout = [
|
||||
"List of devices attached",
|
||||
"emulator-5554 device transport_id:1",
|
||||
"",
|
||||
].join("\n");
|
||||
const stderr = [
|
||||
"adb: warning: some transport warning",
|
||||
"emulator-5556 device transport_id:2",
|
||||
"",
|
||||
].join("\n");
|
||||
const { devices, daemonStarted, unparsedLines } = parseAdbDevicesOutput(
|
||||
stdout,
|
||||
stderr,
|
||||
);
|
||||
expect(daemonStarted).toBe(false);
|
||||
expect(unparsedLines).toEqual([]);
|
||||
expect(devices).toHaveLength(1);
|
||||
expect(devices[0]?.serial).toBe("emulator-5554");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ADB device states", () => {
|
||||
it("keeps non-device states such as unauthorized and offline", () => {
|
||||
const output = [
|
||||
"List of devices attached",
|
||||
|
||||
@@ -30,6 +30,12 @@ export interface AdbDeviceEntry {
|
||||
|
||||
const DEVICE_LINE = /^(\S+)\s+(.*)$/u;
|
||||
|
||||
const normalizeAdbOutputLine = (rawLine: string): string => rawLine.trimEnd();
|
||||
|
||||
const isSuccessfulDaemonStartNotice = (line: string): boolean =>
|
||||
line === "* daemon started successfully" ||
|
||||
line === "* daemon started successfully *";
|
||||
|
||||
const metadataValue = (remainder: string, key: string): string | null => {
|
||||
const match = new RegExp(`(?:^|\\s)${key}:(\\S+)`, "u").exec(remainder);
|
||||
return match === null ? null : match[1]!;
|
||||
@@ -42,6 +48,7 @@ const metadataValue = (remainder: string, key: string): string | null => {
|
||||
*/
|
||||
export const parseAdbDevicesOutput = (
|
||||
stdout: string,
|
||||
stderr = "",
|
||||
): {
|
||||
devices: readonly AdbDeviceEntry[];
|
||||
unparsedLines: readonly string[];
|
||||
@@ -51,12 +58,13 @@ export const parseAdbDevicesOutput = (
|
||||
const unparsedLines: string[] = [];
|
||||
let daemonStarted = false;
|
||||
for (const rawLine of stdout.split(/\r?\n/u)) {
|
||||
const line = rawLine.trimEnd();
|
||||
if (line === "" || line.startsWith("List of devices attached")) continue;
|
||||
if (line.startsWith("* daemon")) {
|
||||
if (line.includes("started successfully")) daemonStarted = true;
|
||||
const line = normalizeAdbOutputLine(rawLine);
|
||||
if (isSuccessfulDaemonStartNotice(line)) {
|
||||
daemonStarted = true;
|
||||
continue;
|
||||
}
|
||||
if (line === "" || line.startsWith("List of devices attached")) continue;
|
||||
if (line.startsWith("* daemon")) continue;
|
||||
const match = DEVICE_LINE.exec(line);
|
||||
const state = ADB_DEVICE_STATES.find((candidate) => {
|
||||
const remainder = match?.[2];
|
||||
@@ -105,6 +113,15 @@ export const parseAdbDevicesOutput = (
|
||||
: "none",
|
||||
});
|
||||
}
|
||||
if (!daemonStarted && stderr !== "") {
|
||||
for (const rawLine of stderr.split(/\r?\n/u)) {
|
||||
const line = normalizeAdbOutputLine(rawLine);
|
||||
if (isSuccessfulDaemonStartNotice(line)) {
|
||||
daemonStarted = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { devices, unparsedLines, daemonStarted };
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { open, stat } from "node:fs/promises";
|
||||
import { basename, join } from "node:path";
|
||||
|
||||
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
|
||||
import {
|
||||
AnalysisArtifactChangedError,
|
||||
AnalysisCancelledError,
|
||||
AnalysisOutputError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import {
|
||||
openRegularFile,
|
||||
RegularFileAdmissionFailure,
|
||||
sameRegularFileState,
|
||||
} from "../filesystem/RegularFile.js";
|
||||
import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
|
||||
import type { PrivateRuntimeRoot } from "../process/PrivateRuntimeRoot.js";
|
||||
|
||||
export interface AdbInputSnapshotDependencies {
|
||||
readonly openSource?: typeof openRegularFile;
|
||||
readonly openDestination?: typeof open;
|
||||
}
|
||||
|
||||
export interface AdbInputSnapshot {
|
||||
readonly path: string;
|
||||
readonly bytes: number;
|
||||
readonly sha256: string;
|
||||
}
|
||||
|
||||
/** Snapshot failure with the handles that remain owned for a later retry. */
|
||||
export class AdbInputSnapshotFailure extends Error {
|
||||
constructor(
|
||||
readonly operation: string,
|
||||
readonly sourcePath: string,
|
||||
readonly primaryCause: unknown,
|
||||
readonly cleanupResources: readonly string[],
|
||||
readonly cleanupCause: unknown,
|
||||
) {
|
||||
super(`ADB input snapshot failed for ${sourcePath}`, {
|
||||
cause: primaryCause ?? cleanupCause,
|
||||
});
|
||||
this.name = "AdbInputSnapshotFailure";
|
||||
}
|
||||
}
|
||||
|
||||
/** Copy one caller file into a private root, hashing the bytes adb will consume. */
|
||||
export const snapshotAdbInput = async (
|
||||
sourcePath: string,
|
||||
root: Pick<PrivateRuntimeRoot, "path">,
|
||||
resources: ArtifactResourceScope,
|
||||
operation: string,
|
||||
signal?: AbortSignal,
|
||||
dependencies: AdbInputSnapshotDependencies = {},
|
||||
): Promise<AdbInputSnapshot> => {
|
||||
let source: OwnedFileHandle | undefined;
|
||||
let destination: OwnedFileHandle | undefined;
|
||||
const cleanupResources: string[] = [];
|
||||
const cleanupCauses: unknown[] = [];
|
||||
let snapshot: AdbInputSnapshot | undefined;
|
||||
let primaryCause: unknown;
|
||||
|
||||
try {
|
||||
source = new OwnedFileHandle(
|
||||
await (dependencies.openSource ?? openRegularFile)(sourcePath, {
|
||||
symlinks: "follow",
|
||||
signal,
|
||||
}),
|
||||
);
|
||||
const destinationPath = join(root.path, basename(sourcePath));
|
||||
destination = new OwnedFileHandle(
|
||||
await (dependencies.openDestination ?? open)(
|
||||
destinationPath,
|
||||
"wx",
|
||||
0o600,
|
||||
),
|
||||
);
|
||||
const initial = await source.handle.stat();
|
||||
if (!Number.isSafeInteger(initial.size) || initial.size < 0)
|
||||
throw new RangeError(
|
||||
`Regular file size cannot be represented safely: ${sourcePath}`,
|
||||
);
|
||||
const hash = createHash("sha256");
|
||||
const chunk = Buffer.allocUnsafe(64 * 1024);
|
||||
let position = 0;
|
||||
while (position < initial.size) {
|
||||
signal?.throwIfAborted();
|
||||
const read = await source.handle.read(
|
||||
chunk,
|
||||
0,
|
||||
Math.min(chunk.length, initial.size - position),
|
||||
position,
|
||||
);
|
||||
signal?.throwIfAborted();
|
||||
if (read.bytesRead === 0)
|
||||
throw new AnalysisArtifactChangedError(
|
||||
operation,
|
||||
sourcePath,
|
||||
"the file ended before its admitted size was copied",
|
||||
);
|
||||
const bytes = chunk.subarray(0, read.bytesRead);
|
||||
let written = 0;
|
||||
while (written < bytes.length) {
|
||||
signal?.throwIfAborted();
|
||||
const result = await destination.handle.write(
|
||||
bytes,
|
||||
written,
|
||||
bytes.length - written,
|
||||
position + written,
|
||||
);
|
||||
if (result.bytesWritten === 0)
|
||||
throw new Error("Private ADB input snapshot stopped accepting bytes");
|
||||
written += result.bytesWritten;
|
||||
}
|
||||
hash.update(bytes);
|
||||
position += read.bytesRead;
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
const [opened, named] = await Promise.all([
|
||||
source.handle.stat(),
|
||||
stat(sourcePath),
|
||||
]);
|
||||
signal?.throwIfAborted();
|
||||
if (
|
||||
!sameRegularFileState(initial, opened) ||
|
||||
!sameRegularFileState(initial, named)
|
||||
)
|
||||
throw new AnalysisArtifactChangedError(
|
||||
operation,
|
||||
sourcePath,
|
||||
"the file changed while its private snapshot was copied",
|
||||
);
|
||||
await destination.handle.sync();
|
||||
await destination.handle.chmod(0o400);
|
||||
snapshot = {
|
||||
path: destinationPath,
|
||||
bytes: position,
|
||||
sha256: hash.digest("hex"),
|
||||
};
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof RegularFileAdmissionFailure) {
|
||||
source = cause.owner;
|
||||
primaryCause = cause.cause;
|
||||
} else if (cause instanceof AnalysisError) {
|
||||
primaryCause = cause;
|
||||
} else {
|
||||
primaryCause =
|
||||
signal?.aborted === true
|
||||
? new AnalysisCancelledError(operation, { cause })
|
||||
: cause;
|
||||
}
|
||||
}
|
||||
|
||||
for (const [owner, resource] of [
|
||||
[source, `${sourcePath} (ADB snapshot source handle)`],
|
||||
[
|
||||
destination,
|
||||
`${join(root.path, basename(sourcePath))} (ADB snapshot handle)`,
|
||||
],
|
||||
] as const) {
|
||||
if (owner === undefined) continue;
|
||||
const result = await resources.release({
|
||||
kind: "file-handle",
|
||||
resource,
|
||||
handle: owner,
|
||||
});
|
||||
if (result.kind === "failed") {
|
||||
cleanupResources.push(resource);
|
||||
cleanupCauses.push(result.cause);
|
||||
}
|
||||
}
|
||||
|
||||
if (cleanupCauses.length > 0)
|
||||
throw new AdbInputSnapshotFailure(
|
||||
operation,
|
||||
sourcePath,
|
||||
primaryCause,
|
||||
cleanupResources,
|
||||
new AggregateError(cleanupCauses, "ADB snapshot handles remain open"),
|
||||
);
|
||||
if (primaryCause instanceof AnalysisError) throw primaryCause;
|
||||
if (primaryCause !== undefined)
|
||||
throw new AnalysisOutputError(
|
||||
operation,
|
||||
`The caller input could not be staged for adb: ${sourcePath}`,
|
||||
{ cause: primaryCause },
|
||||
);
|
||||
if (snapshot === undefined)
|
||||
throw new Error("ADB input snapshot ended without a result");
|
||||
return snapshot;
|
||||
};
|
||||
+294
-6
@@ -9,10 +9,31 @@ import {
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { expect, onTestFinished, test } from "vitest";
|
||||
import { expect, onTestFinished, test, vi } from "vitest";
|
||||
|
||||
const adbTestHooks = vi.hoisted(() => ({
|
||||
afterScreencap: undefined as (() => void) | undefined,
|
||||
}));
|
||||
|
||||
vi.mock("../process/ExecFileOutput.js", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("../process/ExecFileOutput.js")>();
|
||||
return {
|
||||
...actual,
|
||||
execFileBytes: async (
|
||||
...arguments_: Parameters<typeof actual.execFileBytes>
|
||||
) => {
|
||||
const result = await actual.execFileBytes(...arguments_);
|
||||
if (arguments_[1].includes("exec-out")) adbTestHooks.afterScreencap?.();
|
||||
return result;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { AdbProvider } from "./AdbProvider.js";
|
||||
import { PrivateRuntimeRoot } from "../process/PrivateRuntimeRoot.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import { parseEvidence } from "../domain/evidence.js";
|
||||
import type { AdbRequest } from "../domain/adb/adbDeviceAnalysis.js";
|
||||
|
||||
const it = test.skipIf(process.platform === "win32");
|
||||
@@ -91,6 +112,36 @@ it("inspects the adb client without contacting the server", async () => {
|
||||
expect(result.contacted_adb_server).toBe(false);
|
||||
});
|
||||
|
||||
it("fails closed when adb exits successfully without a recognized version", async () => {
|
||||
const stdout = `unexpected adb output ${"x".repeat(700)}\n`;
|
||||
const stderr = `probe stderr ${"y".repeat(700)}\n`;
|
||||
const { binary } = await writeAdbStub(
|
||||
() => `printf '%s' '${stdout}'; printf '%s' '${stderr}' >&2; exit 0`,
|
||||
);
|
||||
await expect(
|
||||
providerOver(binary).inspectAvailability(),
|
||||
).resolves.toMatchObject({
|
||||
status: "unavailable",
|
||||
code: "version_unresolved",
|
||||
diagnostics: {
|
||||
version_probe: "unrecognized_output",
|
||||
stdout,
|
||||
stderr,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts adb's recognized protocol version when the build version is absent", async () => {
|
||||
const { binary } = await writeAdbStub(
|
||||
() => `printf 'Android Debug Bridge version 1.0.41\\n'`,
|
||||
);
|
||||
await expect(
|
||||
providerOver(binary).inspectAvailability(),
|
||||
).resolves.toMatchObject({
|
||||
status: "available",
|
||||
});
|
||||
});
|
||||
|
||||
it("lists devices with inferred kinds and observed daemon startup", async () => {
|
||||
const { binary } = await writeAdbStub(
|
||||
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
|
||||
@@ -121,6 +172,36 @@ fi`,
|
||||
]);
|
||||
});
|
||||
|
||||
it("observes daemon startup when notices are printed to stderr", async () => {
|
||||
const { binary } = await writeAdbStub(
|
||||
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
|
||||
if [ "$1" = "devices" ]; then
|
||||
printf '* daemon not running; starting now at tcp:5037\\n* daemon started successfully\\n' >&2
|
||||
printf 'List of devices attached\\n'
|
||||
printf 'emulator-5554 device product:sdk_gphone64_x86_64 model:sdk_gphone64_x86_64 device:emu64xa transport_id:1\\n'
|
||||
exit 0
|
||||
fi`,
|
||||
);
|
||||
const result = await evidenceResult(providerOver(binary), {
|
||||
operation: "list_adb_devices",
|
||||
input: {},
|
||||
});
|
||||
expect(result.may_have_started_adb_server).toBe(true);
|
||||
expect(result.devices).toEqual([
|
||||
{
|
||||
serial: "emulator-5554",
|
||||
state: "device",
|
||||
transport: "tcp",
|
||||
product: "sdk_gphone64_x86_64",
|
||||
model: "sdk_gphone64_x86_64",
|
||||
device: "emu64xa",
|
||||
transport_id: 1,
|
||||
kind: "emulator",
|
||||
kind_basis: "emulator_serial_prefix",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("refuses device listings with an invalid state token", async () => {
|
||||
const malformedLine = "serial deviceXYZ ";
|
||||
const { binary } = await writeAdbStub(
|
||||
@@ -649,7 +730,7 @@ it("projects package details from dumpsys package output", async () => {
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
version_name: "1.2.3",
|
||||
version_code: 45,
|
||||
version_code: "45",
|
||||
first_install_time: "2026-01-02 03:04:05",
|
||||
last_update_time: "2026-02-03 04:05:06",
|
||||
installer_package_name: "com.android.vending",
|
||||
@@ -659,6 +740,30 @@ it("projects package details from dumpsys package output", async () => {
|
||||
});
|
||||
});
|
||||
|
||||
// PackageInfo.getLongVersionCode() combines versionCodeMajor and versionCode
|
||||
// into a signed 64-bit long, which JSON numbers cannot hold exactly.
|
||||
it.each(["9007199254740993", "-4294967296"])(
|
||||
"keeps the long version code %s exactly as dumpsys prints it",
|
||||
async (versionCode) => {
|
||||
const { binary } = await writeAdbStub(
|
||||
shellStub({
|
||||
"dumpsys:package": `Packages:
|
||||
Package [com.example] (abcdef):
|
||||
versionCode=${versionCode} minSdk=24 targetSdk=34
|
||||
`,
|
||||
}),
|
||||
);
|
||||
const result = await evidenceResult(providerOver(binary), {
|
||||
operation: "inspect_adb_package",
|
||||
input: { serial: "emulator-5554", package: "com.example" },
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
version_code: versionCode,
|
||||
coverage: "complete",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// Android 10 and 11 append ": restricted=true" to hard- and soft-restricted
|
||||
// entries of the requested-permissions list (Settings.dumpPackageLPr).
|
||||
it("counts requested permissions that the device marks as restricted", async () => {
|
||||
@@ -796,6 +901,88 @@ exit 1`,
|
||||
});
|
||||
});
|
||||
|
||||
it("pushes the same private bytes that its reported digest identifies", async () => {
|
||||
const { binary, root } = await writeAdbStub(
|
||||
(stubRoot) => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
|
||||
if [ "$1" = "-s" ] && [ "$4" = "test" ]; then exit 1; fi
|
||||
if [ "$1" = "-s" ] && [ "$3" = "push" ]; then
|
||||
printf 'replacement' > "${join(stubRoot, "payload.bin")}"
|
||||
cat "$4" > "${join(stubRoot, "consumed-push.bin")}"
|
||||
exit 0
|
||||
fi
|
||||
if [ "$1" = "-s" ] && [ "$4" = "sha256sum" ]; then exit 1; fi
|
||||
exit 1`,
|
||||
);
|
||||
const localFile = join(root, "payload.bin");
|
||||
await writeFile(localFile, "original-push");
|
||||
const digest = createHash("sha256").update("original-push").digest("hex");
|
||||
const pushed = await evidenceResult(providerOver(binary), {
|
||||
operation: "push_adb_file",
|
||||
input: {
|
||||
serial: "emulator-5554",
|
||||
local_path: localFile,
|
||||
device_path: "/data/local/tmp/payload.bin",
|
||||
overwrite: false,
|
||||
},
|
||||
});
|
||||
expect(await readFile(join(root, "consumed-push.bin"), "utf8")).toBe(
|
||||
"original-push",
|
||||
);
|
||||
expect(await readFile(localFile, "utf8")).toBe("replacement");
|
||||
expect(pushed).toMatchObject({
|
||||
local_path: localFile,
|
||||
bytes: 13,
|
||||
sha256: digest,
|
||||
device_sha256: null,
|
||||
device_digest_source: "unavailable",
|
||||
});
|
||||
});
|
||||
|
||||
it("retains failed snapshot cleanup with completed push Evidence until close retries", async () => {
|
||||
const { binary, root } = await writeAdbStub(
|
||||
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
|
||||
if [ "$1" = "-s" ] && [ "$4" = "test" ]; then exit 1; fi
|
||||
if [ "$1" = "-s" ] && [ "$3" = "push" ]; then exit 0; fi
|
||||
if [ "$1" = "-s" ] && [ "$4" = "sha256sum" ]; then exit 1; fi
|
||||
exit 1`,
|
||||
);
|
||||
const localFile = join(root, "cleanup-payload.bin");
|
||||
await writeFile(localFile, "payload");
|
||||
let closeAttempts = 0;
|
||||
const provider = new AdbProvider({
|
||||
environment: { REA_ADB_PATH: binary },
|
||||
createRuntimeRoot: async (options) => {
|
||||
const runtime = await PrivateRuntimeRoot.create(options);
|
||||
return {
|
||||
path: runtime.path,
|
||||
close: async () => {
|
||||
closeAttempts += 1;
|
||||
if (closeAttempts === 1) throw new Error("controlled cleanup fault");
|
||||
await runtime.close();
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
const result = await provider.execute({
|
||||
operation: "push_adb_file",
|
||||
input: {
|
||||
serial: "emulator-5554",
|
||||
local_path: localFile,
|
||||
device_path: "/data/local/tmp/cleanup-payload.bin",
|
||||
overwrite: false,
|
||||
},
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("expected snapshot cleanup to be reported");
|
||||
expect(result.error.cleanupResources).toHaveLength(1);
|
||||
expect(result.error.partialObservation).toMatchObject({
|
||||
operation: "push_adb_file",
|
||||
normalized_result: { bytes: 7 },
|
||||
});
|
||||
await provider.close();
|
||||
expect(closeAttempts).toBe(2);
|
||||
});
|
||||
|
||||
it("reports a digest mismatch after push as a changed artifact", async () => {
|
||||
const { binary, root } = await writeAdbStub(
|
||||
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
|
||||
@@ -806,7 +993,7 @@ exit 1`,
|
||||
);
|
||||
const localFile = join(root, "payload.bin");
|
||||
await writeFile(localFile, "push-payload");
|
||||
const projected = await refusedBy(providerOver(binary), {
|
||||
const result = await providerOver(binary).execute({
|
||||
operation: "push_adb_file",
|
||||
input: {
|
||||
serial: "emulator-5554",
|
||||
@@ -815,6 +1002,20 @@ exit 1`,
|
||||
overwrite: false,
|
||||
},
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("expected a changed-artifact result");
|
||||
const evidence = parseEvidence(result.error.partialObservation);
|
||||
expect(evidence).toMatchObject({
|
||||
operation: "push_adb_file",
|
||||
normalized_result: {
|
||||
local_path: localFile,
|
||||
device_path: "/data/local/tmp/payload.bin",
|
||||
bytes: Buffer.byteLength("push-payload"),
|
||||
sha256: createHash("sha256").update("push-payload").digest("hex"),
|
||||
device_digest_source: "device_sha256sum",
|
||||
},
|
||||
});
|
||||
const projected = projectAnalysisError(result.error);
|
||||
expect(projected).toMatchObject({ code: "artifact_changed" });
|
||||
});
|
||||
|
||||
@@ -913,6 +1114,37 @@ exit 1`,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("stops a screen capture aborted after adb returns but before publication", async () => {
|
||||
const { binary, root } = await writeAdbStub(
|
||||
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
|
||||
if [ "$1" = "-s" ] && [ "$3" = "exec-out" ]; then
|
||||
printf '\\211PNG\\r\\n\\032\\n\\0\\0\\0\\rIHDR\\0\\0\\0\\004\\0\\0\\0\\003'
|
||||
exit 0
|
||||
fi
|
||||
exit 1`,
|
||||
);
|
||||
const output = join(root, "cancelled-capture");
|
||||
const controller = new AbortController();
|
||||
adbTestHooks.afterScreencap = () => controller.abort();
|
||||
try {
|
||||
const result = await providerOver(binary).execute(
|
||||
{
|
||||
operation: "capture_adb_screen",
|
||||
input: { serial: "emulator-5554", output_directory: output },
|
||||
},
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok)
|
||||
expect(projectAnalysisError(result.error).code).toBe("cancelled");
|
||||
await expect(readFile(join(output, "screen.png"))).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
});
|
||||
} finally {
|
||||
adbTestHooks.afterScreencap = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
it("lists processes, features, and services through fixed commands", async () => {
|
||||
const { binary } = await writeAdbStub(
|
||||
shellStub({
|
||||
@@ -1048,6 +1280,26 @@ it("inspects the display and window focus", async () => {
|
||||
);
|
||||
});
|
||||
|
||||
// Older builds print `wm density` as a bare number; the display projection
|
||||
// must keep it as the physical density (morluto/rea#3942).
|
||||
it("keeps a bare numeric wm density as the physical density", async () => {
|
||||
const { binary } = await writeAdbStub(
|
||||
shellStub({
|
||||
"wm:size": "Physical size: 1080x2340\n",
|
||||
"wm:density": "420\n",
|
||||
}),
|
||||
);
|
||||
const display = await evidenceResult(providerOver(binary), {
|
||||
operation: "inspect_adb_display",
|
||||
input: { serial: "emulator-5554" },
|
||||
});
|
||||
expect(display).toMatchObject({
|
||||
physical_size: { width: 1080, height: 2340 },
|
||||
physical_density: 420,
|
||||
override_density: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("reads Android 16 focus declarations from displays when windows omits them", async () => {
|
||||
const { binary } = await writeAdbStub(
|
||||
shellStub({
|
||||
@@ -1162,15 +1414,21 @@ exit 1`,
|
||||
const apk = join(root, "app.apk");
|
||||
await writeFile(apk, "apk");
|
||||
const digest = createHash("sha256").update("apk").digest("hex");
|
||||
const installed = await evidenceResult(providerOver(binary), {
|
||||
const execution = await providerOver(binary).execute({
|
||||
operation: "install_adb_package",
|
||||
input: { serial: "emulator-5554", apk_path: apk, replace: false },
|
||||
});
|
||||
expect(installed).toMatchObject({
|
||||
expect(execution.ok).toBe(true);
|
||||
if (!execution.ok) throw execution.error;
|
||||
expect(execution.value.result).toMatchObject({
|
||||
bytes: 3,
|
||||
sha256: digest,
|
||||
replaced: false,
|
||||
replace_requested: false,
|
||||
replaced: null,
|
||||
});
|
||||
expect(execution.value.limitations).toContain(
|
||||
"Whether an existing package was replaced is unknown; replace_requested records only the adb install mode",
|
||||
);
|
||||
|
||||
const { binary: failing } = await writeAdbStub(
|
||||
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
|
||||
@@ -1189,6 +1447,36 @@ exit 1`,
|
||||
expect(projected.message).toContain("INSTALL_FAILED_ALREADY_EXISTS");
|
||||
});
|
||||
|
||||
it("installs the same private APK bytes that its reported digest identifies", async () => {
|
||||
const { binary, root } = await writeAdbStub(
|
||||
(stubRoot) => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
|
||||
if [ "$1" = "-s" ] && [ "$3" = "install" ]; then
|
||||
case "$4" in *.apk) ;; *) printf 'install input needs an APK suffix\\n' >&2; exit 1 ;; esac
|
||||
printf 'replacement-apk' > "${join(stubRoot, "app.apk")}"
|
||||
cat "$4" > "${join(stubRoot, "consumed-app.apk")}"
|
||||
printf 'Success\\n'
|
||||
exit 0
|
||||
fi
|
||||
exit 1`,
|
||||
);
|
||||
const apk = join(root, "app.apk");
|
||||
await writeFile(apk, "original-apk");
|
||||
const digest = createHash("sha256").update("original-apk").digest("hex");
|
||||
const installed = await evidenceResult(providerOver(binary), {
|
||||
operation: "install_adb_package",
|
||||
input: { serial: "emulator-5554", apk_path: apk, replace: false },
|
||||
});
|
||||
expect(await readFile(join(root, "consumed-app.apk"), "utf8")).toBe(
|
||||
"original-apk",
|
||||
);
|
||||
expect(await readFile(apk, "utf8")).toBe("replacement-apk");
|
||||
expect(installed).toMatchObject({
|
||||
apk_path: apk,
|
||||
bytes: 12,
|
||||
sha256: digest,
|
||||
});
|
||||
});
|
||||
|
||||
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
|
||||
|
||||
+1170
-392
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,49 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { parseAmStartOutput } from "./AdbShellOutput.js";
|
||||
import { parseAmStartOutput, parseWmDensityOutput } from "./AdbShellOutput.js";
|
||||
|
||||
describe("parseWmDensityOutput", () => {
|
||||
it("reads labeled physical and override densities", () => {
|
||||
expect(
|
||||
parseWmDensityOutput("Physical density: 420\nOverride density: 480\n"),
|
||||
).toEqual({ physical: 420, override: 480 });
|
||||
expect(parseWmDensityOutput("Physical density: 420\n")).toEqual({
|
||||
physical: 420,
|
||||
override: null,
|
||||
});
|
||||
});
|
||||
|
||||
// Older builds print the physical density as a bare number
|
||||
// (morluto/rea#3942); it must not be lost as an unread declaration.
|
||||
it("reads a bare numeric density as the physical density", () => {
|
||||
expect(parseWmDensityOutput("420\n")).toEqual({
|
||||
physical: 420,
|
||||
override: null,
|
||||
});
|
||||
expect(parseWmDensityOutput("420\r\n")).toEqual({
|
||||
physical: 420,
|
||||
override: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers a labeled physical density over a bare number", () => {
|
||||
expect(parseWmDensityOutput("560\nPhysical density: 420\n")).toEqual({
|
||||
physical: 420,
|
||||
override: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports no density for unrecognized output", () => {
|
||||
expect(parseWmDensityOutput("")).toEqual({
|
||||
physical: null,
|
||||
override: null,
|
||||
});
|
||||
expect(parseWmDensityOutput("420dpi\n")).toEqual({
|
||||
physical: null,
|
||||
override: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const starting =
|
||||
"Starting: Intent { act=android.intent.action.MAIN cmp=com.example/.Main }";
|
||||
|
||||
@@ -125,6 +125,7 @@ const SIZE_PAIR = /^Physical size:\s+(\d+)x(\d+)$/u;
|
||||
const SIZE_OVERRIDE = /^Override size:\s+(\d+)x(\d+)$/u;
|
||||
const DENSITY_PHYSICAL = /^Physical density:\s+(\d+)$/u;
|
||||
const DENSITY_OVERRIDE = /^Override density:\s+(\d+)$/u;
|
||||
const DENSITY_BARE = /^(\d+)$/u;
|
||||
|
||||
/** Parse `wm size` output into physical and optional override sizes. */
|
||||
export const parseWmSizeOutput = (
|
||||
@@ -153,7 +154,12 @@ export const parseWmSizeOutput = (
|
||||
return { physical, override };
|
||||
};
|
||||
|
||||
/** Parse `wm density` output. Older builds print a bare number. */
|
||||
/**
|
||||
* Parse `wm density` output. Current builds print labeled physical and
|
||||
* optional override lines; older builds print the physical density as a
|
||||
* bare number on its own line (morluto/rea#3942). A labeled declaration
|
||||
* wins over a bare one so a mixed dump cannot downgrade the physical value.
|
||||
*/
|
||||
export const parseWmDensityOutput = (
|
||||
stdout: string,
|
||||
): {
|
||||
@@ -162,14 +168,17 @@ export const parseWmDensityOutput = (
|
||||
} => {
|
||||
let physical = null;
|
||||
let override = null;
|
||||
let bare = null;
|
||||
for (const line of stdout.split(/\r?\n/u)) {
|
||||
const trimmed = line.trim();
|
||||
const physicalMatch = DENSITY_PHYSICAL.exec(trimmed);
|
||||
if (physicalMatch !== null) physical = Number(physicalMatch[1]!);
|
||||
const overrideMatch = DENSITY_OVERRIDE.exec(trimmed);
|
||||
if (overrideMatch !== null) override = Number(overrideMatch[1]!);
|
||||
const bareMatch = DENSITY_BARE.exec(trimmed);
|
||||
if (bareMatch !== null && bare === null) bare = Number(bareMatch[1]!);
|
||||
}
|
||||
return { physical, override };
|
||||
return { physical: physical ?? bare, override };
|
||||
};
|
||||
|
||||
/** Parse focus declarations from `dumpsys window windows` or `displays`. */
|
||||
@@ -193,7 +202,8 @@ export const parseWindowFocusOutput = (
|
||||
|
||||
export interface AdbPackageDetails {
|
||||
readonly versionName: string | null;
|
||||
readonly versionCode: number | null;
|
||||
/** Signed 64-bit long version code exactly as Package Manager prints it. */
|
||||
readonly versionCode: string | null;
|
||||
readonly firstInstallTime: string | null;
|
||||
readonly lastUpdateTime: string | null;
|
||||
readonly installerPackageName: string | null;
|
||||
@@ -214,7 +224,7 @@ export const parseDumpsysPackageOutput = (
|
||||
): AdbPackageDetails => {
|
||||
const details: {
|
||||
versionName: string | null;
|
||||
versionCode: number | null;
|
||||
versionCode: string | null;
|
||||
firstInstallTime: string | null;
|
||||
lastUpdateTime: string | null;
|
||||
installerPackageName: string | null;
|
||||
@@ -252,8 +262,8 @@ export const parseDumpsysPackageOutput = (
|
||||
if (key === "versionName" && details.versionName === null)
|
||||
details.versionName = value === "" ? null : value;
|
||||
else if (key === "versionCode" && details.versionCode === null) {
|
||||
const parsed = /^(\d+)/u.exec(value);
|
||||
if (parsed !== null) details.versionCode = Number(parsed[1]!);
|
||||
const parsed = /^(-?\d+)/u.exec(value);
|
||||
if (parsed !== null) details.versionCode = parsed[1]!;
|
||||
} else if (key === "firstInstallTime" && details.firstInstallTime === null)
|
||||
details.firstInstallTime = value === "" ? null : value;
|
||||
else if (key === "lastUpdateTime" && details.lastUpdateTime === null)
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import {
|
||||
openRegularFile,
|
||||
assertRegularFileActive,
|
||||
sameRegularFileState,
|
||||
} from "../filesystem/RegularFile.js";
|
||||
import type { AndroidOperation } from "../domain/android/androidAnalysis.js";
|
||||
@@ -25,7 +26,7 @@ export const snapshotAndroidTarget = async (
|
||||
const snapshot = join(root, "target.apk");
|
||||
try {
|
||||
await copyAndroidSnapshot(path, snapshot, sha256, signal);
|
||||
signal?.throwIfAborted();
|
||||
assertRegularFileActive(signal);
|
||||
await chmod(snapshot, 0o400);
|
||||
return snapshot;
|
||||
} catch (cause: unknown) {
|
||||
@@ -51,7 +52,7 @@ export const snapshotAndroidEngine = async (
|
||||
const path = join(root, "engine.jar");
|
||||
try {
|
||||
const observed = await copyAndroidSnapshot(source, path, sha256, signal);
|
||||
signal?.throwIfAborted();
|
||||
assertRegularFileActive(signal);
|
||||
await chmod(path, 0o400);
|
||||
return { path, sha256: observed };
|
||||
} catch (cause: unknown) {
|
||||
@@ -71,7 +72,7 @@ const copyAndroidSnapshot = async (
|
||||
expectedSha256: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<string> => {
|
||||
signal?.throwIfAborted();
|
||||
assertRegularFileActive(signal);
|
||||
const source = await openRegularFile(sourcePath, {
|
||||
symlinks: "follow",
|
||||
signal,
|
||||
@@ -84,7 +85,7 @@ const copyAndroidSnapshot = async (
|
||||
const hash = createHash("sha256");
|
||||
try {
|
||||
const initial = await source.stat();
|
||||
signal?.throwIfAborted();
|
||||
assertRegularFileActive(signal);
|
||||
const digest = new Transform({
|
||||
transform(
|
||||
chunk: Buffer,
|
||||
@@ -106,7 +107,7 @@ const copyAndroidSnapshot = async (
|
||||
});
|
||||
try {
|
||||
destination = await open(snapshotPath, "wx", 0o600);
|
||||
signal?.throwIfAborted();
|
||||
assertRegularFileActive(signal);
|
||||
inputStream = source.createReadStream({
|
||||
start: 0,
|
||||
end: initial.size,
|
||||
@@ -114,7 +115,7 @@ const copyAndroidSnapshot = async (
|
||||
});
|
||||
outputStream = destination.createWriteStream({ autoClose: false });
|
||||
await pipeline(inputStream, digest, outputStream, { signal });
|
||||
signal?.throwIfAborted();
|
||||
assertRegularFileActive(signal);
|
||||
await verifySourceState(source, sourcePath, initial, copiedBytes);
|
||||
const observedSha256 = hash.digest("hex");
|
||||
if (observedSha256 !== expectedSha256)
|
||||
|
||||
@@ -3,9 +3,11 @@ import { access, realpath, stat } from "node:fs/promises";
|
||||
import { delimiter, isAbsolute, join, resolve } from "node:path";
|
||||
import { inspectWindowsNativeReadiness } from "../process/WindowsAuthority.js";
|
||||
import { systemWindowsNativeAuthority } from "../windows/WindowsNativeLoader.js";
|
||||
import { providerStartupTimeoutSchema } from "../config/providerStartupTimeout.js";
|
||||
import { AnalysisCapabilityUnavailableError } from "../domain/analysisErrorCore.js";
|
||||
import type { AndroidOperation } from "../domain/android/androidAnalysis.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import type { ProviderAvailability } from "../application/AnalysisProvider.js";
|
||||
import { ZipArtifactReader } from "../artifacts/ZipArtifactReader.js";
|
||||
import {
|
||||
@@ -14,10 +16,12 @@ import {
|
||||
} from "../process/ExecFileOutput.js";
|
||||
import type { DoctorProviderCheck } from "../application/Doctor.js";
|
||||
import {
|
||||
DEFAULT_JADX_OPERATION_TIMEOUT_MS,
|
||||
JADX_JAR_CONFIGURATION_REMEDIATION,
|
||||
JADX_JAVA_PROBE_REMEDIATION,
|
||||
JADX_JAVA_RUNTIME_REMEDIATION,
|
||||
JADX_JVM_OPTIONS_REMEDIATION,
|
||||
JADX_OPERATION_TIMEOUT_REMEDIATION,
|
||||
JADX_UNSUPPORTED_HOST_REMEDIATION,
|
||||
JADX_WINDOWS_NATIVE_REMEDIATION,
|
||||
} from "./JadxRelease.js";
|
||||
@@ -57,6 +61,7 @@ export type JadxFailureKind =
|
||||
| "java_runtime"
|
||||
| "java_home"
|
||||
| "jvm_options"
|
||||
| "operation_timeout"
|
||||
| "jar"
|
||||
| "java_probe"
|
||||
| "unsupported_host";
|
||||
@@ -68,6 +73,7 @@ export const JADX_FAILURE_KINDS: readonly JadxFailureKind[] = [
|
||||
"java_runtime",
|
||||
"java_home",
|
||||
"jvm_options",
|
||||
"operation_timeout",
|
||||
"jar",
|
||||
"java_probe",
|
||||
"unsupported_host",
|
||||
@@ -114,6 +120,11 @@ export const projectJadxFailureOutcome = (
|
||||
remediation: JADX_JVM_OPTIONS_REMEDIATION,
|
||||
classification: drift,
|
||||
};
|
||||
case "operation_timeout":
|
||||
return {
|
||||
remediation: JADX_OPERATION_TIMEOUT_REMEDIATION,
|
||||
classification: drift,
|
||||
};
|
||||
case "jar":
|
||||
return {
|
||||
remediation: JADX_JAR_CONFIGURATION_REMEDIATION,
|
||||
@@ -155,6 +166,7 @@ export const inspectJadxAvailability = async (
|
||||
signal?: AbortSignal,
|
||||
): Promise<ProviderAvailability> => {
|
||||
try {
|
||||
const operationTimeoutMs = readOperationTimeout(environment);
|
||||
const configuration = await readJadxConfigurationInputs(
|
||||
environment,
|
||||
signal,
|
||||
@@ -169,6 +181,7 @@ export const inspectJadxAvailability = async (
|
||||
java_executable: configuration.java,
|
||||
jdk_compiler: true,
|
||||
jar_path: configuration.jar,
|
||||
operation_timeout_ms: operationTimeoutMs,
|
||||
},
|
||||
};
|
||||
} catch (cause) {
|
||||
@@ -184,6 +197,22 @@ export const inspectJadxAvailability = async (
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve the whole-operation deadline before any admission work starts, so
|
||||
* one value bounds the provider operation and each upstream call.
|
||||
*/
|
||||
export const resolveJadxOperationTimeout = (
|
||||
environment: Readonly<Record<string, string | undefined>>,
|
||||
operation: AndroidOperation,
|
||||
): Result<number, AnalysisCapabilityUnavailableError> => {
|
||||
try {
|
||||
return ok(readOperationTimeout(environment));
|
||||
} catch (cause) {
|
||||
if (!(cause instanceof JadxConfigurationFailure)) throw cause;
|
||||
return err(projectConfigurationFailure(operation, cause));
|
||||
}
|
||||
};
|
||||
|
||||
/** Resolve caller-selected paths and JVM options without probing their contents. */
|
||||
export const resolveJadxConfigurationInputs = async (
|
||||
environment: Readonly<Record<string, string | undefined>>,
|
||||
@@ -508,6 +537,21 @@ const resolveJvmArguments = (
|
||||
return result;
|
||||
};
|
||||
|
||||
const readOperationTimeout = (
|
||||
environment: Readonly<Record<string, string | undefined>>,
|
||||
): number => {
|
||||
const value = environment.REA_JADX_OPERATION_TIMEOUT_MS;
|
||||
if (value === undefined) return DEFAULT_JADX_OPERATION_TIMEOUT_MS;
|
||||
const parsed = providerStartupTimeoutSchema.safeParse(value);
|
||||
if (parsed.success) return parsed.data;
|
||||
throw configurationFailure(
|
||||
"open_options_invalid",
|
||||
`REA_JADX_OPERATION_TIMEOUT_MS must be a decimal millisecond count from 1 to 2147483647; received ${value}.`,
|
||||
{ setting: "REA_JADX_OPERATION_TIMEOUT_MS", value },
|
||||
"operation_timeout",
|
||||
);
|
||||
};
|
||||
|
||||
const inspectJavaRuntime = async (
|
||||
java: string,
|
||||
environment: Readonly<Record<string, string | undefined>>,
|
||||
|
||||
@@ -49,6 +49,10 @@ export const inspectJadxProvider = async (
|
||||
"REA_JADX_ACTIVE_PROCESSOR_COUNT",
|
||||
environment.REA_JADX_ACTIVE_PROCESSOR_COUNT,
|
||||
),
|
||||
...optionalEntry(
|
||||
"REA_JADX_OPERATION_TIMEOUT_MS",
|
||||
environment.REA_JADX_OPERATION_TIMEOUT_MS,
|
||||
),
|
||||
},
|
||||
checks: [configurationCheck(configured, availability, jarPath)],
|
||||
};
|
||||
@@ -70,6 +74,7 @@ const configurationCheck = (
|
||||
): DoctorProviderCheck => {
|
||||
const code = availability.status === "unavailable" ? availability.code : null;
|
||||
if (availability.status === "available" && jarPath !== undefined) {
|
||||
const operationTimeout = availability.diagnostics["operation_timeout_ms"];
|
||||
return {
|
||||
name: "configuration",
|
||||
ok: true,
|
||||
@@ -77,6 +82,9 @@ const configurationCheck = (
|
||||
detail: `JADX MCP bridge JAR validated at ${jarPath}; its path is recorded for agent registrations.`,
|
||||
remediation: null,
|
||||
classification: "config_drift",
|
||||
...(typeof operationTimeout === "number"
|
||||
? { details: { operation_timeout_ms: operationTimeout } }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
// The failure kind is tagged at the configuration boundary; project its
|
||||
|
||||
+188
-29
@@ -14,6 +14,7 @@ import {
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
|
||||
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import type { AndroidRequest } from "../domain/android/androidAnalysis.js";
|
||||
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
|
||||
@@ -26,18 +27,27 @@ import {
|
||||
snapshotAndroidEngine,
|
||||
snapshotAndroidTarget,
|
||||
} from "./AndroidTargetSnapshot.js";
|
||||
import { hashStableFile } from "../filesystem/StableFileHash.js";
|
||||
import {
|
||||
hashStableFile,
|
||||
stableFileHashCause,
|
||||
StableFileHashFailure,
|
||||
} from "../filesystem/StableFileHash.js";
|
||||
import type { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
|
||||
import { RegularFileChangedError } from "../filesystem/RegularFile.js";
|
||||
import {
|
||||
RegularFileCleanupFailure,
|
||||
regularFileCancellation,
|
||||
} from "../filesystem/OwnedRegularFile.js";
|
||||
import {
|
||||
inspectJadxAvailability,
|
||||
resolveJadxConfigurationInputs,
|
||||
resolveJadxOperationTimeout,
|
||||
validateJadxConfiguration,
|
||||
} from "./JadxConfiguration.js";
|
||||
import type { JadxLauncher } from "./JadxMcpTransport.js";
|
||||
|
||||
import { JadxSession } from "./JadxSession.js";
|
||||
|
||||
const OPERATION_TIMEOUT_MS = 120_000;
|
||||
const BRIDGE_SOURCE = fileURLToPath(
|
||||
new URL("../../bridge/android/ReaJadxBridge.java", import.meta.url),
|
||||
);
|
||||
@@ -62,15 +72,62 @@ const executionError = (context: {
|
||||
cause: unknown;
|
||||
request: AndroidRequest;
|
||||
timeout: AbortSignal;
|
||||
timeoutMs: number;
|
||||
signal: AbortSignal | undefined;
|
||||
session: JadxSession | undefined;
|
||||
}): AnalysisError => {
|
||||
const { cause, request, timeout, signal, session } = context;
|
||||
if (signal?.aborted === true)
|
||||
return new AnalysisCancelledError(request.operation);
|
||||
if (timeout.aborted)
|
||||
return new AnalysisTimeoutError(request.operation, OPERATION_TIMEOUT_MS);
|
||||
const { cause, request, timeout, timeoutMs, signal, session } = context;
|
||||
const cancellation = regularFileCancellation(cause);
|
||||
const cancellationError =
|
||||
cancellation === undefined
|
||||
? undefined
|
||||
: timeout.aborted && cancellation.reason === timeout.reason
|
||||
? new AnalysisTimeoutError(request.operation, timeoutMs, {
|
||||
cause: cancellation.reason,
|
||||
})
|
||||
: new AnalysisCancelledError(request.operation, {
|
||||
cause: cancellation.reason,
|
||||
});
|
||||
if (cause instanceof RegularFileCleanupFailure) {
|
||||
const primaryCause =
|
||||
cause.outcome.kind === "failed"
|
||||
? stableFileHashCause(cause.outcome.cause)
|
||||
: cause;
|
||||
const primary =
|
||||
cancellationError ??
|
||||
(primaryCause instanceof AnalysisError
|
||||
? primaryCause
|
||||
: new ProviderAdapterError("jadx", request.operation, {
|
||||
cause: primaryCause,
|
||||
userMessage:
|
||||
primaryCause instanceof Error
|
||||
? primaryCause.message
|
||||
: String(primaryCause),
|
||||
}));
|
||||
const cleanup = new ProviderCleanupError(
|
||||
"jadx",
|
||||
[cause.path],
|
||||
{
|
||||
reason:
|
||||
cause.cleanupCause instanceof Error
|
||||
? cause.cleanupCause.message
|
||||
: String(cause.cleanupCause),
|
||||
},
|
||||
{ operation: request.operation, cause },
|
||||
);
|
||||
return analysisErrorWithCleanupFailure(primary, cleanup, request.operation);
|
||||
}
|
||||
if (cancellationError !== undefined) return cancellationError;
|
||||
if (cause instanceof StableFileHashFailure)
|
||||
return new ProviderAdapterError("jadx", request.operation, {
|
||||
cause: cause.cause,
|
||||
userMessage: cause.message,
|
||||
});
|
||||
if (cause instanceof AnalysisError) return cause;
|
||||
if (signal?.aborted === true)
|
||||
return new AnalysisCancelledError(request.operation, { cause });
|
||||
if (timeout.aborted)
|
||||
return new AnalysisTimeoutError(request.operation, timeoutMs);
|
||||
const protocolFailure = session?.transport.failureReason();
|
||||
if (protocolFailure !== undefined && protocolFailure !== null)
|
||||
return new AnalysisOutputError(request.operation, protocolFailure, {
|
||||
@@ -142,13 +199,14 @@ const hashAndroidSource = async (
|
||||
path: string,
|
||||
operation: string,
|
||||
signal: AbortSignal,
|
||||
retainFailedClose: (owner: OwnedFileHandle) => void,
|
||||
): Promise<string> => {
|
||||
try {
|
||||
return (await hashStableFile(path, signal)).sha256;
|
||||
return (await hashStableFile(path, { signal, retainFailedClose })).sha256;
|
||||
} catch (cause: unknown) {
|
||||
if (signal.aborted) throw cause;
|
||||
if (cause instanceof RegularFileChangedError)
|
||||
throw androidSourceChanged(operation, path, cause);
|
||||
const physical = stableFileHashCause(cause);
|
||||
if (physical instanceof RegularFileChangedError)
|
||||
throw androidSourceChanged(operation, path, physical);
|
||||
throw cause;
|
||||
}
|
||||
};
|
||||
@@ -156,7 +214,10 @@ const hashAndroidSource = async (
|
||||
interface PendingCleanup {
|
||||
session: JadxSession | undefined;
|
||||
root: JadxWorkspaceRoot | undefined;
|
||||
readonly fileHandles: Map<OwnedFileHandle, string>;
|
||||
readonly previousError: AnalysisError | undefined;
|
||||
readonly operationCause: unknown;
|
||||
reportedErrors: WeakRef<AnalysisError>[];
|
||||
}
|
||||
|
||||
const cleanup = async (
|
||||
@@ -167,30 +228,82 @@ const cleanup = async (
|
||||
previous_error:
|
||||
previousError === undefined ? null : projectAnalysisError(previousError),
|
||||
};
|
||||
let sessionCloseFailed = false;
|
||||
let sessionCloseCause: unknown;
|
||||
try {
|
||||
await session?.close();
|
||||
resources.session = undefined;
|
||||
} catch (cause) {
|
||||
// Retain an uncertain workspace until the caller can resolve process ownership.
|
||||
const error = new ProviderCleanupError(
|
||||
// Still drain independent hash descriptors, but keep the workspace until
|
||||
// this session's process ownership is proven stopped.
|
||||
sessionCloseFailed = true;
|
||||
sessionCloseCause = cause;
|
||||
}
|
||||
const failedHandles: {
|
||||
readonly path: string;
|
||||
readonly owner: OwnedFileHandle;
|
||||
readonly cause: unknown;
|
||||
}[] = [];
|
||||
for (const [owner, path] of resources.fileHandles) {
|
||||
try {
|
||||
await owner.close();
|
||||
if (
|
||||
resources.operationCause instanceof RegularFileCleanupFailure &&
|
||||
resources.operationCause.owner === owner
|
||||
)
|
||||
previousError?.resolveCleanup();
|
||||
resources.fileHandles.delete(owner);
|
||||
} catch (cause: unknown) {
|
||||
failedHandles.push({ path, owner, cause });
|
||||
}
|
||||
}
|
||||
if (sessionCloseFailed || failedHandles.length > 0) {
|
||||
const cleanupCauses = [
|
||||
...(sessionCloseFailed ? [sessionCloseCause] : []),
|
||||
...failedHandles.map(({ cause }) => cause),
|
||||
];
|
||||
const cleanupResources = [
|
||||
...(!sessionCloseFailed
|
||||
? []
|
||||
: [
|
||||
session?.transport.runId ?? "unknown",
|
||||
...(root ? [root.path] : []),
|
||||
]),
|
||||
...failedHandles.map(({ path }) => path),
|
||||
];
|
||||
const cleanupError = new ProviderCleanupError(
|
||||
"jadx",
|
||||
[
|
||||
session?.transport.runId ?? "unknown",
|
||||
...(root === undefined ? [] : [root.path]),
|
||||
],
|
||||
cleanupResources,
|
||||
{
|
||||
...diagnostics,
|
||||
reason: cause instanceof Error ? cause.message : String(cause),
|
||||
provider_cleanup:
|
||||
cause instanceof ProviderCleanupError
|
||||
? (cause.diagnostics ?? null)
|
||||
reason: cleanupCauses
|
||||
.map((failure) =>
|
||||
failure instanceof Error ? failure.message : String(failure),
|
||||
)
|
||||
.join("; "),
|
||||
cleanup_causes: cleanupCauses.map((failure) =>
|
||||
failure instanceof Error ? failure.message : String(failure),
|
||||
),
|
||||
provider_cleanup: cleanupCauses.map((failure) =>
|
||||
failure instanceof ProviderCleanupError
|
||||
? (failure.diagnostics ?? null)
|
||||
: null,
|
||||
),
|
||||
},
|
||||
{
|
||||
cause: new AggregateError(
|
||||
[
|
||||
...(previousError === undefined ? [] : [previousError]),
|
||||
resources.operationCause,
|
||||
...cleanupCauses,
|
||||
],
|
||||
"JADX operation and resource cleanup failed",
|
||||
),
|
||||
},
|
||||
{ cause: previousError ?? cause },
|
||||
);
|
||||
if (previousError?.partialObservation !== undefined)
|
||||
error.retainPartialObservation(previousError.partialObservation);
|
||||
return err(error);
|
||||
cleanupError.retainPartialObservation(previousError.partialObservation);
|
||||
return err(cleanupError);
|
||||
}
|
||||
try {
|
||||
await root?.close();
|
||||
@@ -299,13 +412,29 @@ export class JadxProvider implements AndroidAnalysisPort {
|
||||
this.#pendingCleanup = {
|
||||
session: this.#retained.session,
|
||||
root: this.#retained.root,
|
||||
fileHandles: new Map(),
|
||||
previousError: undefined,
|
||||
operationCause: undefined,
|
||||
reportedErrors: [],
|
||||
};
|
||||
this.#retained = undefined;
|
||||
}
|
||||
if (this.#pendingCleanup === undefined) return ok(undefined);
|
||||
const result = await cleanup(this.#pendingCleanup);
|
||||
if (result.ok) this.#pendingCleanup = undefined;
|
||||
const pending = this.#pendingCleanup;
|
||||
const result = await cleanup(pending);
|
||||
if (result.ok) {
|
||||
for (const reportedError of pending.reportedErrors)
|
||||
reportedError.deref()?.resolveCleanup();
|
||||
pending.reportedErrors = [];
|
||||
this.#pendingCleanup = undefined;
|
||||
} else {
|
||||
pending.reportedErrors = [
|
||||
...pending.reportedErrors.filter(
|
||||
(reportedError) => reportedError.deref() !== undefined,
|
||||
),
|
||||
new WeakRef(result.error),
|
||||
];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -323,13 +452,26 @@ export class JadxProvider implements AndroidAnalysisPort {
|
||||
request: AndroidRequest,
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Outcome> {
|
||||
const timeout = AbortSignal.timeout(OPERATION_TIMEOUT_MS);
|
||||
// The deadline is fixed per provider environment; an unusable value is
|
||||
// refused before any file handle, workspace, or engine is acquired.
|
||||
const timeoutMs = resolveJadxOperationTimeout(
|
||||
this.environment,
|
||||
request.operation,
|
||||
);
|
||||
if (!timeoutMs.ok) return timeoutMs;
|
||||
const timeout = AbortSignal.timeout(timeoutMs.value);
|
||||
const signal =
|
||||
options?.signal === undefined
|
||||
? timeout
|
||||
: AbortSignal.any([options.signal, timeout]);
|
||||
let root: JadxWorkspaceRoot | undefined;
|
||||
let session: JadxSession | undefined;
|
||||
const fileHandles: PendingCleanup["fileHandles"] = new Map();
|
||||
const retainFailedClose =
|
||||
(path: string) =>
|
||||
(owner: OwnedFileHandle): void => {
|
||||
fileHandles.set(owner, path);
|
||||
};
|
||||
try {
|
||||
if (target.format !== "apk")
|
||||
throw new AnalysisCapabilityUnavailableError(
|
||||
@@ -347,15 +489,22 @@ export class JadxProvider implements AndroidAnalysisPort {
|
||||
configuration.jar,
|
||||
request.operation,
|
||||
signal,
|
||||
retainFailedClose(configuration.jar),
|
||||
);
|
||||
const bridgeHash = await hashAndroidSource(
|
||||
BRIDGE_SOURCE,
|
||||
request.operation,
|
||||
signal,
|
||||
retainFailedClose(BRIDGE_SOURCE),
|
||||
);
|
||||
let targetHash: string;
|
||||
try {
|
||||
targetHash = (await hashStableFile(target.path, signal)).sha256;
|
||||
targetHash = (
|
||||
await hashStableFile(target.path, {
|
||||
signal,
|
||||
retainFailedClose: retainFailedClose(target.path),
|
||||
})
|
||||
).sha256;
|
||||
} catch (cause: unknown) {
|
||||
if (signal.aborted) throw cause;
|
||||
if (cause instanceof RegularFileChangedError)
|
||||
@@ -418,6 +567,7 @@ export class JadxProvider implements AndroidAnalysisPort {
|
||||
bridge,
|
||||
request.operation,
|
||||
workspaceSignal,
|
||||
retainFailedClose(bridge),
|
||||
);
|
||||
if (copiedBridgeHash !== bridgeHash)
|
||||
throw androidSourceChanged(
|
||||
@@ -465,6 +615,7 @@ export class JadxProvider implements AndroidAnalysisPort {
|
||||
jarHash: retained.jarHash,
|
||||
bridgeHash: retained.bridgeHash,
|
||||
signal: activeSignal,
|
||||
timeoutMs: timeoutMs.value,
|
||||
}),
|
||||
);
|
||||
activeSignal.throwIfAborted();
|
||||
@@ -475,6 +626,7 @@ export class JadxProvider implements AndroidAnalysisPort {
|
||||
cause,
|
||||
request,
|
||||
timeout,
|
||||
timeoutMs: timeoutMs.value,
|
||||
signal: options?.signal,
|
||||
session,
|
||||
});
|
||||
@@ -485,7 +637,14 @@ export class JadxProvider implements AndroidAnalysisPort {
|
||||
root ??= this.#retained?.root;
|
||||
session ??= this.#retained?.session;
|
||||
this.#retained = undefined;
|
||||
this.#pendingCleanup = { session, root, previousError };
|
||||
this.#pendingCleanup = {
|
||||
session,
|
||||
root,
|
||||
fileHandles,
|
||||
previousError,
|
||||
operationCause: cause,
|
||||
reportedErrors: [],
|
||||
};
|
||||
const retired = await this.#retire();
|
||||
return retired.ok ? err(previousError) : retired;
|
||||
}
|
||||
|
||||
@@ -23,6 +23,12 @@ export const JADX_JAVA_RUNTIME_REMEDIATION =
|
||||
export const JADX_JVM_OPTIONS_REMEDIATION =
|
||||
"Set REA_JADX_HEAP_MIB to a positive integer (heap size in MiB) and REA_JADX_ACTIVE_PROCESSOR_COUNT to a positive integer within the JVM's signed 32-bit range.";
|
||||
|
||||
/** Whole-operation deadline used when REA_JADX_OPERATION_TIMEOUT_MS is unset. */
|
||||
export const DEFAULT_JADX_OPERATION_TIMEOUT_MS = 120_000;
|
||||
|
||||
/** Remediation when the JADX operation deadline override is malformed. */
|
||||
export const JADX_OPERATION_TIMEOUT_REMEDIATION = `Set REA_JADX_OPERATION_TIMEOUT_MS to a decimal millisecond count from 1 to 2147483647, or unset it to keep the ${String(DEFAULT_JADX_OPERATION_TIMEOUT_MS)} ms default.`;
|
||||
|
||||
/** Remediation when the host cannot run the Android JADX subprocess. */
|
||||
export const JADX_UNSUPPORTED_HOST_REMEDIATION =
|
||||
"Use Linux, macOS, or Windows x64 with its bundled native controls for Android JADX analysis.";
|
||||
|
||||
@@ -69,8 +69,10 @@ export class JadxSession {
|
||||
readonly jarHash: string;
|
||||
readonly bridgeHash: string;
|
||||
readonly signal: AbortSignal;
|
||||
/** Selected whole-operation deadline; also bounds each upstream call. */
|
||||
readonly timeoutMs: number;
|
||||
}): Promise<AnalysisExecution> {
|
||||
const { request, target, snapshot, jarHash, signal } = context;
|
||||
const { request, target, snapshot, jarHash, signal, timeoutMs } = context;
|
||||
this.#partialContext = {
|
||||
operation: request.operation,
|
||||
selectedPath: request.input.path,
|
||||
@@ -105,7 +107,7 @@ export class JadxSession {
|
||||
request.operation,
|
||||
`Expected ${JADX_BRIDGE_IDENTITY.name} ${JADX_BRIDGE_IDENTITY.version}; server reported ${server?.name ?? "unknown"} ${server?.version ?? "unknown"}.`,
|
||||
);
|
||||
const tools = this.#tools(request, target, signal);
|
||||
const tools = this.#tools(request, target, signal, timeoutMs);
|
||||
const runtime = jadxRuntimeSchema.parse(
|
||||
await tools.json("rea_jvm_status", {}),
|
||||
);
|
||||
@@ -206,15 +208,17 @@ export class JadxSession {
|
||||
request: AndroidRequest,
|
||||
target: BinaryTarget,
|
||||
signal: AbortSignal,
|
||||
timeoutMs: number,
|
||||
): JadxToolPort {
|
||||
const call = async (
|
||||
name: string,
|
||||
input: Readonly<Record<string, JsonValue>>,
|
||||
): Promise<string> => {
|
||||
signal.throwIfAborted();
|
||||
// Keep an explicit bound: omitting it falls back to the SDK's 60-second default.
|
||||
const response = await this.#client.callTool(
|
||||
{ name, arguments: input },
|
||||
{ timeout: 120_000, signal },
|
||||
{ timeout: timeoutMs, signal },
|
||||
);
|
||||
const observedResponse = jsonValueSchema.parse(response);
|
||||
const call = { operation: name, input, response: observedResponse };
|
||||
|
||||
@@ -58,7 +58,28 @@ describe("parseApktoolVersionOutput", () => {
|
||||
});
|
||||
|
||||
it("reports null when the launcher prints nothing usable", () => {
|
||||
expect(parseApktoolVersionOutput(" \n")).toEqual({ version: null });
|
||||
expect(parseApktoolVersionOutput(" \n")).toEqual({
|
||||
version: null,
|
||||
reportedVersion: null,
|
||||
});
|
||||
expect(parseApktoolVersionOutput("successful but not a version\n")).toEqual(
|
||||
{
|
||||
version: null,
|
||||
reportedVersion: "successful but not a version",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("parses semantic prerelease versions without claiming they meet the minimum", () => {
|
||||
expect(parseApktoolVersionOutput("2.7.0-beta.1\n")).toEqual({
|
||||
version: "2.7.0-beta.1",
|
||||
});
|
||||
});
|
||||
|
||||
it("validates normalized semantic versions while preserving caller spelling", () => {
|
||||
expect(parseApktoolVersionOutput("v2.7.0\n")).toEqual({
|
||||
version: "v2.7.0",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -8,13 +8,16 @@ import { snapshotEnvironment } from "../process/snapshotEnvironment.js";
|
||||
import { ABORTED, waitForAbortable } from "../process/AbortablePromise.js";
|
||||
import { ProviderStartupDeadline } from "../process/ProviderDeadline.js";
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
execFileOutput,
|
||||
execFileOutputFailure,
|
||||
} from "../process/ExecFileOutput.js";
|
||||
OwnedCommandFailure,
|
||||
runOwnedCommand,
|
||||
} from "../process/OwnedCommand.js";
|
||||
import type { ProviderProcessSupervisor } from "../process/ProviderProcess.js";
|
||||
import type { ProviderAvailability } from "../application/AnalysisProvider.js";
|
||||
import type { ProviderRejectionCode } from "../contracts/providerSelection.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import { gte, valid } from "semver";
|
||||
import {
|
||||
isWindowsBatchFile,
|
||||
windowsBatchCommand,
|
||||
@@ -148,22 +151,32 @@ export type ApktoolConfigurationFailureCode =
|
||||
| "command_missing"
|
||||
| "command_unresolved"
|
||||
| "launcher_unsupported"
|
||||
| "version_unresolved";
|
||||
| "version_unresolved"
|
||||
| "unsupported_version";
|
||||
|
||||
/** Unresolved cleanup of a launched probe; its owner was retained separately. */
|
||||
export interface ApktoolProbeCleanup {
|
||||
readonly reason: string;
|
||||
readonly resources: readonly string[];
|
||||
}
|
||||
|
||||
export class ApktoolConfigurationFailure extends Error {
|
||||
readonly cleanup: ApktoolProbeCleanup | undefined;
|
||||
|
||||
constructor(
|
||||
readonly code: ApktoolConfigurationFailureCode,
|
||||
message: string,
|
||||
readonly diagnostics: Readonly<Record<string, JsonValue>> = {},
|
||||
options?: ErrorOptions,
|
||||
options?: ErrorOptions & { readonly cleanup?: ApktoolProbeCleanup },
|
||||
) {
|
||||
super(message, options);
|
||||
this.cleanup = options?.cleanup;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ApktoolClientIdentity {
|
||||
readonly version: string | null;
|
||||
}
|
||||
export type ApktoolClientIdentity =
|
||||
| { readonly version: string; readonly reportedVersion?: never }
|
||||
| { readonly version: null; readonly reportedVersion: string | null };
|
||||
|
||||
/** Process creation for one apktool run on the selected host. */
|
||||
export interface ApktoolInvocation {
|
||||
@@ -199,7 +212,7 @@ export const apktoolInvocation = (
|
||||
windowsVerbatimArguments: false,
|
||||
};
|
||||
|
||||
/** Read the version from `apktool --version` output (first non-empty line). */
|
||||
/** Read the version from Apktool's single-line `--version` response. */
|
||||
export const parseApktoolVersionOutput = (
|
||||
stdout: string,
|
||||
): ApktoolClientIdentity => {
|
||||
@@ -207,7 +220,17 @@ export const parseApktoolVersionOutput = (
|
||||
.split(/\r?\n/u)
|
||||
.map((entry) => entry.trim())
|
||||
.find((entry) => entry !== "");
|
||||
return { version: line === undefined ? null : line };
|
||||
if (line === undefined || valid(line) === null)
|
||||
return { version: null, reportedVersion: line ?? null };
|
||||
return { version: line };
|
||||
};
|
||||
|
||||
const supportsDocumentedApktoolVersion = (version: string): boolean => {
|
||||
// Debian's verified 2.7.0-dirty build is a downstream build marker, not a
|
||||
// prerelease. Treat only that known marker as build metadata for the floor.
|
||||
const comparisonVersion = version.replace(/-dirty$/u, "+dirty");
|
||||
const parsedVersion = valid(comparisonVersion);
|
||||
return parsedVersion !== null && gte(parsedVersion, "2.7.0");
|
||||
};
|
||||
|
||||
/** Probe the selected apktool launcher without decoding anything. */
|
||||
@@ -216,6 +239,7 @@ export const inspectApktoolClient = async (
|
||||
signal?: AbortSignal,
|
||||
environment: Readonly<Record<string, string | undefined>> = process.env,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
onCleanupOwner?: (owner: ProviderProcessSupervisor) => void,
|
||||
): Promise<ApktoolClientIdentity> => {
|
||||
const hostEnvironment = snapshotEnvironment(environment, platform);
|
||||
let invocation: ApktoolInvocation;
|
||||
@@ -239,25 +263,60 @@ export const inspectApktoolClient = async (
|
||||
{ cause },
|
||||
);
|
||||
}
|
||||
let stdout: string;
|
||||
let stderr: string;
|
||||
try {
|
||||
const { stdout } = await execFileOutput(
|
||||
invocation.command,
|
||||
invocation.arguments,
|
||||
// The owned process group stops a shell launcher's children with it; a
|
||||
// child holding inherited output pipes cannot outlive the deadline.
|
||||
const output = await runOwnedCommand(
|
||||
{
|
||||
command: invocation.command,
|
||||
arguments: invocation.arguments,
|
||||
windowsVerbatimArguments: invocation.windowsVerbatimArguments,
|
||||
timeout: VERSION_TIMEOUT_MS,
|
||||
stopSignal: "SIGTERM",
|
||||
maxBuffer: VERSION_MAX_OUTPUT_BYTES,
|
||||
env: hostEnvironment,
|
||||
...(signal === undefined ? {} : { signal }),
|
||||
// Apktool launchers are commonly interpreter scripts, so ownership is
|
||||
// proved by the run token rather than the launcher's argv prefix.
|
||||
expectedCommand: null,
|
||||
runId: randomUUID(),
|
||||
hostEnvironment,
|
||||
},
|
||||
{
|
||||
timeoutMs: VERSION_TIMEOUT_MS,
|
||||
diagnosticBytes: VERSION_MAX_OUTPUT_BYTES,
|
||||
},
|
||||
signal === undefined ? {} : { signal },
|
||||
);
|
||||
return parseApktoolVersionOutput(stdout);
|
||||
stdout = output.stdout.text;
|
||||
stderr = output.stderr.text;
|
||||
} catch (cause) {
|
||||
const failure = execFileOutputFailure(cause);
|
||||
if (signal?.aborted || failure?.code === "ABORT_ERR")
|
||||
throw new AnalysisCancelledError("inspect_apktool_client", { cause });
|
||||
if (failure?.code === "ENOENT")
|
||||
if (
|
||||
cause instanceof OwnedCommandFailure &&
|
||||
cause.cleanupOwner !== undefined
|
||||
)
|
||||
onCleanupOwner?.(cause.cleanupOwner);
|
||||
const cleanup =
|
||||
cause instanceof OwnedCommandFailure && cause.cleanupFailure !== null
|
||||
? { reason: cause.cleanupFailure, resources: cause.resources }
|
||||
: undefined;
|
||||
const cleanupDetails = cleanup === undefined ? {} : { cleanup };
|
||||
if (
|
||||
signal?.aborted === true ||
|
||||
(cause instanceof OwnedCommandFailure && cause.reason === "cancelled")
|
||||
)
|
||||
throw new AnalysisCancelledError("inspect_apktool_client", {
|
||||
cause,
|
||||
...cleanupDetails,
|
||||
});
|
||||
if (cause instanceof OwnedCommandFailure && cause.reason === "timeout")
|
||||
throw new AnalysisTimeoutError(
|
||||
"inspect_apktool_client",
|
||||
VERSION_TIMEOUT_MS,
|
||||
{ cause, ...cleanupDetails },
|
||||
);
|
||||
if (
|
||||
cause instanceof Error &&
|
||||
"code" in cause &&
|
||||
(cause.code === "ENOENT" || cause.code === "EACCES")
|
||||
)
|
||||
throw new ApktoolConfigurationFailure(
|
||||
"command_missing",
|
||||
`The selected apktool launcher was not found: ${selection.command}`,
|
||||
@@ -268,21 +327,54 @@ export const inspectApktoolClient = async (
|
||||
},
|
||||
{ cause },
|
||||
);
|
||||
const snapshot =
|
||||
cause instanceof OwnedCommandFailure ? cause.snapshot : null;
|
||||
throw new ApktoolConfigurationFailure(
|
||||
"version_unresolved",
|
||||
"The selected apktool launcher did not report a usable version",
|
||||
{
|
||||
selected_command: selection.command,
|
||||
...(failure === undefined
|
||||
...(snapshot === null
|
||||
? {}
|
||||
: {
|
||||
exit_code: failure.code,
|
||||
stderr_excerpt: failure.stderr.slice(0, 512),
|
||||
exit_code: snapshot.exitCode ?? null,
|
||||
stderr_excerpt: snapshot.stderr.text.slice(0, 512),
|
||||
}),
|
||||
...(cause instanceof OwnedCommandFailure &&
|
||||
cause.reason === "output-limit"
|
||||
? { max_output_bytes: VERSION_MAX_OUTPUT_BYTES }
|
||||
: {}),
|
||||
},
|
||||
{ cause },
|
||||
{ cause, ...cleanupDetails },
|
||||
);
|
||||
}
|
||||
const identity = parseApktoolVersionOutput(stdout);
|
||||
if (identity.version === null)
|
||||
throw new ApktoolConfigurationFailure(
|
||||
"version_unresolved",
|
||||
"The selected apktool launcher exited successfully but did not report a recognizable version",
|
||||
{
|
||||
selected_command: selection.command,
|
||||
command_source: selection.commandSource,
|
||||
version_probe: "unrecognized_output",
|
||||
reported_version: identity.reportedVersion,
|
||||
stdout,
|
||||
stderr,
|
||||
},
|
||||
);
|
||||
if (!supportsDocumentedApktoolVersion(identity.version))
|
||||
throw new ApktoolConfigurationFailure(
|
||||
"unsupported_version",
|
||||
`The selected Apktool version ${identity.version} is older than the supported minimum 2.7.0`,
|
||||
{
|
||||
selected_command: selection.command,
|
||||
command_source: selection.commandSource,
|
||||
reported_version: identity.version,
|
||||
minimum_version: "2.7.0",
|
||||
remediation: `Use Apktool 2.7.0 or newer; see docs/apktool-resource-analysis.md.`,
|
||||
},
|
||||
);
|
||||
return identity;
|
||||
};
|
||||
|
||||
/** Availability in the shape shared by every provider. */
|
||||
@@ -295,7 +387,9 @@ const rejectionCodeOf = (
|
||||
code === "launcher_unsupported" ||
|
||||
code === "command_unresolved"
|
||||
? "not_configured"
|
||||
: "version_unresolved";
|
||||
: code === "unsupported_version"
|
||||
? "unsupported_version"
|
||||
: "version_unresolved";
|
||||
|
||||
export const apktoolConfigurationAvailability = (
|
||||
cause: ApktoolConfigurationFailure,
|
||||
|
||||
@@ -17,7 +17,7 @@ import type { ApktoolRequest } from "../domain/apktool/apktoolResourceAnalysis.j
|
||||
|
||||
const it = test.skipIf(process.platform === "win32");
|
||||
|
||||
const VERSION_BLOCK = "printf '2.7.0-stub\\n'";
|
||||
const VERSION_BLOCK = "printf '2.7.0-dirty\\n'";
|
||||
|
||||
const REAL_YML = [
|
||||
"!!brut.androlib.meta.MetaInfo",
|
||||
@@ -172,7 +172,7 @@ it("inspects the apktool launcher", async () => {
|
||||
expect(result.client).toEqual({
|
||||
command,
|
||||
command_source: "environment",
|
||||
apktool_version: "2.7.0-stub",
|
||||
apktool_version: "2.7.0-dirty",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -355,6 +355,39 @@ it("reports a missing launcher as a capability gap with recovery", async () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed for unrecognized and unsupported Apktool versions", async () => {
|
||||
const invalidOutput = `unexpected Apktool output ${"x".repeat(700)}\n`;
|
||||
const unrecognized = await writeApktoolStub(
|
||||
() => `printf '%s' '${invalidOutput}'; exit 0`,
|
||||
);
|
||||
await expect(
|
||||
providerOver(unrecognized.command).inspectAvailability(),
|
||||
).resolves.toMatchObject({
|
||||
status: "unavailable",
|
||||
code: "version_unresolved",
|
||||
diagnostics: {
|
||||
reported_version: invalidOutput.trim(),
|
||||
stdout: invalidOutput,
|
||||
},
|
||||
});
|
||||
|
||||
const old = await writeApktoolStub(() => `printf '2.6.3\\n'`);
|
||||
await expect(
|
||||
providerOver(old.command).inspectAvailability(),
|
||||
).resolves.toMatchObject({
|
||||
status: "unavailable",
|
||||
code: "unsupported_version",
|
||||
});
|
||||
|
||||
const prerelease = await writeApktoolStub(() => `printf '2.7.0-beta.1\\n'`);
|
||||
await expect(
|
||||
providerOver(prerelease.command).inspectAvailability(),
|
||||
).resolves.toMatchObject({
|
||||
status: "unavailable",
|
||||
code: "unsupported_version",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a target that is not a readable file", async () => {
|
||||
const { command, root } = await writeApktoolStub(decodeStub());
|
||||
const projected = await refusedBy(providerOver(command), {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { regularFileCancellation } from "../filesystem/OwnedRegularFile.js";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { createWriteStream } from "node:fs";
|
||||
import {
|
||||
@@ -56,6 +57,7 @@ import { WindowsBatchCommandError } from "../process/WindowsBatchCommand.js";
|
||||
import {
|
||||
APKTOOL_COMMAND_ENV,
|
||||
ApktoolConfigurationFailure,
|
||||
type ApktoolProbeCleanup,
|
||||
apktoolConfigurationAvailability,
|
||||
apktoolInvocation,
|
||||
inspectApktoolClient,
|
||||
@@ -148,6 +150,19 @@ interface DecodeLimits {
|
||||
readonly maxOutputBytes: number;
|
||||
}
|
||||
|
||||
/** A probe whose launched processes could not be confirmed stopped. */
|
||||
const probeCleanupFailure = (
|
||||
operation: string,
|
||||
failure: ApktoolConfigurationFailure,
|
||||
cleanup: ApktoolProbeCleanup,
|
||||
): ProviderCleanupError =>
|
||||
new ProviderCleanupError(
|
||||
APKTOOL_PROVIDER_IDENTITY.id,
|
||||
cleanup.resources,
|
||||
{ reason: cleanup.reason, probe_failure: failure.message },
|
||||
{ operation, cause: failure },
|
||||
);
|
||||
|
||||
const runApktool = async (
|
||||
operation: string,
|
||||
selection: ApktoolCommandSelection,
|
||||
@@ -180,6 +195,9 @@ const runApktool = async (
|
||||
command: invocation.command,
|
||||
arguments: invocation.arguments,
|
||||
windowsVerbatimArguments: invocation.windowsVerbatimArguments,
|
||||
// Apktool launchers are commonly interpreter scripts, so ownership is
|
||||
// proved by the run token rather than the launcher's argv prefix.
|
||||
expectedCommand: null,
|
||||
runId: randomUUID(),
|
||||
hostEnvironment: environment,
|
||||
},
|
||||
@@ -328,6 +346,10 @@ export class ApktoolProvider {
|
||||
private readonly workspaces = new Set<string>();
|
||||
private readonly removeWorkspace: (path: string) => Promise<void>;
|
||||
readonly #pendingCommandOwners = new Map<ProviderProcessSupervisor, string>();
|
||||
/** A version probe whose cleanup is unresolved is retried on close. */
|
||||
readonly #retainProbeOwner = (owner: ProviderProcessSupervisor): void => {
|
||||
this.#pendingCommandOwners.set(owner, "apktool version probe");
|
||||
};
|
||||
readonly #shutdown = new AbortController();
|
||||
readonly #active = new Set<Promise<void>>();
|
||||
#closing = false;
|
||||
@@ -378,7 +400,13 @@ export class ApktoolProvider {
|
||||
process.platform,
|
||||
signal,
|
||||
);
|
||||
await inspectApktoolClient(selection, signal, environment);
|
||||
await inspectApktoolClient(
|
||||
selection,
|
||||
signal,
|
||||
environment,
|
||||
process.platform,
|
||||
this.#retainProbeOwner,
|
||||
);
|
||||
throwIfCancelled("inspect_apktool_client", signal);
|
||||
return {
|
||||
status: "available",
|
||||
@@ -390,8 +418,15 @@ export class ApktoolProvider {
|
||||
},
|
||||
};
|
||||
} catch (cause) {
|
||||
if (cause instanceof ApktoolConfigurationFailure)
|
||||
if (cause instanceof ApktoolConfigurationFailure) {
|
||||
if (cause.cleanup !== undefined)
|
||||
throw probeCleanupFailure(
|
||||
"inspect_apktool_client",
|
||||
cause,
|
||||
cause.cleanup,
|
||||
);
|
||||
return apktoolConfigurationAvailability(cause);
|
||||
}
|
||||
throw cause;
|
||||
}
|
||||
}
|
||||
@@ -437,7 +472,13 @@ export class ApktoolProvider {
|
||||
process.platform,
|
||||
signal,
|
||||
);
|
||||
identity = await inspectApktoolClient(selection, signal, environment);
|
||||
identity = await inspectApktoolClient(
|
||||
selection,
|
||||
signal,
|
||||
environment,
|
||||
process.platform,
|
||||
this.#retainProbeOwner,
|
||||
);
|
||||
} catch (cause) {
|
||||
if (
|
||||
cause instanceof AnalysisCancelledError ||
|
||||
@@ -445,6 +486,10 @@ export class ApktoolProvider {
|
||||
)
|
||||
return err(cause);
|
||||
if (cause instanceof ApktoolConfigurationFailure) {
|
||||
if (cause.cleanup !== undefined)
|
||||
return err(
|
||||
probeCleanupFailure(request.operation, cause, cause.cleanup),
|
||||
);
|
||||
return err(
|
||||
providerConfigurationRefusal(
|
||||
APKTOOL_PROVIDER_IDENTITY.id,
|
||||
@@ -497,12 +542,14 @@ export class ApktoolProvider {
|
||||
async #drainAndClean(): Promise<void> {
|
||||
await Promise.all(this.#active);
|
||||
const failures: unknown[] = [];
|
||||
const retained: string[] = [];
|
||||
for (const [owner, root] of this.#pendingCommandOwners) {
|
||||
try {
|
||||
const stopped = await owner.stop();
|
||||
if (stopped.status === "incomplete") throw new Error(stopped.reason);
|
||||
this.#pendingCommandOwners.delete(owner);
|
||||
} catch (cause) {
|
||||
retained.push(root);
|
||||
failures.push(
|
||||
new Error(
|
||||
`apktool process cleanup remains incomplete for ${root}: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
@@ -511,8 +558,23 @@ export class ApktoolProvider {
|
||||
);
|
||||
}
|
||||
}
|
||||
// Ownership stays retained for a later close; report it as typed cleanup.
|
||||
if (failures.length > 0)
|
||||
throw new AggregateError(failures, "Apktool process cleanup failed");
|
||||
throw new ProviderCleanupError(
|
||||
APKTOOL_PROVIDER_IDENTITY.id,
|
||||
retained,
|
||||
{
|
||||
reason: failures
|
||||
.map((failure) =>
|
||||
failure instanceof Error ? failure.message : String(failure),
|
||||
)
|
||||
.join("; "),
|
||||
},
|
||||
{
|
||||
operation: "close",
|
||||
cause: new AggregateError(failures, "Apktool process cleanup failed"),
|
||||
},
|
||||
);
|
||||
for (const path of this.workspaces) {
|
||||
try {
|
||||
await this.removeWorkspace(path);
|
||||
@@ -785,6 +847,15 @@ export class ApktoolProvider {
|
||||
);
|
||||
} catch (cause) {
|
||||
await removeWorkspace();
|
||||
const cancellation = regularFileCancellation(cause);
|
||||
if (cancellation !== undefined)
|
||||
return err(
|
||||
failureWithCleanup(
|
||||
new AnalysisCancelledError(operation, {
|
||||
cause: cancellation.reason,
|
||||
}),
|
||||
),
|
||||
);
|
||||
return err(
|
||||
failureWithCleanup(
|
||||
signal?.aborted === true
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { chmod, readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import { promisify } from "node:util";
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
|
||||
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
|
||||
import { inspectApktoolClient } from "./ApktoolConfiguration.js";
|
||||
|
||||
/**
|
||||
* Only an exited process counts as stopped: it is gone, a zombie waiting to
|
||||
* be reaped, or absent from `ps`. A failed inspection is not proof of exit.
|
||||
*/
|
||||
const alive = async (pid: number): Promise<boolean> => {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
} catch (cause: unknown) {
|
||||
if ((cause as NodeJS.ErrnoException).code === "ESRCH") return false;
|
||||
return true;
|
||||
}
|
||||
return promisify(execFile)("ps", ["-o", "stat=", "-p", `${pid}`]).then(
|
||||
({ stdout }) => {
|
||||
const state = stdout.trim();
|
||||
return state !== "" && !state.startsWith("Z");
|
||||
},
|
||||
// `ps` exits 1 when no process matches; any other failure is unknown.
|
||||
(cause: unknown) => (cause as { code?: unknown }).code !== 1,
|
||||
);
|
||||
};
|
||||
|
||||
const waitFor = async (check: () => Promise<boolean>, ms: number) => {
|
||||
const deadline = Date.now() + ms;
|
||||
while (Date.now() < deadline) {
|
||||
if (await check()) return true;
|
||||
await delay(25);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const readPids = async (path: string): Promise<readonly number[]> =>
|
||||
readFile(path, "utf8")
|
||||
.then((text) => JSON.parse(text) as number[])
|
||||
.catch(() => []);
|
||||
|
||||
// The interpreter is the test runtime itself, so its environment (and the
|
||||
// ownership run token) is readable on both Linux and macOS.
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"stops an interpreter launcher and its pipe-holding child when the probe is cancelled",
|
||||
async () => {
|
||||
const root = await createTestTempDirectory("rea-apktool-probe-");
|
||||
const pids = join(root, "pids.json");
|
||||
const launcher = join(root, "apktool");
|
||||
await writeFile(
|
||||
launcher,
|
||||
[
|
||||
`#!${process.execPath}`,
|
||||
'const { spawn } = require("node:child_process");',
|
||||
'const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "inherit" });',
|
||||
`require("node:fs").writeFileSync(${JSON.stringify(pids)}, JSON.stringify([process.pid, child.pid]));`,
|
||||
"setInterval(() => {}, 1000);",
|
||||
].join("\n"),
|
||||
);
|
||||
await chmod(launcher, 0o755);
|
||||
const controller = new AbortController();
|
||||
const started = performance.now();
|
||||
const probe = inspectApktoolClient(
|
||||
{ command: launcher, commandSource: "environment" },
|
||||
controller.signal,
|
||||
process.env,
|
||||
).catch((cause: unknown) => cause);
|
||||
try {
|
||||
expect(
|
||||
await waitFor(async () => (await readPids(pids)).length === 2, 10_000),
|
||||
).toBe(true);
|
||||
const launched = await readPids(pids);
|
||||
controller.abort();
|
||||
// A regressed probe never settles; bound the wait so cleanup still runs.
|
||||
const outcome = await Promise.race([
|
||||
probe,
|
||||
delay(15_000).then(() => "still pending"),
|
||||
]);
|
||||
expect(outcome).toBeInstanceOf(AnalysisCancelledError);
|
||||
expect(performance.now() - started).toBeLessThan(15_000);
|
||||
expect(
|
||||
await waitFor(
|
||||
async () =>
|
||||
(await Promise.all(launched.map(alive))).every((live) => !live),
|
||||
5_000,
|
||||
),
|
||||
).toBe(true);
|
||||
} finally {
|
||||
// A failed assertion must not leave the fixture's processes running.
|
||||
controller.abort();
|
||||
await Promise.race([probe, delay(10_000)]);
|
||||
for (const pid of await readPids(pids))
|
||||
if (await alive(pid))
|
||||
try {
|
||||
process.kill(pid, "SIGKILL");
|
||||
} catch {
|
||||
// Already exited between the check and the signal.
|
||||
}
|
||||
}
|
||||
},
|
||||
30_000,
|
||||
);
|
||||
@@ -0,0 +1,147 @@
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisTimeoutError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import {
|
||||
OwnedCommandFailure,
|
||||
runOwnedCommand,
|
||||
} from "../process/OwnedCommand.js";
|
||||
import type { ProviderProcessSupervisor } from "../process/ProviderProcess.js";
|
||||
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||
import {
|
||||
ApktoolConfigurationFailure,
|
||||
inspectApktoolClient,
|
||||
} from "./ApktoolConfiguration.js";
|
||||
import { ApktoolProvider } from "./ApktoolProvider.js";
|
||||
|
||||
vi.mock("../process/OwnedCommand.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../process/OwnedCommand.js")>()),
|
||||
runOwnedCommand: vi.fn(),
|
||||
}));
|
||||
afterEach(() => vi.mocked(runOwnedCommand).mockReset());
|
||||
|
||||
const selection = {
|
||||
command: "/opt/apktool/apktool",
|
||||
commandSource: "environment",
|
||||
} as const;
|
||||
const owner = {} as ProviderProcessSupervisor;
|
||||
|
||||
it("runs the version probe as an owned interpreter-script process group", async () => {
|
||||
vi.mocked(runOwnedCommand).mockResolvedValueOnce({
|
||||
stdout: { text: "2.9.3\n" },
|
||||
stderr: { text: "" },
|
||||
} as Awaited<ReturnType<typeof runOwnedCommand>>);
|
||||
await expect(
|
||||
inspectApktoolClient(selection, undefined, {}, "linux"),
|
||||
).resolves.toEqual({ version: "2.9.3" });
|
||||
expect(vi.mocked(runOwnedCommand).mock.lastCall?.[0]).toMatchObject({
|
||||
command: "/opt/apktool/apktool",
|
||||
arguments: ["--version"],
|
||||
expectedCommand: null,
|
||||
});
|
||||
expect(vi.mocked(runOwnedCommand).mock.lastCall?.[1]).toEqual({
|
||||
timeoutMs: 20_000,
|
||||
diagnosticBytes: 64 * 1024,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports an expired version probe as a typed timeout", async () => {
|
||||
vi.mocked(runOwnedCommand).mockRejectedValueOnce(
|
||||
new OwnedCommandFailure("timeout", "Command deadline elapsed."),
|
||||
);
|
||||
const failure = await inspectApktoolClient(
|
||||
selection,
|
||||
undefined,
|
||||
{},
|
||||
"linux",
|
||||
).catch((cause: unknown) => cause);
|
||||
expect(failure).toBeInstanceOf(AnalysisTimeoutError);
|
||||
expect(projectAnalysisError(failure as AnalysisTimeoutError)).toMatchObject({
|
||||
code: "provider_timeout",
|
||||
category: "timeout",
|
||||
details: { timeout_ms: 20_000 },
|
||||
});
|
||||
});
|
||||
|
||||
it("retains an unresolved probe cleanup owner and reports the cleanup", async () => {
|
||||
vi.mocked(runOwnedCommand).mockRejectedValueOnce(
|
||||
new OwnedCommandFailure(
|
||||
"timeout",
|
||||
"Command deadline elapsed.",
|
||||
null,
|
||||
"cleanup incomplete",
|
||||
undefined,
|
||||
["pid:1"],
|
||||
owner,
|
||||
),
|
||||
);
|
||||
const retained: ProviderProcessSupervisor[] = [];
|
||||
const failure = await inspectApktoolClient(
|
||||
selection,
|
||||
undefined,
|
||||
{},
|
||||
"linux",
|
||||
(cleanupOwner) => retained.push(cleanupOwner),
|
||||
).catch((cause: unknown) => cause);
|
||||
expect(failure).toBeInstanceOf(AnalysisTimeoutError);
|
||||
expect(projectAnalysisError(failure as AnalysisTimeoutError)).toMatchObject({
|
||||
code: "cleanup_incomplete",
|
||||
});
|
||||
expect(retained).toEqual([owner]);
|
||||
});
|
||||
|
||||
it("reports a cancelled version probe as cancelled", async () => {
|
||||
vi.mocked(runOwnedCommand).mockRejectedValueOnce(
|
||||
new OwnedCommandFailure("cancelled", "Command cancelled."),
|
||||
);
|
||||
await expect(
|
||||
inspectApktoolClient(selection, undefined, {}, "linux"),
|
||||
).rejects.toBeInstanceOf(AnalysisCancelledError);
|
||||
});
|
||||
|
||||
it("keeps unresolved probe cleanup typed when the launcher overflows its output", async () => {
|
||||
const overflow = () =>
|
||||
new OwnedCommandFailure(
|
||||
"output-limit",
|
||||
"Command diagnostic output exceeded its complete-output budget.",
|
||||
null,
|
||||
"process group still running",
|
||||
undefined,
|
||||
["pid:7", "process-group:7"],
|
||||
owner,
|
||||
);
|
||||
vi.mocked(runOwnedCommand).mockRejectedValueOnce(overflow());
|
||||
const failure = await inspectApktoolClient(
|
||||
selection,
|
||||
undefined,
|
||||
{},
|
||||
"linux",
|
||||
).catch((cause: unknown) => cause);
|
||||
expect(failure).toBeInstanceOf(ApktoolConfigurationFailure);
|
||||
expect((failure as ApktoolConfigurationFailure).cleanup).toEqual({
|
||||
reason: "process group still running",
|
||||
resources: ["pid:7", "process-group:7"],
|
||||
});
|
||||
|
||||
// Both provider entry points report the unresolved processes as cleanup.
|
||||
const provider = new ApktoolProvider({
|
||||
environment: { REA_APKTOOL_COMMAND: "/opt/apktool/apktool" },
|
||||
});
|
||||
vi.mocked(runOwnedCommand).mockRejectedValueOnce(overflow());
|
||||
await expect(provider.inspectAvailability()).rejects.toBeInstanceOf(
|
||||
ProviderCleanupError,
|
||||
);
|
||||
vi.mocked(runOwnedCommand).mockRejectedValueOnce(overflow());
|
||||
const result = await provider.execute({
|
||||
operation: "decode_android_resources",
|
||||
input: {},
|
||||
} as never);
|
||||
expect(result.ok ? null : result.error).toBeInstanceOf(ProviderCleanupError);
|
||||
expect(result.ok ? null : projectAnalysisError(result.error)).toMatchObject({
|
||||
code: "cleanup_incomplete",
|
||||
details: { resources: ["pid:7", "process-group:7"] },
|
||||
});
|
||||
});
|
||||
@@ -173,6 +173,12 @@ export interface AnalysisClientContext {
|
||||
readonly runId: string;
|
||||
}
|
||||
|
||||
/** One lifecycle owner for descriptor cleanup and abortable inspection work. */
|
||||
export interface FileCloseResourceOwner {
|
||||
retain(owner: { close(): Promise<void> }, resource: string): void;
|
||||
run<T>(operation: () => Promise<T>): Promise<T>;
|
||||
}
|
||||
|
||||
export interface ProviderIdentity {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
@@ -187,6 +193,7 @@ export interface AnalysisProfileResolution {
|
||||
/** Cancellation context for side-effect-free provider profile resolution. */
|
||||
export interface AnalysisProfileResolutionOptions {
|
||||
readonly signal?: AbortSignal;
|
||||
readonly fileCloseResources?: FileCloseResourceOwner;
|
||||
}
|
||||
|
||||
interface CapabilityEffects {
|
||||
@@ -283,6 +290,12 @@ export interface AnalysisProviderReadinessSnapshot {
|
||||
|
||||
/** Deep provider candidate discoverable before one target-bound route is chosen. */
|
||||
export interface AnalysisProviderCandidate extends AnalysisProvider {
|
||||
/**
|
||||
* Own inspection through final reads and cleanup. Observe the supplied owner
|
||||
* signal cooperatively; resolve only after finalization settles, retaining any
|
||||
* failed cleanup capability. Registry cancellation and shutdown join this
|
||||
* completion rather than abandoning the promise.
|
||||
*/
|
||||
resolveAnalysisProfile(
|
||||
target: BinaryTarget,
|
||||
options?: AnalysisProfileResolutionOptions,
|
||||
@@ -290,4 +303,8 @@ export interface AnalysisProviderCandidate extends AnalysisProvider {
|
||||
inspectAvailability(): ProviderAvailability;
|
||||
inspectReadinessSnapshot?(): AnalysisProviderReadinessSnapshot;
|
||||
inspectTargetSupport(target: BinaryTarget): ProviderTargetSupport;
|
||||
/** Observe retained candidate cleanup without starting or retrying work. */
|
||||
hasPendingOwnedResources?(): boolean;
|
||||
/** Release candidate resources that are independent of any active client. */
|
||||
closeOwnedResources?(): Promise<Result<null, AnalysisError>>;
|
||||
}
|
||||
@@ -32,3 +32,46 @@ describe("app bundle Info.plist magic", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("app bundle program directory containment", () => {
|
||||
it("rejects an escaping programs directory before resolving its child", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "rea-app-programs-"));
|
||||
try {
|
||||
const bundle = join(root, "Fixture.app");
|
||||
const programs = join(bundle, "Contents", "MacOS");
|
||||
const executable = join(programs, "Missing");
|
||||
const canonicalBundle = join(root, "Canonical.app");
|
||||
const outsidePrograms = join(root, "outside");
|
||||
const plist =
|
||||
"<plist><dict><key>CFBundleExecutable</key><string>Missing</string></dict></plist>";
|
||||
await mkdir(join(bundle, "Contents"), { recursive: true });
|
||||
await writeFile(join(bundle, "Contents", "Info.plist"), plist);
|
||||
let childResolved = false;
|
||||
|
||||
const result = await resolveAppBundleExecutable(bundle, {
|
||||
readFile: async () => Buffer.from(plist),
|
||||
realpath: async (path) => {
|
||||
if (path === bundle) return canonicalBundle;
|
||||
if (path === programs) return outsidePrograms;
|
||||
if (path === executable) {
|
||||
childResolved = true;
|
||||
throw Object.assign(new Error("missing executable"), {
|
||||
code: "ENOENT",
|
||||
});
|
||||
}
|
||||
return path;
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
reason: "app program directory leaves Contents/MacOS",
|
||||
},
|
||||
});
|
||||
expect(childResolved).toBe(false);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -219,11 +219,24 @@ const resolveLayoutExecutable = async (
|
||||
);
|
||||
const programs = join(bundle, ...layout.programs);
|
||||
const executable = join(programs, name);
|
||||
let canonicalBundle: string;
|
||||
try {
|
||||
const [canonicalPrograms, canonicalExecutable] = await Promise.all([
|
||||
fileSystem.realpath(programs),
|
||||
fileSystem.realpath(executable),
|
||||
]);
|
||||
canonicalBundle = await fileSystem.realpath(bundle);
|
||||
} catch (cause: unknown) {
|
||||
return err(
|
||||
new BinaryTargetError(bundle, errorReason(cause, bundle), { cause }),
|
||||
);
|
||||
}
|
||||
try {
|
||||
const canonicalPrograms = await fileSystem.realpath(programs);
|
||||
if (!isPathWithinRoot(canonicalBundle, canonicalPrograms))
|
||||
return err(
|
||||
new BinaryTargetError(
|
||||
bundle,
|
||||
`app program directory leaves ${layout.programsLabel}`,
|
||||
),
|
||||
);
|
||||
const canonicalExecutable = await fileSystem.realpath(executable);
|
||||
if (!isPathWithinRoot(canonicalPrograms, canonicalExecutable))
|
||||
return err(
|
||||
new BinaryTargetError(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { regularFileCancellation } from "../filesystem/OwnedRegularFile.js";
|
||||
import { constants, type Stats } from "node:fs";
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
@@ -7,7 +8,7 @@ import {
|
||||
stat,
|
||||
type FileHandle,
|
||||
} from "node:fs/promises";
|
||||
import { extname, isAbsolute, resolve } from "node:path";
|
||||
import { extname } from "node:path";
|
||||
|
||||
import { BinaryTargetError } from "../domain/configurationErrors.js";
|
||||
import {
|
||||
@@ -52,6 +53,7 @@ import {
|
||||
type ExecutableMetadata,
|
||||
} from "../domain/binaryTarget.js";
|
||||
import type { PeCodeViewRecord } from "../domain/binaryTargetTypes.js";
|
||||
import { absoluteCallerPath } from "../filesystem/callerPath.js";
|
||||
|
||||
const XML_PLIST_PROBE_CHUNK_BYTES = 64 * 1024;
|
||||
|
||||
@@ -80,7 +82,7 @@ export const parseBinaryTarget = async (
|
||||
const cwd = options.cwd ?? process.cwd();
|
||||
const hostArchitecture = options.hostArchitecture ?? process.arch;
|
||||
const { targetKind, formatHint, signal, architecture, entryPoint } = options;
|
||||
const candidate = isAbsolute(input) ? input : resolve(cwd, input);
|
||||
const candidate = absoluteCallerPath(input, { cwd });
|
||||
try {
|
||||
throwIfTargetResolutionCancelled(signal);
|
||||
await access(candidate, constants.R_OK);
|
||||
@@ -223,6 +225,17 @@ export const parseBinaryTarget = async (
|
||||
}
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof AnalysisCancelledError) return err(cause);
|
||||
const cancellation = regularFileCancellation(cause);
|
||||
if (cancellation !== undefined)
|
||||
return err(
|
||||
new AnalysisCancelledError("resolve_binary_target", {
|
||||
cause: cancellation.reason,
|
||||
}),
|
||||
);
|
||||
if (signal?.aborted === true && cause === signal.reason)
|
||||
return err(
|
||||
new AnalysisCancelledError("resolve_binary_target", { cause }),
|
||||
);
|
||||
if (cause instanceof BinaryTargetError) return err(cause);
|
||||
if (!(cause instanceof Error)) throw cause;
|
||||
const failure = classifyBinaryTargetFilesystemFailure(candidate, cause);
|
||||
|
||||
@@ -35,6 +35,8 @@ import type { ExecutionOptions } from "./AnalysisProvider.js";
|
||||
|
||||
/** Provider-neutral application boundary for passive browser observation. */
|
||||
export interface BrowserObservationPort {
|
||||
/** Release provider-owned transports during command or server shutdown. */
|
||||
close?(): Promise<void>;
|
||||
identity(): ProviderIdentity;
|
||||
listTargets(
|
||||
input: ListBrowserTargetsInput,
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { expect, it } from "vitest";
|
||||
import type { BrowserObservationPort } from "./BrowserObservationPort.js";
|
||||
import { listBrowserTargets } from "./BrowserObservationService.js";
|
||||
import type { BrowserTargetList } from "../domain/browserObservationSchemas.js";
|
||||
import { parseEvidence } from "../domain/evidence.js";
|
||||
import { AnalysisOutputError } from "../domain/analysisErrorCore.js";
|
||||
import { BrowserObservationError } from "../domain/browserObservationError.js";
|
||||
import { ok } from "../domain/result.js";
|
||||
import { passiveNetworkCapture } from "../../tests/fixtures/passiveNetworkCapture.js";
|
||||
import { inspectWebPage } from "./BrowserObservationService.js";
|
||||
import { analyzeWebBundleInputSchema } from "../domain/webBundleAnalysis.js";
|
||||
import { inspectWebPageInputSchema } from "../domain/browserObservation.js";
|
||||
import { analyzeWebBundle } from "./BrowserObservationService.js";
|
||||
|
||||
const input = {
|
||||
cdp_endpoint: {
|
||||
transport: "http_discovery" as const,
|
||||
url: "http://127.0.0.1:9222",
|
||||
recorded: "http://127.0.0.1:9222",
|
||||
},
|
||||
allowed_origins: [],
|
||||
};
|
||||
|
||||
const report: BrowserTargetList = {
|
||||
browser: {
|
||||
product: "Chrome",
|
||||
protocol_version: "1.3",
|
||||
revision: "1",
|
||||
user_agent: "Chrome fixture",
|
||||
js_version: "V8 fixture",
|
||||
},
|
||||
targets: [],
|
||||
excluded: { disallowed_origin: 0, unsupported_url: 0, non_page: 0 },
|
||||
limitations: [],
|
||||
};
|
||||
|
||||
const provider = (
|
||||
listTargets: BrowserObservationPort["listTargets"],
|
||||
): BrowserObservationPort =>
|
||||
({
|
||||
identity: () => ({ id: "browser-fixture", name: "Fixture", version: "1" }),
|
||||
listTargets,
|
||||
}) as unknown as BrowserObservationPort;
|
||||
|
||||
it("retains completed browser inventory Evidence when the provider completes after abort", async () => {
|
||||
const controller = new AbortController();
|
||||
const result = await listBrowserTargets(
|
||||
provider(async () => {
|
||||
controller.abort();
|
||||
return ok(report);
|
||||
}),
|
||||
input,
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error._tag).toBe("AnalysisCancelledError");
|
||||
expect(parseEvidence(result.error.partialObservation)).toMatchObject({
|
||||
operation: "list_browser_targets",
|
||||
normalized_result: report,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("does not let late cancellation hide invalid completed browser output", async () => {
|
||||
const malformed = {
|
||||
...report,
|
||||
limitations: [() => undefined],
|
||||
} as unknown as BrowserTargetList;
|
||||
const output = await listBrowserTargets(
|
||||
provider(async () => ok(malformed)),
|
||||
input,
|
||||
);
|
||||
expect(output).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisOutputError" },
|
||||
});
|
||||
if (!output.ok) expect(output.error).toBeInstanceOf(AnalysisOutputError);
|
||||
|
||||
const controller = new AbortController();
|
||||
const late = await listBrowserTargets(
|
||||
provider(async () => {
|
||||
controller.abort();
|
||||
return ok(malformed);
|
||||
}),
|
||||
input,
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
expect(late).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisOutputError" },
|
||||
});
|
||||
if (!late.ok) expect(late.error).toBeInstanceOf(AnalysisOutputError);
|
||||
});
|
||||
|
||||
it("converts a raw page inspection retained after browser cleanup failure", async () => {
|
||||
const inspection = passiveNetworkCapture();
|
||||
const failure = new BrowserObservationError(
|
||||
"inspect_web_page",
|
||||
"cleanup_failed",
|
||||
{
|
||||
cleanup: {
|
||||
reason: "target detach failed",
|
||||
resources: ["browser target"],
|
||||
},
|
||||
partialObservation: inspection,
|
||||
},
|
||||
);
|
||||
const browser: BrowserObservationPort = {
|
||||
identity: () => ({ id: "browser-fixture", name: "Fixture", version: "1" }),
|
||||
listTargets: async () => ok(report),
|
||||
inspectPage: async () => ({ ok: false, error: failure }),
|
||||
} as unknown as BrowserObservationPort;
|
||||
const pageInput = inspectWebPageInputSchema.parse({
|
||||
cdp_endpoint: input.cdp_endpoint.recorded,
|
||||
allowed_origins: [],
|
||||
target_id: "page",
|
||||
observation_ms: 0,
|
||||
include_script_sources: false,
|
||||
});
|
||||
const result = await inspectWebPage(browser, pageInput);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toBe(failure);
|
||||
expect(result.error.cleanupIncomplete).toBe(true);
|
||||
expect(parseEvidence(result.error.partialObservation)).toMatchObject({
|
||||
operation: "inspect_web_page",
|
||||
normalized_result: inspection,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("retains captured page Evidence when bundle analysis fails after capture", async () => {
|
||||
const inspection = passiveNetworkCapture();
|
||||
const failure = new BrowserObservationError(
|
||||
"analyze_web_bundle",
|
||||
"cancelled",
|
||||
{ partialObservation: inspection },
|
||||
);
|
||||
const browser = {
|
||||
identity: () => ({ id: "browser-fixture", name: "Fixture", version: "1" }),
|
||||
analyzeBundle: async () => ({ ok: false as const, error: failure }),
|
||||
} as unknown as BrowserObservationPort;
|
||||
const bundleInput = analyzeWebBundleInputSchema.parse({
|
||||
...input,
|
||||
cdp_endpoint: input.cdp_endpoint.recorded,
|
||||
target_id: "page",
|
||||
observation_ms: 0,
|
||||
include_script_sources: true,
|
||||
});
|
||||
const result = await analyzeWebBundle(browser, bundleInput);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toBe(failure);
|
||||
expect(failure.operation).toBe("analyze_web_bundle");
|
||||
expect(parseEvidence(result.error.partialObservation)).toMatchObject({
|
||||
operation: "inspect_web_page",
|
||||
normalized_result: inspection,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -14,12 +14,16 @@ import type {
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisCapabilityUnavailableError,
|
||||
AnalysisOutputError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import type { ExecutionOptions } from "./AnalysisProvider.js";
|
||||
import type { BrowserObservationPort } from "./BrowserObservationPort.js";
|
||||
import { createBrowserEvidence } from "./BrowserEvidence.js";
|
||||
import { jsonValueSchema } from "../domain/jsonValue.js";
|
||||
import { completeProviderEvidence } from "./ProviderOperationExecution.js";
|
||||
import { webPageInspectionSchema } from "../domain/browserObservationSchemas.js";
|
||||
|
||||
/** List browser targets within the endpoint and origin scope in the request. */
|
||||
export const listBrowserTargets = async (
|
||||
@@ -29,17 +33,16 @@ export const listBrowserTargets = async (
|
||||
): Promise<Result<Evidence, AnalysisError>> => {
|
||||
const ready = requireBrowser(browser, "list_browser_targets");
|
||||
if (!ready.ok) return ready;
|
||||
const result = await ready.value.listTargets(input, options);
|
||||
if (result.ok && options.signal?.aborted === true)
|
||||
if (options.signal?.aborted)
|
||||
return err(new AnalysisCancelledError("list_browser_targets"));
|
||||
const result = await ready.value.listTargets(input, options);
|
||||
return result.ok
|
||||
? ok(
|
||||
createBrowserEvidence(
|
||||
"list_browser_targets",
|
||||
input,
|
||||
result.value,
|
||||
ready.value.identity(),
|
||||
),
|
||||
? browserEvidence(
|
||||
"list_browser_targets",
|
||||
input,
|
||||
result.value,
|
||||
ready.value.identity(),
|
||||
options,
|
||||
)
|
||||
: result;
|
||||
};
|
||||
@@ -52,17 +55,23 @@ export const inspectWebPage = async (
|
||||
): Promise<Result<Evidence, AnalysisError>> => {
|
||||
const ready = requireBrowser(browser, "inspect_web_page");
|
||||
if (!ready.ok) return ready;
|
||||
const result = await ready.value.inspectPage(input, options);
|
||||
if (result.ok && options.signal?.aborted === true)
|
||||
if (options.signal?.aborted)
|
||||
return err(new AnalysisCancelledError("inspect_web_page"));
|
||||
const result = await ready.value.inspectPage(input, options);
|
||||
if (!result.ok)
|
||||
return retainPageInspection(
|
||||
result.error,
|
||||
input,
|
||||
ready.value,
|
||||
"inspect_web_page",
|
||||
);
|
||||
return result.ok
|
||||
? ok(
|
||||
createBrowserEvidence(
|
||||
"inspect_web_page",
|
||||
input,
|
||||
result.value,
|
||||
ready.value.identity(),
|
||||
),
|
||||
? browserEvidence(
|
||||
"inspect_web_page",
|
||||
input,
|
||||
result.value,
|
||||
ready.value.identity(),
|
||||
options,
|
||||
)
|
||||
: result;
|
||||
};
|
||||
@@ -75,17 +84,22 @@ export const analyzeWebBundle = async (
|
||||
): Promise<Result<Evidence, AnalysisError>> => {
|
||||
const ready = requireBrowser(browser, "analyze_web_bundle");
|
||||
if (!ready.ok) return ready;
|
||||
const analyzed = await ready.value.analyzeBundle(input, options);
|
||||
if (analyzed.ok && options.signal?.aborted === true)
|
||||
if (options.signal?.aborted)
|
||||
return err(new AnalysisCancelledError("analyze_web_bundle"));
|
||||
if (!analyzed.ok) return analyzed;
|
||||
return ok(
|
||||
createBrowserEvidence(
|
||||
"analyze_web_bundle",
|
||||
const analyzed = await ready.value.analyzeBundle(input, options);
|
||||
if (!analyzed.ok)
|
||||
return retainPageInspection(
|
||||
analyzed.error,
|
||||
input,
|
||||
analyzed.value,
|
||||
ready.value.identity(),
|
||||
),
|
||||
ready.value,
|
||||
"analyze_web_bundle",
|
||||
);
|
||||
return browserEvidence(
|
||||
"analyze_web_bundle",
|
||||
input,
|
||||
analyzed.value,
|
||||
ready.value.identity(),
|
||||
options,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -97,17 +111,16 @@ export const observeWebSession = async (
|
||||
): Promise<Result<Evidence, AnalysisError>> => {
|
||||
const ready = requireBrowser(browser, "observe_web_session");
|
||||
if (!ready.ok) return ready;
|
||||
const observed = await ready.value.observeSession(input, options);
|
||||
if (observed.ok && options.signal?.aborted === true)
|
||||
if (options.signal?.aborted)
|
||||
return err(new AnalysisCancelledError("observe_web_session"));
|
||||
const observed = await ready.value.observeSession(input, options);
|
||||
return observed.ok
|
||||
? ok(
|
||||
createBrowserEvidence(
|
||||
"observe_web_session",
|
||||
input,
|
||||
observed.value,
|
||||
ready.value.identity(),
|
||||
),
|
||||
? browserEvidence(
|
||||
"observe_web_session",
|
||||
input,
|
||||
observed.value,
|
||||
ready.value.identity(),
|
||||
options,
|
||||
)
|
||||
: observed;
|
||||
};
|
||||
@@ -120,17 +133,16 @@ export const discoverWebMcpTools = async (
|
||||
): Promise<Result<Evidence, AnalysisError>> => {
|
||||
const ready = requireBrowser(browser, "discover_webmcp_tools");
|
||||
if (!ready.ok) return ready;
|
||||
const discovered = await ready.value.discoverWebMcpTools(input, options);
|
||||
if (discovered.ok && options.signal?.aborted === true)
|
||||
if (options.signal?.aborted)
|
||||
return err(new AnalysisCancelledError("discover_webmcp_tools"));
|
||||
const discovered = await ready.value.discoverWebMcpTools(input, options);
|
||||
return discovered.ok
|
||||
? ok(
|
||||
createBrowserEvidence(
|
||||
"discover_webmcp_tools",
|
||||
input,
|
||||
discovered.value,
|
||||
ready.value.identity(),
|
||||
),
|
||||
? browserEvidence(
|
||||
"discover_webmcp_tools",
|
||||
input,
|
||||
discovered.value,
|
||||
ready.value.identity(),
|
||||
options,
|
||||
)
|
||||
: discovered;
|
||||
};
|
||||
@@ -163,17 +175,16 @@ export const captureWebScreenshot = async (
|
||||
): Promise<Result<Evidence, AnalysisError>> => {
|
||||
const ready = requireBrowser(browser, "capture_web_screenshot");
|
||||
if (!ready.ok) return ready;
|
||||
const captured = await ready.value.captureScreenshot(input, options);
|
||||
if (captured.ok && options.signal?.aborted === true)
|
||||
if (options.signal?.aborted)
|
||||
return err(new AnalysisCancelledError("capture_web_screenshot"));
|
||||
const captured = await ready.value.captureScreenshot(input, options);
|
||||
return captured.ok
|
||||
? ok(
|
||||
createBrowserEvidence(
|
||||
"capture_web_screenshot",
|
||||
input,
|
||||
captured.value,
|
||||
ready.value.identity(),
|
||||
),
|
||||
? browserEvidence(
|
||||
"capture_web_screenshot",
|
||||
input,
|
||||
captured.value,
|
||||
ready.value.identity(),
|
||||
options,
|
||||
)
|
||||
: captured;
|
||||
};
|
||||
@@ -211,3 +222,68 @@ const requireBrowser = (
|
||||
),
|
||||
)
|
||||
: ok(browser);
|
||||
|
||||
const browserEvidence = (
|
||||
operation: Parameters<typeof createBrowserEvidence>[0],
|
||||
input: Parameters<typeof createBrowserEvidence>[1],
|
||||
result: Parameters<typeof createBrowserEvidence>[2],
|
||||
provider: Parameters<typeof createBrowserEvidence>[3],
|
||||
options: ExecutionOptions,
|
||||
): Result<Evidence, AnalysisError> => {
|
||||
const json = jsonValueSchema.safeParse(result);
|
||||
if (!json.success)
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
operation,
|
||||
"Browser provider returned non-JSON observation data.",
|
||||
{
|
||||
cause: json.error,
|
||||
},
|
||||
),
|
||||
);
|
||||
return completeProviderEvidence(
|
||||
createBrowserEvidence(operation, input, result, provider),
|
||||
options.signal,
|
||||
);
|
||||
};
|
||||
|
||||
/** Convert a provider-retained page capture without claiming bundle analysis completed. */
|
||||
const retainPageInspection = (
|
||||
error: AnalysisError,
|
||||
input: InspectWebPageInput | AnalyzeWebBundleInput,
|
||||
provider: BrowserObservationPort,
|
||||
errorOperation: "inspect_web_page" | "analyze_web_bundle",
|
||||
): Result<Evidence, AnalysisError> => {
|
||||
const partial = error.partialObservation;
|
||||
if (
|
||||
partial === undefined ||
|
||||
"evidence_id" in partial ||
|
||||
!("target" in partial) ||
|
||||
!("network" in partial) ||
|
||||
!("capture_window" in partial)
|
||||
)
|
||||
return err(error);
|
||||
const inspection = webPageInspectionSchema.safeParse(partial);
|
||||
if (!inspection.success) {
|
||||
if (error instanceof AnalysisCancelledError)
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
errorOperation,
|
||||
"Browser provider retained a malformed completed page inspection.",
|
||||
{ cause: inspection.error },
|
||||
),
|
||||
);
|
||||
return err(error);
|
||||
}
|
||||
const pageInput: InspectWebPageInput =
|
||||
"fetch_source_maps" in input
|
||||
? (({ fetch_source_maps: _fetchSourceMaps, ...page }) => page)(input)
|
||||
: input;
|
||||
error.partialObservation = createBrowserEvidence(
|
||||
"inspect_web_page",
|
||||
pageInput,
|
||||
inspection.data,
|
||||
provider.identity(),
|
||||
);
|
||||
return err(error);
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
|
||||
import { BrowserObservationError } from "../domain/browserObservationError.js";
|
||||
import { parseEvidence } from "../domain/evidence.js";
|
||||
import { browserScenarioInputSchema } from "../domain/browserScenario.js";
|
||||
import { err } from "../domain/result.js";
|
||||
import { scriptScenarioFixture } from "../../tests/fixtures/webScriptCapture.js";
|
||||
import type { BrowserScenarioCapturePort } from "./BrowserScenarioCapturePort.js";
|
||||
import { captureBrowserScenario } from "./BrowserScenarioCaptureService.js";
|
||||
|
||||
const scenario = browserScenarioInputSchema.parse({
|
||||
browser: { mode: "launch", executable_path: "/fixture/chrome" },
|
||||
start_url: { url: "https://fixture.test/", query: [] },
|
||||
actions: [{ step_id: "settle", action: "wait_for_timeout", duration_ms: 1 }],
|
||||
});
|
||||
const capture = scriptScenarioFixture();
|
||||
|
||||
it("converts a retained raw scenario capture into partial Evidence", async () => {
|
||||
const failure = new AnalysisCancelledError("capture_browser_scenario", {
|
||||
partialObservation: {
|
||||
kind: "browser-scenario-observation",
|
||||
capture,
|
||||
},
|
||||
});
|
||||
const provider: BrowserScenarioCapturePort = {
|
||||
identity: () => ({
|
||||
id: "scenario-fixture",
|
||||
name: "Scenario fixture",
|
||||
version: "1",
|
||||
}),
|
||||
captureScenario: async () => err(failure),
|
||||
};
|
||||
const result = await captureBrowserScenario(provider, scenario);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toBe(failure);
|
||||
expect(parseEvidence(result.error.partialObservation)).toMatchObject({
|
||||
operation: "capture_browser_scenario",
|
||||
normalized_result: capture,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("converts a raw scenario capture retained after cleanup failure", async () => {
|
||||
const failure = new BrowserObservationError(
|
||||
"capture_browser_scenario",
|
||||
"cleanup_failed",
|
||||
{
|
||||
cleanup: {
|
||||
reason: "browser close failed",
|
||||
resources: ["browser process"],
|
||||
},
|
||||
partialObservation: {
|
||||
kind: "browser-scenario-observation",
|
||||
capture,
|
||||
},
|
||||
},
|
||||
);
|
||||
const provider: BrowserScenarioCapturePort = {
|
||||
identity: () => ({
|
||||
id: "scenario-fixture",
|
||||
name: "Scenario fixture",
|
||||
version: "1",
|
||||
}),
|
||||
captureScenario: async () => err(failure),
|
||||
};
|
||||
const result = await captureBrowserScenario(provider, scenario);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toBe(failure);
|
||||
expect(result.error.cleanupIncomplete).toBe(true);
|
||||
expect(parseEvidence(result.error.partialObservation)).toMatchObject({
|
||||
operation: "capture_browser_scenario",
|
||||
normalized_result: capture,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,11 +1,17 @@
|
||||
import type { ExecutionOptions } from "./AnalysisProvider.js";
|
||||
import type { BrowserScenarioCapturePort } from "./BrowserScenarioCapturePort.js";
|
||||
import { createBrowserScenarioEvidence } from "./BrowserScenarioEvidence.js";
|
||||
import { AnalysisCapabilityUnavailableError } from "../domain/analysisErrorCore.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisCapabilityUnavailableError,
|
||||
AnalysisOutputError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import type { Evidence } from "../domain/evidence.js";
|
||||
import type { BrowserScenario } from "../domain/browserScenario.js";
|
||||
import { err, type Result } from "../domain/result.js";
|
||||
import { browserScenarioCaptureSchema } from "../domain/browserScenarioCapture.js";
|
||||
import { completeProviderEvidence } from "./ProviderOperationExecution.js";
|
||||
|
||||
const OPERATION = "capture_browser_scenario" as const;
|
||||
const PROVIDER_ID = "rea-playwright-browser-scenario";
|
||||
@@ -24,15 +30,51 @@ export const captureBrowserScenario = async (
|
||||
"browser scenario provider is not configured",
|
||||
),
|
||||
);
|
||||
if (options.signal?.aborted)
|
||||
return err(new AnalysisCancelledError(OPERATION));
|
||||
const captured = await provider.captureScenario(scenario, options);
|
||||
return captured.ok
|
||||
? {
|
||||
ok: true,
|
||||
value: createBrowserScenarioEvidence(
|
||||
if (!captured.ok) {
|
||||
const partial = captured.error.partialObservation;
|
||||
if (
|
||||
partial !== undefined &&
|
||||
"kind" in partial &&
|
||||
partial.kind === "browser-scenario-observation"
|
||||
) {
|
||||
const report = browserScenarioCaptureSchema.safeParse(partial.capture);
|
||||
if (
|
||||
report.success &&
|
||||
report.data.steps.every(({ status }) => status === "completed")
|
||||
) {
|
||||
captured.error.partialObservation = createBrowserScenarioEvidence(
|
||||
scenario,
|
||||
captured.value,
|
||||
report.data,
|
||||
provider.identity(),
|
||||
),
|
||||
);
|
||||
} else if (!report.success) {
|
||||
if (captured.error instanceof AnalysisCancelledError)
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
OPERATION,
|
||||
"Browser scenario provider retained a malformed completed capture.",
|
||||
{ cause: report.error },
|
||||
),
|
||||
);
|
||||
return captured;
|
||||
}
|
||||
: captured;
|
||||
}
|
||||
return captured;
|
||||
}
|
||||
const report = browserScenarioCaptureSchema.safeParse(captured.value);
|
||||
if (!report.success)
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
OPERATION,
|
||||
"Browser scenario provider returned a malformed completed capture.",
|
||||
{ cause: report.error },
|
||||
),
|
||||
);
|
||||
return completeProviderEvidence(
|
||||
createBrowserScenarioEvidence(scenario, report.data, provider.identity()),
|
||||
options.signal,
|
||||
);
|
||||
};
|
||||
@@ -97,6 +97,53 @@ const entry = (
|
||||
return found;
|
||||
};
|
||||
|
||||
describe("ADB and Apktool host availability", () => {
|
||||
it("uses only their selected owner probes", () => {
|
||||
const ready = {
|
||||
status: "available" as const,
|
||||
code: null,
|
||||
reason: null,
|
||||
diagnostics: {},
|
||||
};
|
||||
const configuredButInvalid = {
|
||||
status: "unavailable" as const,
|
||||
code: "not_configured" as const,
|
||||
reason: "REA_ADB_PATH is invalid",
|
||||
diagnostics: {},
|
||||
};
|
||||
const readyInventory = buildCapabilityInventory(status(), {
|
||||
...enabledPolicy,
|
||||
adbAnalysisAvailability: ready,
|
||||
apktoolAnalysisAvailability: ready,
|
||||
});
|
||||
expect(
|
||||
readyInventory.find(({ name }) => name === "inspect_adb_client"),
|
||||
).toMatchObject({ available: true, reason: "available" });
|
||||
expect(
|
||||
readyInventory.find(({ name }) => name === "inspect_apktool_client"),
|
||||
).toMatchObject({ available: true, reason: "available" });
|
||||
|
||||
const unavailableInventory = buildCapabilityInventory(status(), {
|
||||
...enabledPolicy,
|
||||
adbAnalysisAvailability: configuredButInvalid,
|
||||
apktoolAnalysisAvailability: {
|
||||
status: "unavailable",
|
||||
code: "unsupported_version",
|
||||
reason: "Apktool 2.6.3 is older than 2.7.0",
|
||||
diagnostics: {},
|
||||
},
|
||||
});
|
||||
expect(
|
||||
unavailableInventory.find(({ name }) => name === "inspect_adb_client"),
|
||||
).toMatchObject({ available: false, reason: "provider_missing" });
|
||||
expect(
|
||||
unavailableInventory.find(
|
||||
({ name }) => name === "decode_android_resources",
|
||||
),
|
||||
).toMatchObject({ available: false, reason: "provider_unavailable" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("capability failure reasons", () => {
|
||||
it("keeps an unavailable Android runtime distinct from a missing provider", () => {
|
||||
const inventory = buildCapabilityInventory(status(), {
|
||||
|
||||
@@ -22,6 +22,7 @@ import type { ToolKind } from "../contracts/toolContractTypes.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import { isMachOFormat } from "../domain/binaryTargetTypes.js";
|
||||
import type { ProviderAvailability } from "./AnalysisProvider.js";
|
||||
import type { JebAnalysisAvailability } from "./jeb/JebAnalysisPort.js";
|
||||
import { TOOL_CONTRACTS } from "../contracts/toolContracts.js";
|
||||
import { FRIDA_TOOL_CONTRACTS } from "../contracts/fridaToolContracts.js";
|
||||
import {
|
||||
@@ -54,7 +55,9 @@ export type AvailabilityPolicy = {
|
||||
readonly firmwareInspectionEnabled?: boolean;
|
||||
readonly firmwareExtractionEnabled?: boolean;
|
||||
readonly androidAnalysisAvailability?: ProviderAvailability;
|
||||
readonly jebAnalysisAvailability?: ProviderAvailability;
|
||||
readonly adbAnalysisAvailability?: ProviderAvailability;
|
||||
readonly apktoolAnalysisAvailability?: ProviderAvailability;
|
||||
readonly jebAnalysisAvailability?: JebAnalysisAvailability;
|
||||
readonly javascriptRecoveryEnabled?: boolean;
|
||||
readonly webModuleResolutionEnabled?: boolean;
|
||||
readonly browserObservationEnabled?: boolean;
|
||||
@@ -473,15 +476,32 @@ const workflowAvailabilityFor = ({
|
||||
policy.androidAnalysisAvailability?.reason ??
|
||||
"Set REA_JADX_MCP_JAR to a caller-supplied jadx-headless-mcp 0.7.1 JAR and provide a full JDK on Linux, macOS, or Windows x64 with its matching bundled native controls.",
|
||||
};
|
||||
if (kind === "jeb-provider")
|
||||
return policy.jebAnalysisAvailability?.status === "available"
|
||||
? { reason: "available", remediation: null }
|
||||
: {
|
||||
reason: "provider_missing",
|
||||
remediation:
|
||||
policy.jebAnalysisAvailability?.reason ??
|
||||
"Start a JEB client serving MCP (GUI client, or headless via its programmatic launcher) and point REA_JEB_MCP_URL at it (default http://127.0.0.1:8425/mcp). REA does not install or launch JEB.",
|
||||
};
|
||||
if (kind === "adb-provider")
|
||||
return ownerProbeAvailability(
|
||||
policy.adbAnalysisAvailability,
|
||||
"Set REA_ADB_PATH to a caller-supplied adb executable or make adb available on PATH. This checks the client version only; device and serial availability depend on the request.",
|
||||
);
|
||||
if (kind === "apktool-provider")
|
||||
return ownerProbeAvailability(
|
||||
policy.apktoolAnalysisAvailability,
|
||||
"Set REA_APKTOOL_COMMAND to a caller-supplied Apktool executable or make Apktool 2.7.0 or newer available on PATH.",
|
||||
);
|
||||
if (kind === "jeb-provider") {
|
||||
const availability = policy.jebAnalysisAvailability;
|
||||
if (availability?.status === "available")
|
||||
return availability.supportedOperations.some(
|
||||
(operation) => operation === name,
|
||||
)
|
||||
? { reason: "available", remediation: null }
|
||||
: {
|
||||
reason: "provider_unavailable",
|
||||
remediation: `The running JEB endpoint does not advertise support for ${name}. Use a JEB client build that provides this operation.`,
|
||||
};
|
||||
return ownerProbeAvailability(
|
||||
availability,
|
||||
"Start a JEB client serving MCP (GUI client, or headless via its programmatic launcher) and point REA_JEB_MCP_URL at it (default http://127.0.0.1:8425/mcp). REA does not install or launch JEB.",
|
||||
);
|
||||
}
|
||||
const browser = browserProviderAvailability(name, kind, policy);
|
||||
if (browser !== null) return browser;
|
||||
if (name === "capture_process_scenario" && !policy.processCaptureEnabled)
|
||||
@@ -506,6 +526,25 @@ const workflowAvailabilityFor = ({
|
||||
return null;
|
||||
};
|
||||
|
||||
const ownerProbeAvailability = (
|
||||
observation: ProviderAvailability | undefined,
|
||||
missingRemediation: string,
|
||||
): Availability => {
|
||||
if (observation === undefined)
|
||||
return { reason: "provider_missing", remediation: missingRemediation };
|
||||
if (observation.status === "available")
|
||||
return { reason: "available", remediation: null };
|
||||
return {
|
||||
reason:
|
||||
observation.code === "unsupported_host"
|
||||
? "unsupported_host"
|
||||
: observation.code === "not_configured"
|
||||
? "provider_missing"
|
||||
: "provider_unavailable",
|
||||
remediation: observation.reason,
|
||||
};
|
||||
};
|
||||
|
||||
const electronProviderAvailability = (
|
||||
name: string,
|
||||
kind: ToolKind,
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
packagePrivateStorageFamily,
|
||||
redirectedWriteBlocker,
|
||||
type MsixPackageView,
|
||||
windowsAppDataRoots,
|
||||
} from "./MsixPackageStorage.js";
|
||||
|
||||
const home = join("/profile", "a");
|
||||
@@ -38,6 +39,15 @@ const privateDirectory = dirname(privateConfig);
|
||||
const privatePreferences = join(privateDirectory, "Preferences");
|
||||
const appDataDirectory = dirname(appDataConfig);
|
||||
|
||||
it.each([undefined, ""])(
|
||||
"uses default AppData roots only for unset or empty values: %s",
|
||||
(value) => {
|
||||
expect(
|
||||
windowsAppDataRoots(home, { APPDATA: value, LOCALAPPDATA: value }),
|
||||
).toEqual({ roaming: env.APPDATA, local: env.LOCALAPPDATA });
|
||||
},
|
||||
);
|
||||
|
||||
/** Package storage holding `privateFiles`; realpath decides the process view. */
|
||||
const packagedFileSystem = (
|
||||
realpath: ClaudeDesktopLocationFileSystem["realpath"],
|
||||
@@ -153,6 +163,22 @@ describe("Claude Desktop MSIX process view", () => {
|
||||
});
|
||||
|
||||
describe("unresolved Claude Desktop package identity", () => {
|
||||
it.each([
|
||||
"Notes.AnthropicPBC.Claude_fnn82j28hfe8t",
|
||||
"AnthropicPBC.Claude_fnn82j28hfe8t.backup",
|
||||
"AnthropicPBCxClaude_fnn82j28hfe8t",
|
||||
])("does not treat %s as a Claude package family", (name) => {
|
||||
const location = resolveWindowsClaudeDesktopLocation(home, env, {
|
||||
readdir: () => [name],
|
||||
lstat: () => file,
|
||||
realpath: (path) => path,
|
||||
});
|
||||
expect(location).toEqual({
|
||||
ok: true,
|
||||
value: { configPath: appDataConfig, markerPath: appDataDirectory },
|
||||
});
|
||||
});
|
||||
|
||||
it("reports unreadable package storage instead of assuming AppData", () => {
|
||||
const location = resolveWindowsClaudeDesktopLocation(home, env, {
|
||||
readdir: () => {
|
||||
|
||||
@@ -19,8 +19,9 @@ const PROCESS_VIEW_PROBES = 8;
|
||||
*/
|
||||
export const CLAUDE_DESKTOP_PACKAGE_FAMILY = "Claude_pzs8sxrjxfjjc";
|
||||
|
||||
/** `Claude_` plus a 13-character Crockford base32 MSIX publisher ID. */
|
||||
const CLAUDE_PACKAGE_FAMILY = /^Claude_[0-9a-hjkmnp-tv-z]{13}$/iu;
|
||||
/** Observed Claude package names plus Windows' 13-character publisher ID. */
|
||||
const CLAUDE_PACKAGE_FAMILY =
|
||||
/^(?:AnthropicPBC\.)?Claude_[0-9a-hjkmnp-tv-z]{13}$/iu;
|
||||
|
||||
/** The configuration Claude Desktop reads and the evidence that it is installed. */
|
||||
export interface ClaudeDesktopLocation {
|
||||
@@ -52,8 +53,8 @@ const systemFileSystem: ClaudeDesktopLocationFileSystem = {
|
||||
* Resolve the `claude_desktop_config.json` that Windows Claude Desktop reads.
|
||||
* Without its MSIX package storage, that is the AppData file. With it, the
|
||||
* package reads its private copy when one exists and the AppData file
|
||||
* otherwise; a new registration goes to the private copy. A `Claude_*`
|
||||
* package from another publisher leaves the reader unknown.
|
||||
* otherwise; a new registration goes to the private copy. Other Claude
|
||||
* package identities and deployments leave the reader unknown.
|
||||
*/
|
||||
export const resolveWindowsClaudeDesktopLocation = (
|
||||
home: string,
|
||||
@@ -84,7 +85,7 @@ export const resolveWindowsClaudeDesktopLocation = (
|
||||
if (foreign.length > 0)
|
||||
return err({
|
||||
path: join(packagesDirectory, foreign[0] ?? ""),
|
||||
message: `MSIX package storage ${foreign.map((name) => join(packagesDirectory, name)).join(", ")} uses Claude Desktop's package name with an unrecognized publisher (Claude Desktop is ${CLAUDE_DESKTOP_PACKAGE_FAMILY}). REA cannot establish which ${CONFIG_FILE} that package reads and does not fall back to ${appDataConfigPath}. Remove the unrecognized package, or register REA in the configuration it reads.`,
|
||||
message: `MSIX package storage ${foreign.map((name) => join(packagesDirectory, name)).join(", ")} has an unrecognized Claude Desktop package identity or deployment (the supported identity is ${CLAUDE_DESKTOP_PACKAGE_FAMILY}). REA cannot establish which ${CONFIG_FILE} that package reads and does not fall back to ${appDataConfigPath}. Quit Claude Desktop fully and register REA in the configuration the installed app reads.`,
|
||||
});
|
||||
const family = families[0];
|
||||
if (family === undefined)
|
||||
|
||||
@@ -14,12 +14,14 @@ import {
|
||||
} from "./HermesConfigurationYaml.js";
|
||||
import {
|
||||
applyEdits,
|
||||
createScanner,
|
||||
findNodeAtLocation,
|
||||
modify,
|
||||
parse as parseJsonc,
|
||||
parseTree,
|
||||
printParseErrorCode,
|
||||
type ParseError,
|
||||
type SyntaxKind,
|
||||
} from "jsonc-parser";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -1247,44 +1249,27 @@ const tomlTextHolds = (
|
||||
}
|
||||
};
|
||||
|
||||
/** Comments in the trivia between two properties. `undefined` when there are none. */
|
||||
const commentsBetweenProperties = (gap: string): string | undefined => {
|
||||
const pieces: string[] = [];
|
||||
let index = 0;
|
||||
while (index < gap.length) {
|
||||
const line = gap.indexOf("//", index);
|
||||
const block = gap.indexOf("/*", index);
|
||||
if (line < 0 && block < 0) break;
|
||||
const start = line < 0 ? block : block < 0 ? line : Math.min(line, block);
|
||||
const lineComment = start === line;
|
||||
const close = lineComment
|
||||
? gap.indexOf("\n", start)
|
||||
: gap.indexOf("*/", start + 2);
|
||||
if (!lineComment && close < 0) return undefined;
|
||||
const end = lineComment ? (close < 0 ? gap.length : close) : close + 2;
|
||||
const comment = gap.slice(start, end);
|
||||
const lineBreak = gap.lastIndexOf("\n", start - 1);
|
||||
if (lineBreak < 0) pieces.push(` ${comment}`);
|
||||
else {
|
||||
const prefix = gap.slice(lineBreak + 1, start);
|
||||
let indentEnd = 0;
|
||||
while (prefix[indentEnd] === " " || prefix[indentEnd] === "\t")
|
||||
indentEnd += 1;
|
||||
const newline =
|
||||
lineBreak > 0 && gap[lineBreak - 1] === "\r" ? "\r\n" : "\n";
|
||||
pieces.push(`${newline}${prefix.slice(0, indentEnd)}${comment}`);
|
||||
}
|
||||
index = end;
|
||||
}
|
||||
return pieces.length === 0 ? undefined : pieces.join("");
|
||||
// jsonc-parser declares SyntaxKind as an ambient const enum, so keep its token
|
||||
// code local and type-checked as in the JSON scanner used by IDA diagnostics.
|
||||
const JSON_COMMA = 5 satisfies SyntaxKind;
|
||||
|
||||
/** Find the next non-trivia JSONC token at or after `offset`. */
|
||||
const nextJsoncToken = (
|
||||
text: string,
|
||||
offset: number,
|
||||
): { readonly kind: SyntaxKind; readonly offset: number } => {
|
||||
const scanner = createScanner(text, true);
|
||||
scanner.setPosition(offset);
|
||||
const kind = scanner.scan();
|
||||
return { kind, offset: scanner.getTokenOffset() };
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete an object property without taking a comment from the gap before it.
|
||||
* jsonc-parser's removal span includes that gap, so a trailing comment on the
|
||||
* previous property would disappear. Returns `undefined` to keep the stock edit.
|
||||
* Delete a property by its JSONC AST span and the separator needed to keep its
|
||||
* parent object valid. All whitespace and comments outside those tokens stay
|
||||
* byte-for-byte in place.
|
||||
*/
|
||||
const deleteJsonPropertyPreservingComments = (
|
||||
const deleteJsonProperty = (
|
||||
text: string,
|
||||
path: readonly string[],
|
||||
): string | undefined => {
|
||||
@@ -1301,54 +1286,26 @@ const deleteJsonPropertyPreservingComments = (
|
||||
)
|
||||
return undefined;
|
||||
const propertyIndex = parent.children.indexOf(property);
|
||||
if (propertyIndex < 0) return undefined;
|
||||
const previous = parent.children[propertyIndex - 1];
|
||||
if (propertyIndex <= 0 || previous === undefined) return undefined;
|
||||
const comments = commentsBetweenProperties(
|
||||
text.slice(previous.offset + previous.length, property.offset),
|
||||
);
|
||||
if (comments === undefined) return undefined;
|
||||
let removeEnd = property.offset + property.length;
|
||||
const hasNext = parent.children[propertyIndex + 1] !== undefined;
|
||||
if (hasNext) {
|
||||
let comma = removeEnd;
|
||||
while (comma < text.length) {
|
||||
const char = text[comma];
|
||||
if (char === " " || char === "\t" || char === "\r" || char === "\n") {
|
||||
comma += 1;
|
||||
continue;
|
||||
}
|
||||
if (text.startsWith("/*", comma)) {
|
||||
const close = text.indexOf("*/", comma + 2);
|
||||
if (close < 0) return undefined;
|
||||
comma = close + 2;
|
||||
continue;
|
||||
}
|
||||
if (text.startsWith("//", comma)) {
|
||||
while (
|
||||
comma < text.length &&
|
||||
text[comma] !== "\n" &&
|
||||
text[comma] !== "\r"
|
||||
)
|
||||
comma += 1;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (text[comma] !== ",") return undefined;
|
||||
removeEnd = comma + 1;
|
||||
const next = parent.children[propertyIndex + 1];
|
||||
let commaOffset: number | undefined;
|
||||
if (previous !== undefined) {
|
||||
const comma = nextJsoncToken(text, previous.offset + previous.length);
|
||||
if (comma.kind !== JSON_COMMA) return undefined;
|
||||
commaOffset = comma.offset;
|
||||
} else {
|
||||
const token = nextJsoncToken(text, property.offset + property.length);
|
||||
if (token.kind === JSON_COMMA) commaOffset = token.offset;
|
||||
else if (next !== undefined) return undefined;
|
||||
}
|
||||
// A line comment runs to the next break. Keep the following token outside it.
|
||||
const remainder = text.slice(removeEnd);
|
||||
let suffix = `${hasNext ? "," : ""}${comments}`;
|
||||
if (
|
||||
suffix.includes("//") &&
|
||||
!suffix.endsWith("\n") &&
|
||||
!suffix.endsWith("\r") &&
|
||||
!remainder.startsWith("\n") &&
|
||||
!remainder.startsWith("\r")
|
||||
)
|
||||
suffix += "\n";
|
||||
return `${text.slice(0, previous.offset + previous.length)}${suffix}${remainder}`;
|
||||
const edits = [
|
||||
{ offset: property.offset, length: property.length, content: "" },
|
||||
...(commaOffset === undefined
|
||||
? []
|
||||
: [{ offset: commaOffset, length: 1, content: "" }]),
|
||||
];
|
||||
return applyEdits(text, edits);
|
||||
};
|
||||
|
||||
/** Serialize a validated client document, retaining JSONC and TOML comments. */
|
||||
@@ -1377,7 +1334,7 @@ export const serializeClientConfiguration = (
|
||||
return editedPaths.reduce((text, path) => {
|
||||
const value = valueAt(document, path);
|
||||
if (value === undefined) {
|
||||
const preserved = deleteJsonPropertyPreservingComments(text, path);
|
||||
const preserved = deleteJsonProperty(text, path);
|
||||
if (preserved !== undefined) return preserved;
|
||||
}
|
||||
return applyEdits(
|
||||
|
||||
@@ -13,6 +13,18 @@ import {
|
||||
import { PRODUCT_IDENTITY } from "../identity.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
import { dshDocument } from "./DshCordisPatch.js";
|
||||
import type { McpToolTimeoutInputs } from "../mcpStartupPolicy.js";
|
||||
import type { AnalysisProviderSelector } from "../contracts/providerSelection.js";
|
||||
|
||||
export type ProviderRequestBudgetResolver = (
|
||||
analysisProvider: AnalysisProviderSelector,
|
||||
environment: Readonly<Record<string, string>>,
|
||||
) => Result<number | undefined, ConfigurationError>;
|
||||
|
||||
export interface ClientRegistrationEnvironmentDetails {
|
||||
readonly values: Readonly<Record<string, string>>;
|
||||
readonly timeoutInputs: McpToolTimeoutInputs;
|
||||
}
|
||||
|
||||
/** Read explicit REA overrides without dropping peers when one value is invalid. */
|
||||
export const clientRegistrationEnvironment = (
|
||||
@@ -22,6 +34,33 @@ export const clientRegistrationEnvironment = (
|
||||
): Result<Readonly<Record<string, string>>, ConfigurationError> => {
|
||||
if (parsed?.dialect === "dsh")
|
||||
return dshDocument(parsed.document).environment(overrides, validation);
|
||||
const details = clientRegistrationEnvironmentDetails(
|
||||
parsed,
|
||||
overrides,
|
||||
validation,
|
||||
);
|
||||
return details.ok ? ok(details.value.values) : details;
|
||||
};
|
||||
|
||||
/** Retain original strings and the parser-owned timeout facts. */
|
||||
export const clientRegistrationEnvironmentDetails = (
|
||||
parsed: ClientConfigurationDocument | undefined,
|
||||
overrides: Readonly<Record<string, string>> = {},
|
||||
validation: "fragment" | "effective" = "effective",
|
||||
resolveProviderRequestBudget?: ProviderRequestBudgetResolver,
|
||||
): Result<ClientRegistrationEnvironmentDetails, ConfigurationError> => {
|
||||
if (parsed?.dialect === "dsh") {
|
||||
const environment = dshDocument(parsed.document).environment(
|
||||
overrides,
|
||||
validation,
|
||||
);
|
||||
if (!environment.ok) return environment;
|
||||
return validatedDetails(
|
||||
environment.value,
|
||||
validation,
|
||||
resolveProviderRequestBudget,
|
||||
);
|
||||
}
|
||||
const key =
|
||||
parsed?.dialect === "opencode" || parsed?.dialect === "opencode_v2"
|
||||
? "environment"
|
||||
@@ -50,16 +89,45 @@ export const clientRegistrationEnvironment = (
|
||||
// Validate known settings with the runtime's parser, but retain the original
|
||||
// string overrides, including unknown client-specific keys and no defaults.
|
||||
const environment = { ...existing.data, ...overrides };
|
||||
return validatedDetails(
|
||||
environment,
|
||||
validation,
|
||||
resolveProviderRequestBudget,
|
||||
);
|
||||
};
|
||||
|
||||
const validatedDetails = (
|
||||
environment: Readonly<Record<string, string>>,
|
||||
validation: "fragment" | "effective",
|
||||
resolveProviderRequestBudget?: ProviderRequestBudgetResolver,
|
||||
): Result<ClientRegistrationEnvironmentDetails, ConfigurationError> => {
|
||||
const validated =
|
||||
validation === "fragment"
|
||||
? parseEnvironmentValues(environment)
|
||||
: parseEnvironment(environment);
|
||||
if (!validated.ok) return validated;
|
||||
return ok(
|
||||
Object.fromEntries(
|
||||
const providerRequestBudget =
|
||||
resolveProviderRequestBudget?.(
|
||||
validated.value.REA_ANALYSIS_PROVIDER,
|
||||
environment,
|
||||
) ?? ok(undefined);
|
||||
if (!providerRequestBudget.ok) return providerRequestBudget;
|
||||
return ok({
|
||||
values: Object.fromEntries(
|
||||
Object.entries(environment).sort(([left], [right]) =>
|
||||
compareUnicodeCodePoints(left, right),
|
||||
),
|
||||
),
|
||||
);
|
||||
timeoutInputs: {
|
||||
analysisProvider: validated.value.REA_ANALYSIS_PROVIDER,
|
||||
ghidraStartupTimeoutMs: validated.value.REA_GHIDRA_STARTUP_TIMEOUT_MS,
|
||||
hopperStartupTimeoutMs: validated.value.REA_HOPPER_STARTUP_TIMEOUT_MS,
|
||||
...(validated.value.REA_JEB_MCP_TIMEOUT_MS === undefined
|
||||
? {}
|
||||
: { jebMcpTimeoutMs: validated.value.REA_JEB_MCP_TIMEOUT_MS }),
|
||||
...(providerRequestBudget.value === undefined
|
||||
? {}
|
||||
: { providerRequestTimeoutMs: providerRequestBudget.value }),
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { basename, isAbsolute, resolve } from "node:path";
|
||||
import { isAbsolute, resolve } from "node:path";
|
||||
import { valid } from "semver";
|
||||
|
||||
import { PRODUCT_IDENTITY } from "../identity.js";
|
||||
@@ -13,8 +13,9 @@ const environmentValue = (
|
||||
platform: NodeJS.Platform,
|
||||
): string | undefined => {
|
||||
if (platform !== "win32") return environment[name];
|
||||
const key = Object.keys(environment).find(
|
||||
(candidate) => candidate.toLowerCase() === name,
|
||||
const target = name.toLowerCase();
|
||||
const key = Object.keys(environment).findLast(
|
||||
(candidate) => candidate.toLowerCase() === target,
|
||||
);
|
||||
return key === undefined ? undefined : environment[key];
|
||||
};
|
||||
@@ -112,13 +113,56 @@ const isOwnedPackageReference = (packageReference: string): boolean => {
|
||||
};
|
||||
|
||||
/** Check that one parsed command points to REA's MCP entry point. */
|
||||
export type ClientRegistrationCommandIdentity = "owned" | "foreign" | "unknown";
|
||||
|
||||
/** Classify only what the command and current executable identity establish. */
|
||||
export const classifyClientRegistrationCommand = (
|
||||
command: readonly string[],
|
||||
currentCommandPath: string | undefined,
|
||||
): ClientRegistrationCommandIdentity => {
|
||||
const packageReference = clientRegistrationPackageReference(command);
|
||||
if (packageReference !== undefined)
|
||||
return isOwnedClientRegistrationCommand(command, currentCommandPath)
|
||||
? "owned"
|
||||
: "foreign";
|
||||
const currentPathAvailable =
|
||||
currentCommandPath !== undefined && currentCommandPath.length > 0;
|
||||
const nodeScript = command.length === 3 && command[2] === "mcp";
|
||||
if (nodeScript) {
|
||||
if (
|
||||
!isAbsolute(command[0] ?? "") ||
|
||||
!isAbsolute(command[1] ?? "") ||
|
||||
!currentPathAvailable
|
||||
)
|
||||
return "unknown";
|
||||
return isOwnedClientRegistrationCommand(command, currentCommandPath)
|
||||
? "owned"
|
||||
: "foreign";
|
||||
}
|
||||
if (command.length === 2 && command[1] === "mcp") {
|
||||
const executable = command[0] ?? "";
|
||||
if (executable === PRODUCT_IDENTITY.cliBinary) return "owned";
|
||||
const relativePathLike =
|
||||
!isAbsolute(executable) && /[./\\]/u.test(executable);
|
||||
if (relativePathLike || (isAbsolute(executable) && !currentPathAvailable))
|
||||
return "unknown";
|
||||
}
|
||||
if (isOwnedClientRegistrationCommand(command, currentCommandPath))
|
||||
return "owned";
|
||||
return "foreign";
|
||||
};
|
||||
|
||||
export const isOwnedClientRegistrationCommand = (
|
||||
command: readonly string[],
|
||||
currentCommandPath: string = resolve(process.argv[1] ?? "unknown"),
|
||||
currentCommandPath: string | undefined,
|
||||
): boolean => {
|
||||
if (
|
||||
currentCommandPath !== undefined &&
|
||||
currentCommandPath.length > 0 &&
|
||||
command.length === 3 &&
|
||||
command[2] === "mcp" &&
|
||||
isAbsolute(command[0] ?? "") &&
|
||||
isAbsolute(command[1] ?? "") &&
|
||||
resolve(command[0] ?? "") === resolve(process.execPath) &&
|
||||
resolve(command[1] ?? "") === currentCommandPath
|
||||
)
|
||||
@@ -129,8 +173,9 @@ export const isOwnedClientRegistrationCommand = (
|
||||
if (
|
||||
executable === PRODUCT_IDENTITY.cliBinary ||
|
||||
(isAbsolute(executable) &&
|
||||
basename(executable) === PRODUCT_IDENTITY.cliBinary) ||
|
||||
resolve(executable) === currentCommandPath
|
||||
currentCommandPath !== undefined &&
|
||||
currentCommandPath.length > 0 &&
|
||||
resolve(executable) === currentCommandPath)
|
||||
)
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -7,16 +7,23 @@ import {
|
||||
type ClientConfigurationDocument,
|
||||
} from "./ClientConfigurationDocument.js";
|
||||
import { access } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { isAbsolute, resolve } from "node:path";
|
||||
|
||||
import { z } from "zod";
|
||||
import { valid } from "semver";
|
||||
|
||||
import { PRODUCT_IDENTITY } from "../identity.js";
|
||||
import { MCP_STARTUP_POLICY } from "../mcpStartupPolicy.js";
|
||||
import { CURRENT_ENTRYPOINT_PATH } from "../process/CurrentEntrypointPath.js";
|
||||
import {
|
||||
MCP_STARTUP_POLICY,
|
||||
mcpProviderDeadlineAllowanceMs,
|
||||
recommendedMcpToolTimeoutMs,
|
||||
recommendedMcpToolTimeoutSeconds,
|
||||
type McpToolTimeoutInputs,
|
||||
} from "../mcpStartupPolicy.js";
|
||||
import {
|
||||
clientRegistrationPackageReference,
|
||||
isOwnedClientRegistrationCommand,
|
||||
classifyClientRegistrationCommand,
|
||||
packageRunnerRegistrationCommand,
|
||||
} from "./ClientRegistrationIdentity.js";
|
||||
import {
|
||||
@@ -28,6 +35,8 @@ import {
|
||||
import type { SetupClient } from "./SupportedClients.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
import { dshDocument } from "./DshCordisPatch.js";
|
||||
import { clientRegistrationEnvironmentDetails } from "./ClientRegistrationEnvironment.js";
|
||||
import type { ProviderRequestBudgetResolver } from "./ClientRegistrationEnvironment.js";
|
||||
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
|
||||
|
||||
interface ClientRegistrationStatusBase {
|
||||
@@ -35,6 +44,8 @@ interface ClientRegistrationStatusBase {
|
||||
readonly config_path: string;
|
||||
/** Exact package reference requested by npx/bunx, when registration uses one. */
|
||||
readonly package_reference?: string;
|
||||
/** Non-blocking timeout guidance; caller-selected values remain aligned. */
|
||||
readonly advisory?: string;
|
||||
}
|
||||
|
||||
type RegistrationCommand = readonly [string, ...string[]];
|
||||
@@ -51,6 +62,11 @@ export type ClientRegistrationStatus = ClientRegistrationStatusBase &
|
||||
readonly state: "stale";
|
||||
readonly remediation: string;
|
||||
}
|
||||
| {
|
||||
readonly command: RegistrationCommand;
|
||||
readonly state: "unknown";
|
||||
readonly remediation: string;
|
||||
}
|
||||
| {
|
||||
readonly command: readonly [];
|
||||
readonly state: "missing" | "invalid" | "manual";
|
||||
@@ -68,6 +84,7 @@ const registrationSchema = z
|
||||
command: z.string().min(1),
|
||||
args: z.array(z.string()).default([]),
|
||||
startup_timeout_sec: z.number().positive().optional(),
|
||||
tool_timeout_sec: z.number().positive().optional(),
|
||||
startupTimeoutMs: z.number().positive().optional(),
|
||||
toolTimeoutMs: z.number().positive().optional(),
|
||||
type: z.string().optional(),
|
||||
@@ -92,6 +109,7 @@ interface ClientRegistrationReadOptions {
|
||||
* product profile is absent.
|
||||
*/
|
||||
readonly evidence?: "readiness" | "ownership";
|
||||
readonly resolveProviderRequestBudget?: ProviderRequestBudgetResolver;
|
||||
}
|
||||
|
||||
/** Registration facts shared by diagnostics and update maintenance. */
|
||||
@@ -103,7 +121,7 @@ export interface ClientRegistrationInspection {
|
||||
/** Project diagnostics from the same inspection used to select maintenance. */
|
||||
export const readClientRegistrationStatuses = async (
|
||||
home: string,
|
||||
currentCommandPath: string = resolve(process.argv[1] ?? "unknown"),
|
||||
currentCommandPath: string | undefined = CURRENT_ENTRYPOINT_PATH,
|
||||
options: ClientRegistrationReadOptions = {},
|
||||
): Promise<readonly ClientRegistrationStatus[]> =>
|
||||
(
|
||||
@@ -113,7 +131,7 @@ export const readClientRegistrationStatuses = async (
|
||||
/** Parse effective registrations and their client-specific activation once. */
|
||||
export const readClientRegistrationInspections = async (
|
||||
home: string,
|
||||
currentCommandPath: string = resolve(process.argv[1] ?? "unknown"),
|
||||
currentCommandPath: string | undefined = CURRENT_ENTRYPOINT_PATH,
|
||||
options: ClientRegistrationReadOptions = {},
|
||||
): Promise<readonly ClientRegistrationInspection[]> => {
|
||||
const inspections: ClientRegistrationInspection[] = [];
|
||||
@@ -226,6 +244,28 @@ export const readClientRegistrationInspections = async (
|
||||
}
|
||||
: client;
|
||||
const registration = parseRegistration(raw, effectiveClient);
|
||||
const registrationEnvironment =
|
||||
client.name === "kimi" || client.name === "codex"
|
||||
? clientRegistrationEnvironmentDetails(
|
||||
parsed,
|
||||
{},
|
||||
"effective",
|
||||
options.resolveProviderRequestBudget,
|
||||
)
|
||||
: undefined;
|
||||
if (
|
||||
registrationEnvironment !== undefined &&
|
||||
!registrationEnvironment.ok
|
||||
) {
|
||||
record({
|
||||
client: client.name,
|
||||
config_path: configPath,
|
||||
command: [],
|
||||
state: "invalid",
|
||||
remediation: registrationEnvironment.error.message,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const command: RegistrationCommand = [
|
||||
registration.command,
|
||||
...registration.args,
|
||||
@@ -236,27 +276,44 @@ export const readClientRegistrationInspections = async (
|
||||
const packageVersionDrift =
|
||||
packageVersion !== undefined &&
|
||||
packageVersion !== PRODUCT_IDENTITY.packageVersion;
|
||||
const alignment = registrationAligned({
|
||||
registration,
|
||||
client: effectiveClient,
|
||||
currentCommandPath,
|
||||
platform: options.platform ?? process.platform,
|
||||
kimiInputSchemaProfileSelected:
|
||||
registrationEnvironment?.ok === true &&
|
||||
registrationEnvironment.value.values.REA_MCP_INPUT_SCHEMA_PROFILE !==
|
||||
undefined,
|
||||
});
|
||||
record(
|
||||
configuredStatus(
|
||||
effectiveClient,
|
||||
command,
|
||||
activation === "enabled" &&
|
||||
!packageVersionDrift &&
|
||||
registrationAligned(
|
||||
registration,
|
||||
effectiveClient,
|
||||
currentCommandPath,
|
||||
options.platform ?? process.platform,
|
||||
) &&
|
||||
(parsed.dialect !== "dsh" || dshDocument(parsed.document).ready) &&
|
||||
policyBlock === undefined
|
||||
? "aligned"
|
||||
: "stale",
|
||||
alignment === "unknown"
|
||||
? "unknown"
|
||||
: activation === "enabled" &&
|
||||
!packageVersionDrift &&
|
||||
alignment === "aligned" &&
|
||||
(parsed.dialect !== "dsh" ||
|
||||
dshDocument(parsed.document).ready) &&
|
||||
policyBlock === undefined
|
||||
? "aligned"
|
||||
: "stale",
|
||||
policyBlock ??
|
||||
(packageVersionDrift
|
||||
? packageVersionRemediation(packageReference ?? "", client)
|
||||
: undefined),
|
||||
: alignment === "unknown"
|
||||
? unknownEntryPointRemediation()
|
||||
: undefined),
|
||||
packageReference,
|
||||
registrationEnvironment?.ok === true
|
||||
? timeoutAdvisory(
|
||||
client,
|
||||
registration,
|
||||
registrationEnvironment.value.timeoutInputs,
|
||||
)
|
||||
: undefined,
|
||||
),
|
||||
activation,
|
||||
);
|
||||
@@ -295,28 +352,45 @@ const registrationActivation = (
|
||||
? "disabled"
|
||||
: "enabled";
|
||||
|
||||
const registrationAligned = (
|
||||
registration: z.output<typeof registrationSchema>,
|
||||
client: SetupClient,
|
||||
currentCommandPath: string,
|
||||
platform: NodeJS.Platform,
|
||||
): boolean => {
|
||||
const registrationAligned = ({
|
||||
registration,
|
||||
client,
|
||||
currentCommandPath,
|
||||
platform,
|
||||
kimiInputSchemaProfileSelected,
|
||||
}: {
|
||||
readonly registration: z.output<typeof registrationSchema>;
|
||||
readonly client: SetupClient;
|
||||
readonly currentCommandPath: string | undefined;
|
||||
readonly platform: NodeJS.Platform;
|
||||
readonly kimiInputSchemaProfileSelected: boolean;
|
||||
}): "aligned" | "stale" | "unknown" => {
|
||||
const command = [registration.command, ...registration.args];
|
||||
if (!isOwnedClientRegistrationCommand(command, currentCommandPath))
|
||||
return false;
|
||||
const identity = classifyClientRegistrationCommand(
|
||||
command,
|
||||
currentCommandPath,
|
||||
);
|
||||
if (identity === "unknown") return "unknown";
|
||||
if (identity === "foreign") return "stale";
|
||||
if (
|
||||
(client.name === "codex" || client.name === "grok_build") &&
|
||||
client.name === "grok_build" &&
|
||||
registration.startup_timeout_sec !==
|
||||
MCP_STARTUP_POLICY.codexStartupTimeoutSeconds
|
||||
)
|
||||
return false;
|
||||
return "stale";
|
||||
if (
|
||||
client.name === "codex" &&
|
||||
(registration.startup_timeout_sec === undefined ||
|
||||
registration.tool_timeout_sec === undefined)
|
||||
)
|
||||
return "stale";
|
||||
if (
|
||||
client.name === "kimi" &&
|
||||
(registration.startupTimeoutMs !==
|
||||
MCP_STARTUP_POLICY.kimiStartupTimeoutMs ||
|
||||
registration.toolTimeoutMs !== MCP_STARTUP_POLICY.kimiToolTimeoutMs)
|
||||
(registration.startupTimeoutMs === undefined ||
|
||||
registration.toolTimeoutMs === undefined)
|
||||
)
|
||||
return false;
|
||||
return "stale";
|
||||
if (client.name === "kimi" && !kimiInputSchemaProfileSelected) return "stale";
|
||||
if (
|
||||
(client.format === "kimi" ||
|
||||
client.format === "omp" ||
|
||||
@@ -324,45 +398,55 @@ const registrationAligned = (
|
||||
registration.type !== undefined &&
|
||||
registration.type !== "stdio"
|
||||
)
|
||||
return false;
|
||||
if (client.format === "vscode" && registration.type !== "stdio") return false;
|
||||
return "stale";
|
||||
if (client.format === "vscode" && registration.type !== "stdio")
|
||||
return "stale";
|
||||
// A Harness registration only connects as the rea stdio server; another
|
||||
// server name or transport leaves the session without REA tools.
|
||||
if (
|
||||
client.format === "dsh" &&
|
||||
(registration.serverName !== "rea" || registration.transport !== "stdio")
|
||||
)
|
||||
return false;
|
||||
return "stale";
|
||||
if (
|
||||
client.format === "copilot_cli" &&
|
||||
(registration.type !== "stdio" ||
|
||||
JSON.stringify(registration.tools) !== JSON.stringify(["*"]))
|
||||
)
|
||||
return false;
|
||||
return "stale";
|
||||
if (client.format === "commandcode" && registration.transport !== "stdio")
|
||||
return false;
|
||||
if (
|
||||
command.length === 3 &&
|
||||
command[2] === "mcp" &&
|
||||
resolve(command[0] ?? "") === resolve(process.execPath) &&
|
||||
resolve(command[1] ?? "") === currentCommandPath
|
||||
)
|
||||
return true;
|
||||
return "stale";
|
||||
if (
|
||||
(["npx", "bunx"] as const).some((runner) => {
|
||||
const runnerCommand = packageRunnerRegistrationCommand(runner, platform);
|
||||
const expected = packageRunnerRegistrationCommand(runner, platform);
|
||||
return (
|
||||
command.length === runnerCommand.length &&
|
||||
command.every((argument, index) => argument === runnerCommand[index])
|
||||
command.length === expected.length &&
|
||||
command.every((argument, index) => argument === expected[index])
|
||||
);
|
||||
})
|
||||
)
|
||||
return true;
|
||||
return (
|
||||
return "aligned";
|
||||
if (
|
||||
currentCommandPath !== undefined &&
|
||||
currentCommandPath.length > 0 &&
|
||||
command.length === 3 &&
|
||||
command[2] === "mcp" &&
|
||||
isAbsolute(command[0] ?? "") &&
|
||||
isAbsolute(command[1] ?? "") &&
|
||||
resolve(command[0] ?? "") === resolve(process.execPath) &&
|
||||
resolve(command[1] ?? "") === currentCommandPath
|
||||
)
|
||||
return "aligned";
|
||||
if (
|
||||
currentCommandPath !== undefined &&
|
||||
currentCommandPath.length > 0 &&
|
||||
command.length === 2 &&
|
||||
command[1] === "mcp" &&
|
||||
isAbsolute(command[0] ?? "") &&
|
||||
resolve(command[0] ?? "") === currentCommandPath
|
||||
);
|
||||
)
|
||||
return "aligned";
|
||||
return "stale";
|
||||
};
|
||||
|
||||
const parseRegistration = (
|
||||
@@ -423,12 +507,16 @@ const remediation = (client: SetupClient): string => {
|
||||
return `Quit ${name} fully, run rea setup to refresh this registration, then start ${name} again. A running ${name} can rewrite its configuration from memory and drop the registration.`;
|
||||
};
|
||||
|
||||
const unknownEntryPointRemediation = (): string =>
|
||||
"The existing registration was retained because its executable identity cannot be verified. Run the installed REA CLI or a supported package runner, then rerun rea doctor; no registration was changed.";
|
||||
|
||||
const configuredStatus = (
|
||||
client: SetupClient,
|
||||
command: RegistrationCommand,
|
||||
state: "aligned" | "stale",
|
||||
state: "aligned" | "stale" | "unknown",
|
||||
policyRemediation?: string,
|
||||
packageReference?: string,
|
||||
advisory?: string,
|
||||
): ClientRegistrationStatus =>
|
||||
state === "aligned"
|
||||
? {
|
||||
@@ -440,6 +528,7 @@ const configuredStatus = (
|
||||
: { package_reference: packageReference }),
|
||||
state,
|
||||
remediation: null,
|
||||
...(advisory === undefined ? {} : { advisory }),
|
||||
}
|
||||
: {
|
||||
client: client.name,
|
||||
@@ -452,6 +541,35 @@ const configuredStatus = (
|
||||
remediation: policyRemediation ?? remediation(client),
|
||||
};
|
||||
|
||||
const timeoutAdvisory = (
|
||||
client: SetupClient,
|
||||
registration: z.output<typeof registrationSchema>,
|
||||
timeoutInputs: McpToolTimeoutInputs,
|
||||
): string | undefined => {
|
||||
const toolTimeout =
|
||||
client.name === "kimi"
|
||||
? registration.toolTimeoutMs
|
||||
: client.name === "codex"
|
||||
? registration.tool_timeout_sec
|
||||
: undefined;
|
||||
if (toolTimeout === undefined) return undefined;
|
||||
const providerAllowance = mcpProviderDeadlineAllowanceMs(timeoutInputs);
|
||||
const isKimi = client.name === "kimi";
|
||||
const toolTimeoutMs = isKimi ? toolTimeout : toolTimeout * 1000;
|
||||
if (toolTimeoutMs >= providerAllowance) return undefined;
|
||||
const setting = isKimi ? "toolTimeoutMs" : "tool_timeout_sec";
|
||||
const currentValue = isKimi
|
||||
? `${String(toolTimeout)} ms`
|
||||
: `${String(toolTimeout)} seconds`;
|
||||
const recommendedValue = isKimi
|
||||
? `${String(recommendedMcpToolTimeoutMs(timeoutInputs))} ms`
|
||||
: `${String(recommendedMcpToolTimeoutSeconds(timeoutInputs))} seconds`;
|
||||
const headroom = isKimi
|
||||
? `${String(MCP_STARTUP_POLICY.toolOperationHeadroomMs)} ms`
|
||||
: `${String(MCP_STARTUP_POLICY.toolOperationHeadroomMs / 1000)} seconds`;
|
||||
return `The configured ${setting} (${currentValue}) is shorter than REA's known provider allowance (${String(providerAllowance)} ms), so provider startup or an upstream request may exceed this client deadline. REA recommends ${setting} = ${recommendedValue} to cover that allowance plus ${headroom} of operation headroom; this additional headroom is a recommendation, not a guarantee for every analysis.`;
|
||||
};
|
||||
|
||||
const exactRegisteredPackageVersion = (
|
||||
packageReference: string | undefined,
|
||||
): string | undefined => {
|
||||
|
||||
@@ -82,6 +82,16 @@ type DirectAnalysisTool =
|
||||
| "trace_native_ui_action"
|
||||
| "trace_native_values";
|
||||
|
||||
// These one-shot CLI questions require native utilities, not a deep engine.
|
||||
// Runtime observation retains the ordinary session and authority checks.
|
||||
const NATIVE_INSPECTION_OPERATIONS: ReadonlySet<string> = new Set([
|
||||
"inspect_macho",
|
||||
"inspect_signature",
|
||||
"list_architectures",
|
||||
"inspect_plist",
|
||||
"demangle_swift",
|
||||
] satisfies readonly NativeToolName[]);
|
||||
|
||||
const ARTIFACT_ANALYSIS_OPERATION_SET: ReadonlySet<string> = new Set(
|
||||
ARTIFACT_ANALYSIS_OPERATIONS,
|
||||
);
|
||||
@@ -152,7 +162,9 @@ export const runProviderAnalysis = async (
|
||||
signal: operationSignal,
|
||||
...(ARTIFACT_ANALYSIS_OPERATION_SET.has(tool)
|
||||
? { createSession: dependencies.createArtifactBinarySession }
|
||||
: {}),
|
||||
: NATIVE_INSPECTION_OPERATIONS.has(tool)
|
||||
? { createSession: dependencies.createNativeBinarySession }
|
||||
: {}),
|
||||
}),
|
||||
);
|
||||
};
|
||||
@@ -236,7 +248,11 @@ const runAnalysis = async (
|
||||
} else {
|
||||
const config = dependencies.readConfiguration();
|
||||
if (!config.ok) return cliError(config.error);
|
||||
session = await dependencies.createBinarySession(config.value, logger);
|
||||
session = await (
|
||||
tool === "inspect_native_dispatch_metadata" && snapshotPath === undefined
|
||||
? dependencies.createNativeDispatchBinarySession
|
||||
: dependencies.createBinarySession
|
||||
)(config.value, logger);
|
||||
}
|
||||
return withSessionCleanup(
|
||||
session,
|
||||
|
||||
@@ -46,10 +46,13 @@ export const directAnalysisCleanupFailure = (
|
||||
...primary.data,
|
||||
code: "cleanup_incomplete",
|
||||
retryable: false,
|
||||
remediation: cleanup.remediation,
|
||||
details: {
|
||||
// Primary fields describe the original failure; the later close
|
||||
// assessment, including its resources, belongs to cleanup_error.
|
||||
resources: error.cleanupResources.slice(),
|
||||
...primary.data.details,
|
||||
cleanup: "incomplete",
|
||||
resources: error.cleanupResources.slice(),
|
||||
execution_failure:
|
||||
primary.data.details !== undefined &&
|
||||
"execution_failure" in primary.data.details
|
||||
@@ -86,6 +89,7 @@ export const managedAnalysisCleanupFailure = (
|
||||
"close_binary",
|
||||
{
|
||||
cause: error,
|
||||
remediationAction: cleanup.remediation.action,
|
||||
cleanup: { reason: cleanup.message, resources: error.cleanupResources },
|
||||
diagnostics: {
|
||||
cleanup_error: cleanup,
|
||||
|
||||
@@ -11,10 +11,19 @@ export interface DirectAnalysisDependencies {
|
||||
config: AppConfig,
|
||||
logger: Logger,
|
||||
) => BinarySession | Promise<BinarySession>;
|
||||
/** Prefer byte-backed dispatch inspection while preserving explicit selection. */
|
||||
readonly createNativeDispatchBinarySession: (
|
||||
config: AppConfig,
|
||||
logger: Logger,
|
||||
) => BinarySession | Promise<BinarySession>;
|
||||
/** Create an artifact-only session without selecting native analysis engines. */
|
||||
readonly createArtifactBinarySession: () =>
|
||||
| BinarySession
|
||||
| Promise<BinarySession>;
|
||||
/** Create a native utility session without selecting deep analysis engines. */
|
||||
readonly createNativeBinarySession: () =>
|
||||
| BinarySession
|
||||
| Promise<BinarySession>;
|
||||
readonly createManagedBinarySession: () =>
|
||||
| BinarySession
|
||||
| Promise<BinarySession>;
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { DirectAnalysisDependencies } from "./DirectAnalysisDependencies.js
|
||||
import { providerCapabilitySet } from "../contracts/toolOutputSchemaPrimitives.js";
|
||||
import { providerCapabilitySets } from "./binary/BinarySessionStatus.js";
|
||||
import { sqliteDatabaseCapability } from "./sqlite/SqliteDatabaseCapability.js";
|
||||
import { CURRENT_ENTRYPOINT_PATH } from "../process/CurrentEntrypointPath.js";
|
||||
|
||||
/** Read target-free provider status and operation availability. */
|
||||
export const runSessionStatus = async (
|
||||
@@ -31,6 +32,7 @@ export const runSessionStatus = async (
|
||||
],
|
||||
server_identity: createServerIdentity({
|
||||
startedAt: new Date().toISOString(),
|
||||
commandPath: CURRENT_ENTRYPOINT_PATH,
|
||||
}),
|
||||
};
|
||||
} finally {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { CATALOG_IDENTITY } from "../catalogIdentity.js";
|
||||
import { PRODUCT_IDENTITY } from "../identity.js";
|
||||
import { ok } from "../domain/result.js";
|
||||
import type { DoctorHost } from "./Doctor.js";
|
||||
|
||||
/** Healthy recording host for focused doctor and projection tests. */
|
||||
@@ -14,7 +15,16 @@ export const createDoctorHostFixture = (
|
||||
linuxDistribution: () => Promise.resolve(undefined),
|
||||
validTarget: (path) => Promise.resolve(path.includes("Hopper")),
|
||||
executable: (path) => Promise.resolve(path.includes("Hopper")),
|
||||
supportedLinuxHopper: () => Promise.resolve(true),
|
||||
inspectLinuxHopper: () =>
|
||||
Promise.resolve(
|
||||
ok({
|
||||
sha256: "0".repeat(64),
|
||||
version: "fixture",
|
||||
primaryFingerprint: "0".repeat(40),
|
||||
signingFingerprint: "1".repeat(40),
|
||||
source: "signed_package_cache" as const,
|
||||
}),
|
||||
),
|
||||
linuxDemoRuntimeCheck: () =>
|
||||
Promise.resolve({
|
||||
name: "hopper-demo-runtime",
|
||||
|
||||
@@ -4,6 +4,8 @@ import { SUPPORTED_NODE_VERSION_PROSE } from "../domain/runtimeVersion.js";
|
||||
import type { ClientRegistrationStatus } from "./ClientRegistrationStatus.js";
|
||||
import { createDoctorHostFixture as host } from "./Doctor.fixture.js";
|
||||
import { runDoctor } from "./Doctor.js";
|
||||
import { err } from "../domain/result.js";
|
||||
import { HopperReleaseError } from "./HopperReleaseFeed.js";
|
||||
|
||||
describe("doctor runtime check", () => {
|
||||
it("reports Node 25 as unsupported", async () => {
|
||||
@@ -330,7 +332,7 @@ describe("doctor scoped readiness", () => {
|
||||
client: "cursor",
|
||||
config_path: "/home/user/.cursor/mcp.json",
|
||||
command: ["/old/rea", "mcp"],
|
||||
state: "stale",
|
||||
state: "unknown",
|
||||
remediation: "Run rea setup to refresh this registration.",
|
||||
},
|
||||
];
|
||||
@@ -349,7 +351,11 @@ describe("doctor scoped readiness", () => {
|
||||
expect.objectContaining({ name: "registration:codex", ok: true }),
|
||||
);
|
||||
expect(scoped.informational_checks).toContainEqual(
|
||||
expect.objectContaining({ name: "registration:cursor", ok: false }),
|
||||
expect.objectContaining({
|
||||
name: "registration:cursor",
|
||||
ok: false,
|
||||
classification: "unknown",
|
||||
}),
|
||||
);
|
||||
|
||||
const staleSelected = await runDoctor(
|
||||
@@ -359,6 +365,38 @@ describe("doctor scoped readiness", () => {
|
||||
);
|
||||
expect(staleSelected.healthy).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps a caller-selected short timeout healthy and reports its advisory as informational", async () => {
|
||||
const result = await runDoctor(
|
||||
undefined,
|
||||
host({
|
||||
clientRegistrations: () =>
|
||||
Promise.resolve([
|
||||
{
|
||||
client: "kimi",
|
||||
config_path: "/home/user/.kimi-code/mcp.json",
|
||||
command: ["/current/rea", "mcp"],
|
||||
state: "aligned",
|
||||
remediation: null,
|
||||
advisory: "The configured toolTimeoutMs is shorter than startup.",
|
||||
},
|
||||
]),
|
||||
}),
|
||||
{ clients: ["kimi"] },
|
||||
);
|
||||
|
||||
expect(result.healthy).toBe(true);
|
||||
expect(result.scope_checks).toContainEqual(
|
||||
expect.objectContaining({ name: "registration:kimi", ok: true }),
|
||||
);
|
||||
expect(result.informational_checks).toContainEqual(
|
||||
expect.objectContaining({
|
||||
name: "registration_timeout:kimi",
|
||||
ok: true,
|
||||
detail: "The configured toolTimeoutMs is shorter than startup.",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Grok Bot readiness", () => {
|
||||
@@ -581,16 +619,46 @@ describe("doctor Linux and Hopper discovery", () => {
|
||||
hopperSetupSupported: true,
|
||||
}),
|
||||
executable: (candidate) => Promise.resolve(candidate === path),
|
||||
supportedLinuxHopper: () => Promise.resolve(false),
|
||||
inspectLinuxHopper: () =>
|
||||
Promise.resolve(
|
||||
err(
|
||||
new HopperReleaseError(
|
||||
"unsupported_hopper_build",
|
||||
"Unverified fixture build",
|
||||
),
|
||||
),
|
||||
),
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
result.checks.find(({ name }) => name === "hopper-version")?.remediation,
|
||||
).toContain("Unset or update HOPPER_LAUNCHER_PATH");
|
||||
).toContain("Check HOPPER_LAUNCHER_PATH");
|
||||
});
|
||||
});
|
||||
|
||||
describe("doctor installed Hopper discovery", () => {
|
||||
it("checks the canonical user install after the system launcher", async () => {
|
||||
const userPath =
|
||||
"/fixture/home/Applications/Hopper Disassembler.app/Contents/MacOS/hopper";
|
||||
const checked: string[] = [];
|
||||
const result = await runDoctor(
|
||||
undefined,
|
||||
host({
|
||||
homeDirectory: "/fixture/home",
|
||||
executable: (candidate) => {
|
||||
checked.push(candidate);
|
||||
return Promise.resolve(candidate === userPath);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.hopperPath).toBe(userPath);
|
||||
expect(checked.slice(0, 2)).toEqual([
|
||||
"/Applications/Hopper Disassembler.app/Contents/MacOS/hopper",
|
||||
userPath,
|
||||
]);
|
||||
});
|
||||
|
||||
it("detects a Homebrew cask installed outside /Applications", async () => {
|
||||
const path =
|
||||
"/opt/homebrew/Caskroom/hopper/Hopper Disassembler.app/Contents/MacOS/hopper";
|
||||
|
||||
+78
-20
@@ -1,6 +1,7 @@
|
||||
import { constants } from "node:fs";
|
||||
import { access, readdir, stat } from "node:fs/promises";
|
||||
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
|
||||
import type { FileCloseResourceOwner } from "./AnalysisProvider.js";
|
||||
import { homeDirectoryFromEnvironment } from "../config/homeDirectory.js";
|
||||
import { join } from "node:path";
|
||||
|
||||
@@ -15,11 +16,18 @@ import {
|
||||
} from "../domain/runtimeVersion.js";
|
||||
import { installedCaskAppdir, probeHomebrew } from "./homebrew.js";
|
||||
import {
|
||||
linuxHopperBinarySupported,
|
||||
macHopperApplicationRoots,
|
||||
macHopperRenamedLauncherCandidates,
|
||||
} from "./MacHopperPaths.js";
|
||||
import {
|
||||
inspectLinuxHopperLauncher,
|
||||
linuxSharedLibrariesAvailable,
|
||||
readLinuxDistribution,
|
||||
type LinuxDistribution,
|
||||
} from "./LinuxHopper.js";
|
||||
import type { Result } from "../domain/result.js";
|
||||
import type { HopperReleaseError } from "./HopperReleaseFeed.js";
|
||||
import type { VerifiedLinuxHopperLauncher } from "./LinuxHopperPublisher.js";
|
||||
import {
|
||||
readInstalledSkillIdentity,
|
||||
type InstalledSkillIdentity,
|
||||
@@ -32,6 +40,7 @@ import {
|
||||
type ClientRegistrationStatus,
|
||||
type UnhealthyClientRegistrationStatus,
|
||||
} from "./ClientRegistrationStatus.js";
|
||||
import type { ProviderRequestBudgetResolver } from "./ClientRegistrationEnvironment.js";
|
||||
import {
|
||||
collectDoctorDiagnostics,
|
||||
doctorHealthy,
|
||||
@@ -53,6 +62,7 @@ export interface DoctorCheck {
|
||||
| "unsupported_host"
|
||||
| "unsupported_target"
|
||||
| "missing_analysis_engine"
|
||||
| "unknown"
|
||||
| "config_drift";
|
||||
readonly detail?: string;
|
||||
readonly details?: Readonly<Record<string, JsonValue>>;
|
||||
@@ -95,16 +105,21 @@ export interface DoctorHost {
|
||||
/** Route-specific target admission details, when supplied by the host. */
|
||||
inspectTarget?(path: string): Promise<DoctorCheck>;
|
||||
executable(path: string): Promise<boolean>;
|
||||
supportedLinuxHopper(path: string): Promise<boolean>;
|
||||
inspectLinuxHopper(
|
||||
path: string,
|
||||
): Promise<Result<VerifiedLinuxHopperLauncher, HopperReleaseError>>;
|
||||
linuxDemoRuntimeCheck(): Promise<DoctorCheck>;
|
||||
brewHopperPath(): Promise<string | undefined>;
|
||||
manualHopperPaths(): Promise<readonly string[]>;
|
||||
providerInspections?(): Promise<readonly DoctorProviderInspection[]>;
|
||||
/** Resolve a parsed upstream request budget from the effective client environment. */
|
||||
resolveProviderRequestBudget?: ProviderRequestBudgetResolver;
|
||||
/** Validated provider settings preserved by setup, independent of readiness. */
|
||||
providerRegistrationEnvironment?(): Readonly<Record<string, string>>;
|
||||
installationPaths?(): Promise<readonly string[]>;
|
||||
/** Undefined checks installed skill roots; explicit selections stay scoped. */
|
||||
installedSkillIdentity?(
|
||||
clientIds: readonly string[],
|
||||
clientIds: readonly string[] | undefined,
|
||||
): Promise<InstalledSkillIdentity | undefined>;
|
||||
close?(): Promise<string | undefined>;
|
||||
clientRegistrations?(
|
||||
@@ -216,7 +231,11 @@ const inspectDoctor = async (
|
||||
providerInspections,
|
||||
} = await collectDoctorDiagnostics(target, host);
|
||||
const identity = await collectDoctorIdentity(host, scope);
|
||||
const checks = [...diagnosticChecks, ...identity.checks];
|
||||
const checks = [
|
||||
...diagnosticChecks,
|
||||
...identity.checks,
|
||||
...registrationAdvisoryChecks(identity.value.registrations),
|
||||
];
|
||||
const providers = {
|
||||
hopperAvailable:
|
||||
hopperPath !== undefined &&
|
||||
@@ -248,6 +267,22 @@ const inspectDoctor = async (
|
||||
};
|
||||
};
|
||||
|
||||
const registrationAdvisoryChecks = (
|
||||
registrations: readonly ClientRegistrationStatus[],
|
||||
): readonly DoctorCheck[] =>
|
||||
registrations.flatMap(({ client, advisory }) =>
|
||||
advisory === undefined
|
||||
? []
|
||||
: [
|
||||
{
|
||||
name: `registration_timeout:${client}`,
|
||||
ok: true,
|
||||
classification: "healthy" as const,
|
||||
detail: advisory,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
const closeDoctorHost = async (
|
||||
host: DoctorHost,
|
||||
): Promise<string | undefined> => {
|
||||
@@ -289,9 +324,7 @@ const collectDoctorIdentity = async (
|
||||
const registrations = (await host.clientRegistrations?.(scope.clients)) ?? [];
|
||||
const skillClientIds =
|
||||
scope.skill_clients ??
|
||||
(scope.mode === "audit-wide"
|
||||
? registrations.map(({ client }) => client)
|
||||
: scope.clients);
|
||||
(scope.clients.length > 0 ? scope.clients : undefined);
|
||||
const installedSkillIdentity =
|
||||
await host.installedSkillIdentity?.(skillClientIds);
|
||||
const skillAligned = skillIdentityAligned(installedSkillIdentity);
|
||||
@@ -387,7 +420,7 @@ const registrationCheck = (
|
||||
): DoctorCheck => ({
|
||||
name: `registration:${registration.client}`,
|
||||
ok: false,
|
||||
classification: "config_drift",
|
||||
classification: registration.state === "unknown" ? "unknown" : "config_drift",
|
||||
detail: registration.config_path,
|
||||
remediation: registration.remediation,
|
||||
});
|
||||
@@ -403,12 +436,13 @@ export interface SystemDoctorHostOptions {
|
||||
readonly architecture?: NodeJS.Architecture;
|
||||
readonly environment?: NodeJS.ProcessEnv;
|
||||
readonly execFileOutput?: typeof execFileOutput;
|
||||
readonly providerInspections?: () => Promise<
|
||||
readonly DoctorProviderInspection[]
|
||||
>;
|
||||
readonly providerInspections?: (
|
||||
fileCloseResources: FileCloseResourceOwner,
|
||||
) => Promise<readonly DoctorProviderInspection[]>;
|
||||
readonly providerRegistrationEnvironment?: () => Readonly<
|
||||
Record<string, string>
|
||||
>;
|
||||
readonly resolveProviderRequestBudget?: ProviderRequestBudgetResolver;
|
||||
readonly linuxDemoRuntimeCheck?: () => Promise<DoctorCheck>;
|
||||
}
|
||||
|
||||
@@ -423,6 +457,15 @@ export const systemDoctorHost = (
|
||||
const hostExecFileOutput = options.execFileOutput ?? execFileOutput;
|
||||
const homeDirectory = homeDirectoryFromEnvironment(environment, platform);
|
||||
const commandEnvironment = { env: environment };
|
||||
const fileCloseResources: FileCloseResourceOwner = {
|
||||
retain: (owner, resource) =>
|
||||
resources.retain({
|
||||
kind: "lease",
|
||||
resource,
|
||||
lease: { release: () => owner.close() },
|
||||
}),
|
||||
run: (operation) => resources.run(operation),
|
||||
};
|
||||
return {
|
||||
platform,
|
||||
architecture,
|
||||
@@ -486,7 +529,7 @@ export const systemDoctorHost = (
|
||||
),
|
||||
linuxDemoRuntimeCheck:
|
||||
options.linuxDemoRuntimeCheck ?? uncomposedLinuxDemoRuntimeCheck,
|
||||
supportedLinuxHopper: linuxHopperBinarySupported,
|
||||
inspectLinuxHopper: (path) => inspectLinuxHopperLauncher(path, environment),
|
||||
async brewHopperPath() {
|
||||
return probeHomebrew(async (command) => {
|
||||
let caskroom: string;
|
||||
@@ -518,13 +561,19 @@ export const systemDoctorHost = (
|
||||
manualHopperPaths: () => manualHopperPaths(homeDirectory),
|
||||
...(options.providerInspections === undefined
|
||||
? {}
|
||||
: { providerInspections: options.providerInspections }),
|
||||
: {
|
||||
providerInspections: () =>
|
||||
options.providerInspections!(fileCloseResources),
|
||||
}),
|
||||
...(options.providerRegistrationEnvironment === undefined
|
||||
? {}
|
||||
: {
|
||||
providerRegistrationEnvironment:
|
||||
options.providerRegistrationEnvironment,
|
||||
}),
|
||||
...(options.resolveProviderRequestBudget === undefined
|
||||
? {}
|
||||
: { resolveProviderRequestBudget: options.resolveProviderRequestBudget }),
|
||||
async ilspyCmdVersion(path) {
|
||||
try {
|
||||
await access(path, constants.X_OK);
|
||||
@@ -578,6 +627,12 @@ export const systemDoctorHost = (
|
||||
environment,
|
||||
resources,
|
||||
...(selectedClientIds === undefined ? {} : { selectedClientIds }),
|
||||
...(options.resolveProviderRequestBudget === undefined
|
||||
? {}
|
||||
: {
|
||||
resolveProviderRequestBudget:
|
||||
options.resolveProviderRequestBudget,
|
||||
}),
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -624,19 +679,22 @@ const uncomposedLinuxDemoRuntimeCheck = (): Promise<DoctorCheck> =>
|
||||
});
|
||||
|
||||
const manualHopperPaths = async (home: string): Promise<readonly string[]> => {
|
||||
const paths: string[] = [];
|
||||
for (const root of ["/Applications", join(home, "Applications")]) {
|
||||
const roots = macHopperApplicationRoots(home);
|
||||
const bundleNamesByRoot = new Map<string, readonly string[]>();
|
||||
for (const root of roots) {
|
||||
try {
|
||||
for (const entry of await readdir(root, { withFileTypes: true })) {
|
||||
if (entry.isDirectory() && /^Hopper.*\.app$/i.test(entry.name))
|
||||
paths.push(join(root, entry.name, "Contents/MacOS/hopper"));
|
||||
}
|
||||
bundleNamesByRoot.set(
|
||||
root,
|
||||
(await readdir(root, { withFileTypes: true }))
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map(({ name }) => name),
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
// A missing or unreadable optional application directory is not fatal.
|
||||
void cause;
|
||||
}
|
||||
}
|
||||
return paths;
|
||||
return macHopperRenamedLauncherCandidates(home, bundleNamesByRoot);
|
||||
};
|
||||
|
||||
const uniqueLines = (value: string): string[] => [
|
||||
|
||||
@@ -7,15 +7,16 @@ import {
|
||||
systemLinuxHopperLauncherCandidates,
|
||||
supportsReaHost,
|
||||
} from "./LinuxHopper.js";
|
||||
import {
|
||||
MAC_HOPPER_SYSTEM_LAUNCHER,
|
||||
macHopperCanonicalLauncherCandidates,
|
||||
} from "./MacHopperPaths.js";
|
||||
import type {
|
||||
DoctorCheck,
|
||||
DoctorHost,
|
||||
DoctorProviderInspection,
|
||||
} from "./Doctor.js";
|
||||
|
||||
const DEFAULT_HOPPER =
|
||||
"/Applications/Hopper Disassembler.app/Contents/MacOS/hopper";
|
||||
|
||||
/** Ordered environment diagnostics consumed by the Doctor report assembler. */
|
||||
export interface DoctorDiagnostics {
|
||||
readonly checks: readonly DoctorCheck[];
|
||||
@@ -150,10 +151,17 @@ const findHopper = async (host: DoctorHost): Promise<string | undefined> => {
|
||||
const candidates =
|
||||
host.configuredHopperPath === undefined
|
||||
? [
|
||||
DEFAULT_HOPPER,
|
||||
...(host.platform === "darwin"
|
||||
? macHopperCanonicalLauncherCandidates(host.homeDirectory)
|
||||
: [MAC_HOPPER_SYSTEM_LAUNCHER]),
|
||||
...(host.platform === "linux"
|
||||
? linuxHopperLauncherCandidates(host.homeDirectory)
|
||||
: [...systemLinuxHopperLauncherCandidates]),
|
||||
...(host.platform === "darwin"
|
||||
? []
|
||||
: macHopperCanonicalLauncherCandidates(host.homeDirectory).slice(
|
||||
1,
|
||||
)),
|
||||
...(await host.manualHopperPaths()),
|
||||
]
|
||||
: [host.configuredHopperPath];
|
||||
@@ -201,17 +209,34 @@ const optionalHopperVersionCheck = async (
|
||||
hopperPath: string | undefined,
|
||||
): Promise<DoctorCheck | undefined> => {
|
||||
if (host.platform !== "linux" || hopperPath === undefined) return undefined;
|
||||
return check(
|
||||
"hopper-version",
|
||||
await host.supportedLinuxHopper(hopperPath),
|
||||
hopperPath,
|
||||
{
|
||||
remediation: unsupportedHopperRemediation(
|
||||
host.configuredHopperPath === hopperPath,
|
||||
),
|
||||
classification: "config_drift",
|
||||
},
|
||||
);
|
||||
const inspected = await host.inspectLinuxHopper(hopperPath);
|
||||
const buildCheck = check("hopper-version", inspected.ok, hopperPath, {
|
||||
remediation: unsupportedHopperRemediation(
|
||||
host.configuredHopperPath === hopperPath,
|
||||
),
|
||||
classification:
|
||||
!inspected.ok &&
|
||||
[
|
||||
"release_metadata",
|
||||
"download",
|
||||
"verification_unavailable",
|
||||
"cancelled",
|
||||
"cleanup_failed",
|
||||
].includes(inspected.error.reason)
|
||||
? "unknown"
|
||||
: "config_drift",
|
||||
});
|
||||
return {
|
||||
...buildCheck,
|
||||
details: inspected.ok
|
||||
? {
|
||||
reported_release_version: inspected.value.version,
|
||||
launcher_sha256: inspected.value.sha256,
|
||||
primary_fingerprint: inspected.value.primaryFingerprint,
|
||||
source: inspected.value.source,
|
||||
}
|
||||
: { reason: inspected.error.reason, message: inspected.error.message },
|
||||
};
|
||||
};
|
||||
|
||||
const optionalHopperDemoCheck = async (
|
||||
@@ -289,8 +314,8 @@ const providerDoctorChecks = (
|
||||
|
||||
const unsupportedHopperRemediation = (configured: boolean): string =>
|
||||
configured
|
||||
? "Unset or update HOPPER_LAUNCHER_PATH, install the Hopper build supported by this REA release, or update REA."
|
||||
: "Install the Hopper build supported by this REA release, or update REA for a newer Hopper build.";
|
||||
? "Check HOPPER_LAUNCHER_PATH and its publisher verification evidence. Run rea setup to install and retain an authenticated official Hopper package for offline use."
|
||||
: "Run rea setup to install and retain an authenticated official Hopper package for offline use.";
|
||||
|
||||
const parseMajor = (version: string | undefined): number =>
|
||||
Number.parseInt(version?.split(".")[0] ?? "0", 10);
|
||||
|
||||
@@ -1,2 +1,8 @@
|
||||
/** Deep-analysis providers whose readiness can be selected explicitly by doctor. */
|
||||
export const DOCTOR_PROVIDER_IDS = ["hopper", "ghidra", "ida", "jadx"] as const;
|
||||
export const DOCTOR_PROVIDER_IDS = [
|
||||
"hopper",
|
||||
"ghidra",
|
||||
"ida",
|
||||
"jadx",
|
||||
"kuna",
|
||||
] as const;
|
||||
@@ -174,7 +174,7 @@ const registrationCheck = (
|
||||
): DoctorCheck => ({
|
||||
name: `registration:${status.client}`,
|
||||
ok: false,
|
||||
classification: "config_drift",
|
||||
classification: status.state === "unknown" ? "unknown" : "config_drift",
|
||||
detail: status.config_path,
|
||||
remediation: status.remediation,
|
||||
});
|
||||
|
||||
@@ -242,7 +242,7 @@ export class DshCordisDocument {
|
||||
const environment = { ...literals, ...overrides };
|
||||
const validated =
|
||||
validation === "fragment"
|
||||
? parseEnvironmentValues(environment)
|
||||
? parseEnvironmentValues(environment, unresolvedSettings)
|
||||
: parseEnvironment(environment, unresolvedSettings);
|
||||
return validated.ok ? ok(environment) : validated;
|
||||
}
|
||||
|
||||
@@ -47,6 +47,49 @@ import { resolveProcedureAddress } from "./ProcedureAddressResolution.js";
|
||||
import { traceLiteralFeature } from "./EnhancedLiteralTracing.js";
|
||||
import { projectNativeApiInspection } from "./native/NativeApiInspection.js";
|
||||
|
||||
type BatchProcedureIdentity =
|
||||
| {
|
||||
readonly status: "resolved";
|
||||
readonly address: string;
|
||||
readonly name: string | null;
|
||||
readonly name_error?: ReturnType<typeof projectAnalysisError>;
|
||||
}
|
||||
| {
|
||||
readonly status: "unknown";
|
||||
readonly error: ReturnType<typeof projectAnalysisError>;
|
||||
};
|
||||
|
||||
type BatchResolution = {
|
||||
readonly address: string;
|
||||
readonly result: Result<string, AnalysisError>;
|
||||
};
|
||||
|
||||
type BatchReadyItem = {
|
||||
readonly address: string;
|
||||
readonly procedure: BatchProcedureIdentity;
|
||||
readonly providerProcedure: string;
|
||||
};
|
||||
|
||||
type BatchDecompileItem = {
|
||||
readonly address: string;
|
||||
readonly procedure: BatchProcedureIdentity;
|
||||
readonly status: "ok" | "error";
|
||||
readonly pseudocode?: string;
|
||||
readonly error?: ReturnType<typeof projectAnalysisError>;
|
||||
};
|
||||
|
||||
const isAnalysisCancellation = (
|
||||
error: AnalysisError,
|
||||
): error is AnalysisCancelledError => error._tag === "AnalysisCancelledError";
|
||||
|
||||
const firstAnalysisCancellation = <Value>(
|
||||
results: readonly Result<Value, AnalysisError>[],
|
||||
): AnalysisCancelledError | undefined => {
|
||||
for (const result of results)
|
||||
if (!result.ok && isAnalysisCancellation(result.error)) return result.error;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Composes direct provider operations into bounded reverse-engineering tools.
|
||||
* Direct callers use `execute`; adapters that own validation use
|
||||
@@ -259,52 +302,34 @@ export class EnhancedTools {
|
||||
addresses: readonly string[],
|
||||
signal?: AbortSignal,
|
||||
): EnhancedResult {
|
||||
const items = await Promise.all(
|
||||
const resolutions = await Promise.all(
|
||||
addresses.map(async (address) => {
|
||||
const resolved = await resolveProcedureAddress(
|
||||
const result = await resolveProcedureAddress(
|
||||
this.#call.bind(this),
|
||||
address,
|
||||
signal,
|
||||
);
|
||||
const procedure = resolved.ok
|
||||
? await this.#batchProcedureIdentity(resolved.value, signal)
|
||||
: {
|
||||
status: "unknown" as const,
|
||||
error: projectAnalysisError(resolved.error),
|
||||
};
|
||||
const result = await this.#call(
|
||||
"procedure_pseudo_code",
|
||||
{ procedure: resolved.ok ? resolved.value : address },
|
||||
signal,
|
||||
resolved.ok ? "provider" : undefined,
|
||||
);
|
||||
if (!result.ok)
|
||||
return {
|
||||
address,
|
||||
procedure,
|
||||
status: "error" as const,
|
||||
error: projectAnalysisError(result.error),
|
||||
};
|
||||
if (typeof result.value !== "string" || result.value.length === 0)
|
||||
return {
|
||||
address,
|
||||
procedure,
|
||||
status: "error" as const,
|
||||
error: projectAnalysisError(
|
||||
new AnalysisOutputError(
|
||||
"procedure_pseudo_code",
|
||||
"provider returned empty pseudocode",
|
||||
),
|
||||
),
|
||||
};
|
||||
return {
|
||||
address,
|
||||
procedure,
|
||||
status: "ok" as const,
|
||||
pseudocode: result.value,
|
||||
};
|
||||
return { address, result };
|
||||
}),
|
||||
);
|
||||
const resolutionCancellation = firstAnalysisCancellation(
|
||||
resolutions.map(({ result }) => result),
|
||||
);
|
||||
if (resolutionCancellation !== undefined)
|
||||
return err(resolutionCancellation);
|
||||
|
||||
const identities = await this.#batchIdentities(resolutions, signal);
|
||||
const ready: BatchReadyItem[] = [];
|
||||
for (const identity of identities) {
|
||||
if (!identity.ok) return err(identity.error);
|
||||
ready.push(identity.value);
|
||||
}
|
||||
const decompilations = await this.#batchPseudocode(ready, signal);
|
||||
const items: BatchDecompileItem[] = [];
|
||||
for (const decompilation of decompilations) {
|
||||
if (!decompilation.ok) return err(decompilation.error);
|
||||
items.push(decompilation.value);
|
||||
}
|
||||
const succeeded = items.filter(({ status }) => status === "ok").length;
|
||||
return ok({
|
||||
items,
|
||||
@@ -314,12 +339,91 @@ export class EnhancedTools {
|
||||
});
|
||||
}
|
||||
|
||||
async #batchProcedureIdentity(address: string, signal?: AbortSignal) {
|
||||
async #batchIdentities(
|
||||
resolutions: readonly BatchResolution[],
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<BatchReadyItem, AnalysisCancelledError>[]> {
|
||||
return Promise.all(
|
||||
resolutions.map(async ({ address, result }) => {
|
||||
if (!result.ok)
|
||||
return ok({
|
||||
address,
|
||||
providerProcedure: address,
|
||||
procedure: {
|
||||
status: "unknown" as const,
|
||||
error: projectAnalysisError(result.error),
|
||||
},
|
||||
});
|
||||
const identity = await this.#batchProcedureIdentity(
|
||||
result.value,
|
||||
signal,
|
||||
);
|
||||
return identity.ok
|
||||
? ok({
|
||||
address,
|
||||
providerProcedure: result.value,
|
||||
procedure: identity.value,
|
||||
})
|
||||
: err(identity.error);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async #batchPseudocode(
|
||||
ready: readonly BatchReadyItem[],
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<BatchDecompileItem, AnalysisCancelledError>[]> {
|
||||
return Promise.all(
|
||||
ready.map(async ({ address, procedure, providerProcedure }) => {
|
||||
const result = await this.#call(
|
||||
"procedure_pseudo_code",
|
||||
{ procedure: providerProcedure },
|
||||
signal,
|
||||
procedure.status === "resolved" ? "provider" : undefined,
|
||||
);
|
||||
if (!result.ok && isAnalysisCancellation(result.error))
|
||||
return err(result.error);
|
||||
const item: BatchDecompileItem = !result.ok
|
||||
? {
|
||||
address,
|
||||
procedure,
|
||||
status: "error",
|
||||
error: projectAnalysisError(result.error),
|
||||
}
|
||||
: typeof result.value !== "string" || result.value.length === 0
|
||||
? {
|
||||
address,
|
||||
procedure,
|
||||
status: "error",
|
||||
error: projectAnalysisError(
|
||||
new AnalysisOutputError(
|
||||
"procedure_pseudo_code",
|
||||
"provider returned empty pseudocode",
|
||||
),
|
||||
),
|
||||
}
|
||||
: {
|
||||
address,
|
||||
procedure,
|
||||
status: "ok",
|
||||
pseudocode: result.value,
|
||||
};
|
||||
return ok(item);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async #batchProcedureIdentity(
|
||||
address: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<BatchProcedureIdentity, AnalysisCancelledError>> {
|
||||
const observed = await this.#call("address_name", { address }, signal);
|
||||
if (!observed.ok && isAnalysisCancellation(observed.error))
|
||||
return err(observed.error);
|
||||
const named =
|
||||
observed.ok &&
|
||||
(typeof observed.value === "string" || observed.value === null);
|
||||
return {
|
||||
return ok({
|
||||
status: "resolved" as const,
|
||||
address,
|
||||
name: named ? observed.value : null,
|
||||
@@ -335,7 +439,7 @@ export class EnhancedTools {
|
||||
: observed.error,
|
||||
),
|
||||
}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async #callGraph(
|
||||
@@ -385,6 +489,7 @@ export class EnhancedTools {
|
||||
"provider",
|
||||
);
|
||||
if (!result.ok) {
|
||||
if (isAnalysisCancellation(result.error)) return result;
|
||||
graph[level].push({
|
||||
address: current.address,
|
||||
status: "error",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { realpath } from "node:fs/promises";
|
||||
import { realpath, stat } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { stripComments } from "jsonc-parser";
|
||||
import { z } from "zod";
|
||||
@@ -149,12 +149,27 @@ export const readClientPolicyBlock = async (
|
||||
".gemini",
|
||||
"settings.json",
|
||||
);
|
||||
const document = await readConfigurationFile(path, "json", resources);
|
||||
if (!document.ok) return document;
|
||||
if (document.value !== undefined) {
|
||||
const trusted = await workspaceTrusted(settings, sources, resources);
|
||||
if (!trusted.ok) return trusted;
|
||||
if (trusted.value) {
|
||||
// Only ENOENT proves absence; preserve other metadata failures as unreadable.
|
||||
const present = await stat(path).then(
|
||||
() => ok(true),
|
||||
(cause: unknown) =>
|
||||
cause instanceof Error && "code" in cause && cause.code === "ENOENT"
|
||||
? ok(false)
|
||||
: err({
|
||||
kind: "unreadable" as const,
|
||||
path,
|
||||
detail: `Configuration ${path} could not be read: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
}),
|
||||
);
|
||||
if (!present.ok) return present;
|
||||
const includeWorkspace = present.value
|
||||
? await workspaceTrusted(settings, sources, resources)
|
||||
: ok(false);
|
||||
if (!includeWorkspace.ok) return includeWorkspace;
|
||||
if (includeWorkspace.value) {
|
||||
const document = await readConfigurationFile(path, "json", resources);
|
||||
if (!document.ok) return document;
|
||||
if (document.value !== undefined) {
|
||||
const source = { path, document: document.value };
|
||||
const systemIndex = sources.findIndex(
|
||||
(item) => item.path === settings.systemPath,
|
||||
|
||||
@@ -8,6 +8,34 @@ import {
|
||||
} from "./HermesConfigurationYaml.js";
|
||||
|
||||
describe("Hermes configuration YAML", () => {
|
||||
it("retains a quoted sequence element when replacing its registration", () => {
|
||||
const source =
|
||||
"mcp_servers:\n rea:\n command: old\n args:\n - 'mcp'\n";
|
||||
expect(
|
||||
editHermesConfigurationYaml(source, ["mcp_servers", "rea"], {
|
||||
command: "rea",
|
||||
args: ["mcp"],
|
||||
}),
|
||||
).toBe(source.replace("command: old", "command: rea"));
|
||||
});
|
||||
|
||||
it("retains explicit string tags on unchanged keys and values", () => {
|
||||
const source =
|
||||
"%YAML 1.2\n---\nmcp_servers:\n rea:\n command: old\n env:\n !!str ON: !!str off\n";
|
||||
const written = editHermesConfigurationYaml(
|
||||
source,
|
||||
["mcp_servers", "rea"],
|
||||
{
|
||||
command: "rea",
|
||||
env: { ON: "off" },
|
||||
},
|
||||
);
|
||||
expect(written).toBe(source.replace("command: old", 'command: "rea"'));
|
||||
expect(
|
||||
parse(written.slice("%YAML 1.2\n---\n".length), { version: "1.1" }),
|
||||
).toMatchObject({ mcp_servers: { rea: { env: { ON: "off" } } } });
|
||||
});
|
||||
|
||||
it("writes an edited number from its new value", () => {
|
||||
expect(
|
||||
editHermesConfigurationYaml("mode: 0600\nmask: 0x1F\n", ["mode"], 420),
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import {
|
||||
isMap,
|
||||
isNode,
|
||||
isScalar,
|
||||
isSeq,
|
||||
parseDocument,
|
||||
Document,
|
||||
Schema,
|
||||
type ParsedNode,
|
||||
type Node,
|
||||
type ScalarTag,
|
||||
type Tags,
|
||||
} from "yaml";
|
||||
@@ -322,6 +326,36 @@ export const parseHermesConfigurationYaml = (text: string): unknown => {
|
||||
});
|
||||
};
|
||||
|
||||
/** Keep unchanged scalar nodes, including their quoting and explicit tags. */
|
||||
const retainHermesScalarSource = (previous: unknown, next: Node): Node => {
|
||||
if (isScalar(previous) && isScalar(next) && previous.value === next.value)
|
||||
return previous;
|
||||
if (isMap(previous) && isMap(next)) {
|
||||
const entries = new Map(
|
||||
previous.items.flatMap((pair) =>
|
||||
isScalar(pair.key) && typeof pair.key.value === "string"
|
||||
? [[pair.key.value, pair] as const]
|
||||
: [],
|
||||
),
|
||||
);
|
||||
for (const pair of next.items) {
|
||||
const key = isScalar(pair.key) ? pair.key.value : undefined;
|
||||
const prior = typeof key === "string" ? entries.get(key) : undefined;
|
||||
if (prior === undefined) continue;
|
||||
pair.key = prior.key;
|
||||
if (isNode(pair.value))
|
||||
pair.value = retainHermesScalarSource(prior.value, pair.value);
|
||||
}
|
||||
} else if (isSeq(previous) && isSeq(next)) {
|
||||
next.items = next.items.map((item, index) =>
|
||||
isNode(item)
|
||||
? retainHermesScalarSource(previous.items[index], item)
|
||||
: item,
|
||||
);
|
||||
}
|
||||
return next;
|
||||
};
|
||||
|
||||
/** Set or delete one value in a Hermes `config.yaml`, keeping the rest as written. */
|
||||
export const editHermesConfigurationYaml = (
|
||||
text: string,
|
||||
@@ -348,8 +382,26 @@ export const editHermesConfigurationYaml = (
|
||||
if (!document.hasIn(parent))
|
||||
document.setIn(parent, document.createNode({}));
|
||||
}
|
||||
document.setIn(path, value);
|
||||
const previous = document.getIn(path, true);
|
||||
// Let YAML's scalar update preserve numeric formats for a direct edit.
|
||||
// Replacing containers needs explicit retention of unchanged child nodes.
|
||||
document.setIn(
|
||||
path,
|
||||
isMap(previous) || isSeq(previous)
|
||||
? retainHermesScalarSource(previous, document.createNode(value))
|
||||
: value,
|
||||
);
|
||||
}
|
||||
// Editing REA must not reflow unrelated user prompts or other YAML scalars.
|
||||
return document.toString({ lineWidth: 0 });
|
||||
return document.toString({
|
||||
lineWidth: 0,
|
||||
// Hermes tries the YAML 1.1 C loader before its directive-aware pure
|
||||
// fallback. Quote new strings to avoid introducing YAML 1.1 coercions.
|
||||
...(document.directives?.yaml.version === "1.2"
|
||||
? {
|
||||
defaultStringType: "QUOTE_DOUBLE" as const,
|
||||
defaultKeyType: "QUOTE_DOUBLE" as const,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
};
|
||||
@@ -4,9 +4,23 @@ export const HOPPER_DOWNLOAD_BYTES = 100_000_000;
|
||||
/** Bound observed response bytes before retaining them, including chunked bodies. */
|
||||
export const downloadHopperPackage = async (
|
||||
url: string,
|
||||
options: { readonly signal?: AbortSignal } = {},
|
||||
options: {
|
||||
readonly signal?: AbortSignal;
|
||||
readonly maxBytes?: number;
|
||||
readonly timeoutMs?: number;
|
||||
} = {},
|
||||
): Promise<{ readonly ok: boolean; readonly bytes: Uint8Array }> => {
|
||||
const deadline = AbortSignal.timeout(120_000);
|
||||
const timeoutMs = options.timeoutMs ?? 120_000;
|
||||
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 120_000)
|
||||
throw new RangeError("Invalid Hopper response time budget");
|
||||
const deadline = AbortSignal.timeout(timeoutMs);
|
||||
const maxBytes = options.maxBytes ?? HOPPER_DOWNLOAD_BYTES;
|
||||
if (
|
||||
!Number.isSafeInteger(maxBytes) ||
|
||||
maxBytes < 1 ||
|
||||
maxBytes > HOPPER_DOWNLOAD_BYTES
|
||||
)
|
||||
throw new RangeError("Invalid Hopper response byte budget");
|
||||
const response = await fetch(url, {
|
||||
headers: { accept: "application/json", "user-agent": "rea-installer" },
|
||||
signal:
|
||||
@@ -17,7 +31,7 @@ export const downloadHopperPackage = async (
|
||||
const refused = { ok: false, bytes: new Uint8Array() };
|
||||
if (
|
||||
!response.ok ||
|
||||
Number(response.headers.get("content-length")) > HOPPER_DOWNLOAD_BYTES
|
||||
Number(response.headers.get("content-length")) > maxBytes
|
||||
) {
|
||||
await response.body?.cancel();
|
||||
return refused;
|
||||
@@ -30,7 +44,7 @@ export const downloadHopperPackage = async (
|
||||
while (true) {
|
||||
const chunk = await reader.read();
|
||||
if (chunk.done) return { ok: true, bytes: Buffer.concat(chunks, bytes) };
|
||||
if (chunk.value.byteLength > HOPPER_DOWNLOAD_BYTES - bytes) {
|
||||
if (chunk.value.byteLength > maxBytes - bytes) {
|
||||
await reader.cancel();
|
||||
return refused;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { parseLinuxHopperReleases } from "./HopperReleaseFeed.js";
|
||||
|
||||
const release = {
|
||||
version: "99.2.1",
|
||||
filename:
|
||||
"https://www.hopperapp.com:443/downloader/public/Hopper-99.2.1-Linux-demo.deb",
|
||||
file_length: "123",
|
||||
file_hash: "a".repeat(40),
|
||||
main_exe_hash: "b".repeat(64),
|
||||
signature: "AQ==",
|
||||
public: "1",
|
||||
beta: "0",
|
||||
published_at: "preserved vendor field",
|
||||
};
|
||||
|
||||
describe("Linux Hopper release feed boundary", () => {
|
||||
it("preserves future release identity and partial platform facts", () => {
|
||||
const parsed = parseLinuxHopperReleases({
|
||||
"Ubuntu / Mint": release,
|
||||
"OS X": { signature: "Sparkle DSA" },
|
||||
});
|
||||
expect(parsed).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
deb: { ok: true, value: release },
|
||||
rpm: { ok: false },
|
||||
arch: { ok: false },
|
||||
},
|
||||
});
|
||||
if (!parsed.ok || !parsed.value.deb.ok) throw new Error("Missing release");
|
||||
expect(parsed.value.deb.value.filename).toBe(release.filename);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ signature: undefined },
|
||||
{ signature: "AQ==garbage" },
|
||||
{ main_exe_hash: "b".repeat(40) },
|
||||
{ file_length: "100000001" },
|
||||
{ file_length: "NaN" },
|
||||
{ public: "0" },
|
||||
{ beta: "1" },
|
||||
{
|
||||
filename:
|
||||
"https://other.example/downloader/public/Hopper-99.2.1-Linux-demo.deb",
|
||||
},
|
||||
{ filename: release.filename + "?token=caller" },
|
||||
{
|
||||
filename: release.filename.replace("https://", "https://user:password@"),
|
||||
},
|
||||
{ filename: release.filename.replace(".deb", ".rpm") },
|
||||
{ filename: release.filename.replace("demo.deb", "full.deb") },
|
||||
])(
|
||||
"rejects metadata outside the selected public package contract: %j",
|
||||
(changes) => {
|
||||
expect(
|
||||
parseLinuxHopperReleases({
|
||||
"Ubuntu / Mint": { ...release, ...changes },
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: true,
|
||||
value: { deb: { ok: false, error: { reason: "release_metadata" } } },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each([null, [], "html error page"])(
|
||||
"keeps malformed feed roots unknown",
|
||||
(raw) => {
|
||||
expect(parseLinuxHopperReleases(raw)).toMatchObject({
|
||||
ok: false,
|
||||
error: { reason: "release_metadata" },
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { HOPPER_DOWNLOAD_BYTES } from "./HopperDownload.js";
|
||||
|
||||
/** Vendor endpoints, shared by installation and existing-build inspection. */
|
||||
export const HOPPER_RELEASES_URL =
|
||||
"https://www.hopperapp.com/include/files-api.php?request=releases&public=true";
|
||||
export const HOPPER_PUBLIC_KEY_URL =
|
||||
"https://www.hopperapp.com/hopper-public-key.asc";
|
||||
export const HOPPER_DOWNLOAD_PREFIX =
|
||||
"https://www.hopperapp.com:443/downloader/public/";
|
||||
|
||||
/** Stable publisher identity; signing subkeys are admitted through this primary. */
|
||||
export const HOPPER_LINUX_PRIMARY_FINGERPRINT =
|
||||
"7C2D73BAFAF5B9A710A69DA1A26DF815099B1BCD";
|
||||
|
||||
export type LinuxPackageFamily = "deb" | "rpm" | "arch";
|
||||
const platforms = {
|
||||
deb: "Ubuntu / Mint",
|
||||
rpm: "Fedora",
|
||||
arch: "Arch",
|
||||
} as const;
|
||||
const suffixes = { deb: ".deb", rpm: ".rpm", arch: ".pkg.tar.xz" } as const;
|
||||
|
||||
export class HopperReleaseError extends Error {
|
||||
readonly _tag = "HopperReleaseError";
|
||||
constructor(
|
||||
readonly reason:
|
||||
| "release_metadata"
|
||||
| "download"
|
||||
| "integrity"
|
||||
| "publisher_verification_failed"
|
||||
| "verification_unavailable"
|
||||
| "unsupported_hopper_build"
|
||||
| "cancelled"
|
||||
| "cleanup_failed"
|
||||
| "cache_missing",
|
||||
message: string,
|
||||
options?: ErrorOptions,
|
||||
) {
|
||||
super(message, options);
|
||||
this.name = this._tag;
|
||||
}
|
||||
}
|
||||
|
||||
const sha256 = z.string().regex(/^[a-f0-9]{64}$/u);
|
||||
const releaseSchema = z
|
||||
.object({
|
||||
version: z.string().min(1),
|
||||
filename: z.string().url(),
|
||||
file_length: z
|
||||
.string()
|
||||
.regex(/^[1-9]\d*$/u)
|
||||
.refine((text) => {
|
||||
const count = Number(text);
|
||||
return Number.isSafeInteger(count) && count <= HOPPER_DOWNLOAD_BYTES;
|
||||
}),
|
||||
file_hash: z.string().regex(/^[a-f0-9]{40}$/u),
|
||||
main_exe_hash: sha256,
|
||||
signature: z
|
||||
.string()
|
||||
.min(1)
|
||||
.refine(
|
||||
(text) => Buffer.from(text, "base64").toString("base64") === text,
|
||||
),
|
||||
public: z.literal("1"),
|
||||
beta: z.literal("0"),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
/** Parsed once at the feed boundary; preserve the vendor's complete release row. */
|
||||
export type LinuxHopperRelease = z.infer<typeof releaseSchema>;
|
||||
export type LinuxHopperReleaseFeed = Readonly<
|
||||
Record<LinuxPackageFamily, Result<LinuxHopperRelease, HopperReleaseError>>
|
||||
>;
|
||||
|
||||
export const parseLinuxHopperRelease = (
|
||||
raw: unknown,
|
||||
family: LinuxPackageFamily,
|
||||
): Result<LinuxHopperRelease, HopperReleaseError> => {
|
||||
const parsed = releaseSchema.safeParse(raw);
|
||||
if (!parsed.success)
|
||||
return err(
|
||||
new HopperReleaseError(
|
||||
"release_metadata",
|
||||
"The Hopper release omitted valid package identity or signature metadata.",
|
||||
),
|
||||
);
|
||||
const url = new URL(parsed.data.filename);
|
||||
if (
|
||||
url.origin !== "https://www.hopperapp.com" ||
|
||||
url.username !== "" ||
|
||||
url.password !== "" ||
|
||||
url.search !== "" ||
|
||||
url.hash !== "" ||
|
||||
!url.pathname.startsWith("/downloader/public/Hopper-") ||
|
||||
!url.pathname.endsWith(`-Linux-demo${suffixes[family]}`)
|
||||
)
|
||||
return err(
|
||||
new HopperReleaseError(
|
||||
"release_metadata",
|
||||
"The Hopper release is outside the official public Linux demo channel.",
|
||||
),
|
||||
);
|
||||
return ok(parsed.data);
|
||||
};
|
||||
|
||||
export const parseLinuxHopperReleases = (
|
||||
raw: unknown,
|
||||
): Result<LinuxHopperReleaseFeed, HopperReleaseError> => {
|
||||
const feed = z.record(z.string(), z.unknown()).safeParse(raw);
|
||||
if (!feed.success)
|
||||
return err(
|
||||
new HopperReleaseError(
|
||||
"release_metadata",
|
||||
"The Hopper releases response is not an object.",
|
||||
),
|
||||
);
|
||||
return ok({
|
||||
deb: parseLinuxHopperRelease(feed.data[platforms.deb], "deb"),
|
||||
rpm: parseLinuxHopperRelease(feed.data[platforms.rpm], "rpm"),
|
||||
arch: parseLinuxHopperRelease(feed.data[platforms.arch], "arch"),
|
||||
});
|
||||
};
|
||||
@@ -18,13 +18,14 @@ export const workflowAnalysisProfile = (
|
||||
): AnalysisProfileCommitment =>
|
||||
createAnalysisProfile(REA_WORKFLOW_PROVIDER, {
|
||||
upstream_analysis_profile: upstream,
|
||||
source_limitations: "provider-operation-caveats-v1",
|
||||
...(operation === "binary_overview"
|
||||
? { workflow_revision: 3 }
|
||||
? { workflow_revision: 4 }
|
||||
: operation === "batch_decompile" ||
|
||||
operation === "get_call_graph" ||
|
||||
operation === "trace_feature"
|
||||
? { workflow_revision: 2 }
|
||||
: {}),
|
||||
? { workflow_revision: 3 }
|
||||
: { workflow_revision: 1 }),
|
||||
});
|
||||
|
||||
/** Provider identity for deterministic artifact inventories. */
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
formatCommandForDisplay,
|
||||
installLinuxHopper,
|
||||
linuxHopperLauncherDigestSupported,
|
||||
linuxPackageManagerCommands,
|
||||
linuxSharedLibrariesAvailable,
|
||||
parseLinuxDistribution,
|
||||
supportsReaHost,
|
||||
LinuxHopperTemporaryCleanupOwner,
|
||||
systemLinuxHopperInstallHost,
|
||||
type LinuxDistribution,
|
||||
type LinuxHopperDownload,
|
||||
type LinuxHopperInstallHost,
|
||||
@@ -18,6 +20,15 @@ import {
|
||||
type LinuxPackageInstallFailure,
|
||||
type LinuxPackageInstallOutcome,
|
||||
} from "./LinuxHopper.js";
|
||||
import { err, ok } from "../domain/result.js";
|
||||
import {
|
||||
HopperReleaseError,
|
||||
type LinuxHopperRelease,
|
||||
} from "./HopperReleaseFeed.js";
|
||||
import type { VerifiedLinuxHopperPackage } from "./LinuxHopperPublisher.js";
|
||||
import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
|
||||
import * as nativeCommands from "../process/ExecFileOutput.js";
|
||||
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
|
||||
|
||||
describe("Linux package-manager command display", () => {
|
||||
it("preserves argument boundaries for paths with spaces and quotes", () => {
|
||||
@@ -43,6 +54,9 @@ class RecordingLinuxHost implements LinuxHopperInstallHost {
|
||||
archiveOk = true;
|
||||
installSucceeds = true;
|
||||
cleanupFails = false;
|
||||
signatureValid = true;
|
||||
cacheWritable = true;
|
||||
verifiedBeforeInstall = false;
|
||||
packageFailure: LinuxPackageInstallFailure = {
|
||||
command: "pacman -U --noconfirm /tmp/rea test/hopper.arch",
|
||||
exitCode: 1,
|
||||
@@ -57,6 +71,8 @@ class RecordingLinuxHost implements LinuxHopperInstallHost {
|
||||
|
||||
distribution = (): Promise<LinuxDistribution | undefined> =>
|
||||
Promise.resolve(this.distributionValue);
|
||||
release = (family: LinuxPackageFamily) =>
|
||||
Promise.resolve(ok(releasesFor(new Uint8Array([1, 2, 3]))[family]));
|
||||
download = (url: string): Promise<LinuxHopperDownload> => {
|
||||
this.downloads.push(url);
|
||||
return Promise.resolve({ ok: this.archiveOk, bytes: this.archive });
|
||||
@@ -64,10 +80,52 @@ class RecordingLinuxHost implements LinuxHopperInstallHost {
|
||||
createTemporaryDirectory = (): Promise<string> =>
|
||||
Promise.resolve("/tmp/rea test");
|
||||
writeArchive = (): Promise<void> => Promise.resolve();
|
||||
verifyArchive = (family: LinuxPackageFamily, release: LinuxHopperRelease) => {
|
||||
if (
|
||||
createHash("sha1").update(this.archive).digest("hex") !==
|
||||
release.file_hash
|
||||
)
|
||||
return Promise.resolve(
|
||||
err(new HopperReleaseError("integrity", "Fixture byte mismatch")),
|
||||
);
|
||||
if (!this.signatureValid)
|
||||
return Promise.resolve(
|
||||
err(
|
||||
new HopperReleaseError(
|
||||
"publisher_verification_failed",
|
||||
"Fixture signature rejection",
|
||||
),
|
||||
),
|
||||
);
|
||||
this.verifiedBeforeInstall = true;
|
||||
return Promise.resolve(
|
||||
ok({
|
||||
family,
|
||||
release,
|
||||
packageSha256: createHash("sha256").update(this.archive).digest("hex"),
|
||||
publicKey: "fixture",
|
||||
primaryFingerprint: "0".repeat(40),
|
||||
signingFingerprint: "1".repeat(40),
|
||||
}),
|
||||
);
|
||||
};
|
||||
retainVerifiedArchive = (_verified: VerifiedLinuxHopperPackage) =>
|
||||
Promise.resolve(
|
||||
this.cacheWritable
|
||||
? ok(undefined)
|
||||
: err(
|
||||
new HopperReleaseError(
|
||||
"verification_unavailable",
|
||||
"Fixture cache permission rejection",
|
||||
),
|
||||
),
|
||||
);
|
||||
installPackage = (
|
||||
family: LinuxPackageFamily,
|
||||
archive: string,
|
||||
): Promise<LinuxPackageInstallOutcome> => {
|
||||
if (!this.verifiedBeforeInstall)
|
||||
throw new Error("Package manager ran before publisher verification");
|
||||
this.installed.push({ family, archive });
|
||||
const outcome: LinuxPackageInstallOutcome = this.installSucceeds
|
||||
? { status: "installed" }
|
||||
@@ -160,20 +218,6 @@ describe("Linux Hopper host classification", () => {
|
||||
});
|
||||
|
||||
describe("Linux Hopper installation", () => {
|
||||
it.each([
|
||||
[
|
||||
"Debian and Arch",
|
||||
"0294ced141cc373468ee22d8343e7dac41980cb05a937994ca81c9f09afe7ded",
|
||||
],
|
||||
["RPM", "1339f9e58377442b0c6fcb0dfc3cec20d593cc557408521fad9a00dbc6b8da13"],
|
||||
])("accepts the pinned %s launcher build", (_, digest) => {
|
||||
expect(linuxHopperLauncherDigestSupported(digest)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects an unverified launcher build", () => {
|
||||
expect(linuxHopperLauncherDigestSupported("0".repeat(64))).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["deb", "apt-get", ["xvfb", "xauth", "python3", "libx11-6", "libxtst6"]],
|
||||
[
|
||||
@@ -239,6 +283,23 @@ describe("Linux Hopper package installation", () => {
|
||||
});
|
||||
|
||||
describe("Linux Hopper installation failure classification", () => {
|
||||
it.each([
|
||||
["signatureValid", "publisher_verification_failed"],
|
||||
["cacheWritable", "verification_unavailable"],
|
||||
] as const)(
|
||||
"stops before privileged installation when %s fails",
|
||||
async (field, reason) => {
|
||||
const host = new RecordingLinuxHost();
|
||||
host[field] = false;
|
||||
expect(await installFixture(host)).toMatchObject({
|
||||
status: "failed",
|
||||
reason,
|
||||
verificationFailure: { reason, message: expect.any(String) },
|
||||
});
|
||||
expect(host.installed).toEqual([]);
|
||||
expect(host.cleaned).toEqual(["/tmp/rea test"]);
|
||||
},
|
||||
);
|
||||
it.each([
|
||||
["unsupported host", "unsupported_host"],
|
||||
["package request failure", "download"],
|
||||
@@ -263,7 +324,7 @@ describe("Linux Hopper installation failure classification", () => {
|
||||
host.launcherStatusValue = "runtime_dependencies";
|
||||
if (scenario === "unsupported build")
|
||||
host.launcherStatusValue = "unsupported_hopper_build";
|
||||
expect(await installFixture(host)).toEqual({
|
||||
expect(await installFixture(host)).toMatchObject({
|
||||
status: "failed",
|
||||
reason,
|
||||
...(scenario === "package manager failure"
|
||||
@@ -276,6 +337,37 @@ describe("Linux Hopper installation failure classification", () => {
|
||||
});
|
||||
|
||||
describe("Linux Hopper cleanup and failure details", () => {
|
||||
it("preserves a publisher descriptor cleanup failure during launcher status inspection", async () => {
|
||||
const directory = await createTestTempDirectory("rea-hopper-status-");
|
||||
const launcher = join(directory, "Hopper");
|
||||
await writeFile(launcher, "fixture launcher");
|
||||
const host = systemLinuxHopperInstallHost({
|
||||
...process.env,
|
||||
HOME: directory,
|
||||
XDG_CACHE_HOME: join(directory, "cache"),
|
||||
});
|
||||
const command = vi
|
||||
.spyOn(nativeCommands, "execFileOutput")
|
||||
.mockResolvedValue({
|
||||
stdout: "libc.so.6 => /lib/libc.so.6",
|
||||
stderr: "",
|
||||
stdoutBytes: Buffer.byteLength("libc.so.6 => /lib/libc.so.6"),
|
||||
stderrBytes: 0,
|
||||
});
|
||||
const close = vi
|
||||
.spyOn(OwnedFileHandle.prototype, "close")
|
||||
.mockRejectedValue(new Error("Fixture descriptor close unavailable"));
|
||||
try {
|
||||
await expect(host.launcherStatus(launcher, "0".repeat(64))).resolves.toBe(
|
||||
"cleanup_failed",
|
||||
);
|
||||
} finally {
|
||||
close.mockRestore();
|
||||
command.mockRestore();
|
||||
await host.close?.();
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves the failing package-manager command and its output", async () => {
|
||||
const host = new RecordingLinuxHost();
|
||||
host.installSucceeds = false;
|
||||
@@ -363,14 +455,16 @@ describe("Linux Hopper temporary cleanup ownership", () => {
|
||||
|
||||
function releasesFor(
|
||||
bytes: Uint8Array,
|
||||
): Record<
|
||||
LinuxPackageFamily,
|
||||
{ filename: string; file_length: string; file_hash: string }
|
||||
> {
|
||||
): Record<LinuxPackageFamily, LinuxHopperRelease> {
|
||||
const release = (extension: string) => ({
|
||||
filename: `https://www.hopperapp.com:443/downloader/public/Hopper.${extension}`,
|
||||
version: "future-fixture",
|
||||
filename: `https://www.hopperapp.com:443/downloader/public/Hopper-future-fixture-Linux-demo.${extension}`,
|
||||
file_length: String(bytes.byteLength),
|
||||
file_hash: createHash("sha1").update(bytes).digest("hex"),
|
||||
main_exe_hash: "0".repeat(64),
|
||||
signature: "AQ==",
|
||||
public: "1" as const,
|
||||
beta: "0" as const,
|
||||
});
|
||||
return {
|
||||
deb: release("deb"),
|
||||
@@ -379,7 +473,4 @@ function releasesFor(
|
||||
};
|
||||
}
|
||||
|
||||
const installFixture = (host: RecordingLinuxHost) =>
|
||||
installLinuxHopper(host, {
|
||||
releases: releasesFor(new Uint8Array([1, 2, 3])),
|
||||
});
|
||||
const installFixture = (host: RecordingLinuxHost) => installLinuxHopper(host);
|
||||
+186
-103
@@ -1,55 +1,38 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { execFileBoundedOutput } from "../process/ExecFileBoundedOutput.js";
|
||||
import { execFileOutput } from "../process/ExecFileOutput.js";
|
||||
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
|
||||
import { downloadHopperPackage } from "./HopperDownload.js";
|
||||
import { RegularFileCleanupFailure } from "../filesystem/OwnedRegularFile.js";
|
||||
import { err, type Result } from "../domain/result.js";
|
||||
import {
|
||||
downloadHopperPackage,
|
||||
HOPPER_DOWNLOAD_BYTES,
|
||||
} from "./HopperDownload.js";
|
||||
HOPPER_RELEASES_URL,
|
||||
HOPPER_PUBLIC_KEY_URL,
|
||||
HOPPER_DOWNLOAD_PREFIX,
|
||||
HOPPER_LINUX_PRIMARY_FINGERPRINT,
|
||||
HopperReleaseError,
|
||||
type LinuxHopperRelease,
|
||||
type LinuxPackageFamily,
|
||||
} from "./HopperReleaseFeed.js";
|
||||
import {
|
||||
LinuxHopperPublisher,
|
||||
retryLinuxHopperPublisherCleanup,
|
||||
type VerifiedLinuxHopperPackage,
|
||||
type VerifiedLinuxHopperLauncher,
|
||||
} from "./LinuxHopperPublisher.js";
|
||||
|
||||
export type { LinuxPackageFamily } from "./HopperReleaseFeed.js";
|
||||
|
||||
/** Package-manager output retained per stream without stopping the command. */
|
||||
const PACKAGE_MANAGER_OUTPUT_BYTES = 64 * 1024;
|
||||
const DOWNLOAD_PREFIX = "https://www.hopperapp.com:443/downloader/public/";
|
||||
const SUPPORTED_HOPPER_SHA256 = new Set([
|
||||
"0294ced141cc373468ee22d8343e7dac41980cb05a937994ca81c9f09afe7ded",
|
||||
"1339f9e58377442b0c6fcb0dfc3cec20d593cc557408521fad9a00dbc6b8da13",
|
||||
]);
|
||||
interface LinuxHopperRelease {
|
||||
readonly filename: string;
|
||||
readonly file_length: string;
|
||||
readonly file_hash: string;
|
||||
}
|
||||
const SUPPORTED_RELEASES: Readonly<
|
||||
Record<LinuxPackageFamily, LinuxHopperRelease>
|
||||
> = {
|
||||
deb: {
|
||||
filename: `${DOWNLOAD_PREFIX}Hopper-6.4.2-Linux-demo.deb`,
|
||||
file_length: "35755772",
|
||||
file_hash: "e4f79dff602648a8ff4a88b773875b6bfac0dc65",
|
||||
},
|
||||
rpm: {
|
||||
filename: `${DOWNLOAD_PREFIX}Hopper-6.4.2-Linux-demo.rpm`,
|
||||
file_length: "32264475",
|
||||
file_hash: "b9a65f2f583a386d440d4401d9140aef3bf305de",
|
||||
},
|
||||
arch: {
|
||||
filename: `${DOWNLOAD_PREFIX}Hopper-6.4.2-Linux-demo.pkg.tar.xz`,
|
||||
file_length: "29405968",
|
||||
file_hash: "92d780b61cab38eda95dc4112dee4fbeb0920f46",
|
||||
},
|
||||
};
|
||||
|
||||
export type LinuxPackageFamily = "deb" | "rpm" | "arch";
|
||||
|
||||
/** Exact stable external effects disclosed before a Linux Hopper installation. */
|
||||
export interface LinuxHopperInstallDisclosure {
|
||||
readonly downloadUrl: string;
|
||||
readonly expectedBytes: number;
|
||||
readonly expectedSha1: string;
|
||||
readonly networkOrigins: readonly string[];
|
||||
readonly integrity: string;
|
||||
readonly commands: readonly string[];
|
||||
}
|
||||
|
||||
@@ -58,11 +41,13 @@ export const linuxHopperInstallDisclosure = (
|
||||
family: LinuxPackageFamily,
|
||||
isRoot: boolean,
|
||||
): LinuxHopperInstallDisclosure => {
|
||||
const release = SUPPORTED_RELEASES[family];
|
||||
return {
|
||||
downloadUrl: release.filename,
|
||||
expectedBytes: Number(release.file_length),
|
||||
expectedSha1: release.file_hash,
|
||||
networkOrigins: [
|
||||
HOPPER_RELEASES_URL,
|
||||
HOPPER_PUBLIC_KEY_URL,
|
||||
HOPPER_DOWNLOAD_PREFIX,
|
||||
],
|
||||
integrity: `Detached PGP signature · primary ${HOPPER_LINUX_PRIMARY_FINGERPRINT} · package byte identity and installed launcher SHA-256 from the official feed`,
|
||||
commands: linuxPackageManagerCommands(
|
||||
family,
|
||||
`<verified-hopper.${family}>`,
|
||||
@@ -133,6 +118,10 @@ export type LinuxPackageInstallOutcome =
|
||||
/** External effects required to install the official Linux package. */
|
||||
export interface LinuxHopperInstallHost {
|
||||
distribution(): Promise<LinuxDistribution | undefined>;
|
||||
release(
|
||||
family: LinuxPackageFamily,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<LinuxHopperRelease, HopperReleaseError>>;
|
||||
download(
|
||||
url: string,
|
||||
options: { readonly signal?: AbortSignal },
|
||||
@@ -143,8 +132,23 @@ export interface LinuxHopperInstallHost {
|
||||
family: LinuxPackageFamily,
|
||||
archive: string,
|
||||
): Promise<LinuxPackageInstallOutcome>;
|
||||
launcherStatus(path: string): Promise<LinuxHopperLauncherStatus>;
|
||||
verifyArchive(
|
||||
family: LinuxPackageFamily,
|
||||
release: LinuxHopperRelease,
|
||||
archive: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<VerifiedLinuxHopperPackage, HopperReleaseError>>;
|
||||
retainVerifiedArchive(
|
||||
verified: VerifiedLinuxHopperPackage,
|
||||
archive: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<void, HopperReleaseError>>;
|
||||
launcherStatus(
|
||||
path: string,
|
||||
expectedSha256: string,
|
||||
): Promise<LinuxHopperLauncherStatus>;
|
||||
cleanup(path: string): Promise<void>;
|
||||
close?(): Promise<void>;
|
||||
}
|
||||
|
||||
/** Structured outcome for every expected Linux installation failure. */
|
||||
@@ -163,6 +167,9 @@ export type LinuxHopperInstallResult =
|
||||
| "launcher_missing"
|
||||
| "runtime_dependencies"
|
||||
| "unsupported_hopper_build"
|
||||
| "publisher_verification_failed"
|
||||
| "verification_unavailable"
|
||||
| "cleanup_failed"
|
||||
| "cancelled";
|
||||
/**
|
||||
* The failing package-manager command and its output. Present only when
|
||||
@@ -170,6 +177,8 @@ export type LinuxHopperInstallResult =
|
||||
* package manager said instead of assuming an authorization problem.
|
||||
*/
|
||||
readonly packageFailure?: LinuxPackageInstallFailure;
|
||||
/** Publisher-owned failure, retained for the public setup diagnostic. */
|
||||
readonly verificationFailure?: HopperReleaseError;
|
||||
} & { readonly cleanupFailure?: LinuxHopperTemporaryCleanupFailure });
|
||||
|
||||
/** Failed removal of an owned temporary install directory. */
|
||||
@@ -212,7 +221,8 @@ export type LinuxHopperLauncherStatus =
|
||||
| "ready"
|
||||
| "missing"
|
||||
| "runtime_dependencies"
|
||||
| "unsupported_hopper_build";
|
||||
| "unsupported_hopper_build"
|
||||
| "cleanup_failed";
|
||||
|
||||
/** Parse an os-release document without executing its shell syntax. */
|
||||
export const parseLinuxDistribution = (text: string): LinuxDistribution => {
|
||||
@@ -270,9 +280,6 @@ export const installLinuxHopper = async (
|
||||
host: LinuxHopperInstallHost = systemLinuxHopperInstallHost(process.env),
|
||||
options: {
|
||||
readonly signal?: AbortSignal;
|
||||
readonly releases?: Readonly<
|
||||
Record<LinuxPackageFamily, LinuxHopperRelease>
|
||||
>;
|
||||
} = {},
|
||||
): Promise<LinuxHopperInstallResult> => {
|
||||
if (options.signal?.aborted === true)
|
||||
@@ -284,16 +291,56 @@ export const installLinuxHopper = async (
|
||||
let result: LinuxHopperInstallResult;
|
||||
try {
|
||||
result = await (async (): Promise<LinuxHopperInstallResult> => {
|
||||
const release = (options.releases ?? SUPPORTED_RELEASES)[
|
||||
distribution.packageFamily
|
||||
];
|
||||
const selected = await host.release(
|
||||
distribution.packageFamily,
|
||||
options.signal,
|
||||
);
|
||||
if (!selected.ok)
|
||||
return {
|
||||
status: "failed",
|
||||
verificationFailure: selected.error,
|
||||
reason:
|
||||
selected.error.reason === "cancelled"
|
||||
? "cancelled"
|
||||
: "release_metadata",
|
||||
};
|
||||
const release = selected.value;
|
||||
const archiveDownload = await host.download(release.filename, options);
|
||||
if (!archiveDownload.ok) return { status: "failed", reason: "download" };
|
||||
if (!packageIntegrityMatches(archiveDownload.bytes, release))
|
||||
return { status: "failed", reason: "integrity" };
|
||||
temporary = await host.createTemporaryDirectory();
|
||||
const archive = join(temporary, `hopper.${distribution.packageFamily}`);
|
||||
await host.writeArchive(archive, archiveDownload.bytes);
|
||||
const verified = await host.verifyArchive(
|
||||
distribution.packageFamily,
|
||||
release,
|
||||
archive,
|
||||
options.signal,
|
||||
);
|
||||
if (!verified.ok)
|
||||
return {
|
||||
status: "failed",
|
||||
verificationFailure: verified.error,
|
||||
reason:
|
||||
verified.error.reason === "cache_missing"
|
||||
? "verification_unavailable"
|
||||
: verified.error.reason,
|
||||
};
|
||||
const retained = await host.retainVerifiedArchive(
|
||||
verified.value,
|
||||
archive,
|
||||
options.signal,
|
||||
);
|
||||
if (!retained.ok)
|
||||
return {
|
||||
status: "failed",
|
||||
verificationFailure: retained.error,
|
||||
reason:
|
||||
retained.error.reason === "cache_missing"
|
||||
? "verification_unavailable"
|
||||
: retained.error.reason,
|
||||
};
|
||||
if (options.signal?.aborted === true)
|
||||
return { status: "failed", reason: "cancelled" };
|
||||
const install = await host.installPackage(
|
||||
distribution.packageFamily,
|
||||
archive,
|
||||
@@ -305,7 +352,10 @@ export const installLinuxHopper = async (
|
||||
packageFailure: install.failure,
|
||||
};
|
||||
const launcherPath = "/opt/hopper/bin/Hopper";
|
||||
const launcherStatus = await host.launcherStatus(launcherPath);
|
||||
const launcherStatus = await host.launcherStatus(
|
||||
launcherPath,
|
||||
release.main_exe_hash,
|
||||
);
|
||||
if (launcherStatus !== "ready")
|
||||
return {
|
||||
status: "failed",
|
||||
@@ -360,49 +410,90 @@ export const linuxHopperLauncherCandidates = (
|
||||
/** Compose package-manager and linker checks from the selected caller environment. */
|
||||
export const systemLinuxHopperInstallHost = (
|
||||
environment: Readonly<NodeJS.ProcessEnv>,
|
||||
): LinuxHopperInstallHost => ({
|
||||
distribution: readLinuxDistribution,
|
||||
download: downloadHopperPackage,
|
||||
createTemporaryDirectory: () => mkdtemp(join(tmpdir(), "rea-hopper-")),
|
||||
writeArchive: (path, bytes) => writeFile(path, bytes, { mode: 0o600 }),
|
||||
installPackage: (family, archive) =>
|
||||
installSystemPackage(family, archive, environment),
|
||||
async launcherStatus(path) {
|
||||
try {
|
||||
await access(path);
|
||||
const linked = await execFileOutput("ldd", [path], { env: environment });
|
||||
if (!linuxSharedLibrariesAvailable(`${linked.stdout}\n${linked.stderr}`))
|
||||
return "runtime_dependencies";
|
||||
return (await linuxHopperBinarySupported(path))
|
||||
? "ready"
|
||||
: "unsupported_hopper_build";
|
||||
} catch (cause: unknown) {
|
||||
// best-effort cleanup: optional launcher probing; unreadable means missing.
|
||||
void cause;
|
||||
return "missing";
|
||||
}
|
||||
},
|
||||
cleanup: (path) => rm(path, { recursive: true, force: true }),
|
||||
});
|
||||
|
||||
/** Match the Linux Hopper build validated by the packaged demo-dialog adapter. */
|
||||
export const linuxHopperBinarySupported = async (
|
||||
path: string,
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
const hash = createHash("sha256");
|
||||
for await (const chunk of createReadStream(path)) hash.update(chunk);
|
||||
return linuxHopperLauncherDigestSupported(hash.digest("hex"));
|
||||
} catch (cause: unknown) {
|
||||
// best-effort cleanup: optional build probing; unreadable means unsupported.
|
||||
void cause;
|
||||
return false;
|
||||
}
|
||||
): LinuxHopperInstallHost => {
|
||||
const publisher = systemLinuxHopperPublisher(environment);
|
||||
return {
|
||||
distribution: readLinuxDistribution,
|
||||
release: (family, signal) => publisher.currentRelease(family, signal),
|
||||
download: downloadHopperPackage,
|
||||
createTemporaryDirectory: () => mkdtemp(join(tmpdir(), "rea-hopper-")),
|
||||
writeArchive: (path, bytes) => writeFile(path, bytes, { mode: 0o600 }),
|
||||
installPackage: (family, archive) =>
|
||||
installSystemPackage(family, archive, environment),
|
||||
verifyArchive: (family, release, archive, signal) =>
|
||||
publisher.verifyArchive(family, release, archive, signal),
|
||||
retainVerifiedArchive: (verified, archive, signal) =>
|
||||
publisher.retainVerifiedArchive(verified, archive, signal),
|
||||
async launcherStatus(path, expectedSha256) {
|
||||
try {
|
||||
await access(path);
|
||||
const linked = await execFileOutput("ldd", [path], {
|
||||
env: environment,
|
||||
});
|
||||
if (
|
||||
!linuxSharedLibrariesAvailable(`${linked.stdout}\n${linked.stderr}`)
|
||||
)
|
||||
return "runtime_dependencies";
|
||||
return (await publisher.hashLauncher(path)) === expectedSha256
|
||||
? "ready"
|
||||
: "unsupported_hopper_build";
|
||||
} catch (cause: unknown) {
|
||||
if (
|
||||
cause instanceof RegularFileCleanupFailure ||
|
||||
(cause instanceof HopperReleaseError &&
|
||||
cause.reason === "cleanup_failed") ||
|
||||
(cause instanceof ArtifactReaderFailure &&
|
||||
cause.cleanup !== undefined)
|
||||
)
|
||||
return "cleanup_failed";
|
||||
// best-effort cleanup: optional launcher probing; unreadable means missing.
|
||||
void cause;
|
||||
return "missing";
|
||||
}
|
||||
},
|
||||
cleanup: (path) => rm(path, { recursive: true, force: true }),
|
||||
close: async () => {
|
||||
await publisher.close();
|
||||
await retryLinuxHopperPublisherCleanup();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/** Match a launcher extracted from one of the pinned official Linux packages. */
|
||||
export const linuxHopperLauncherDigestSupported = (digest: string): boolean =>
|
||||
SUPPORTED_HOPPER_SHA256.has(digest);
|
||||
export const systemLinuxHopperPublisher = (
|
||||
environment: Readonly<NodeJS.ProcessEnv>,
|
||||
): LinuxHopperPublisher =>
|
||||
new LinuxHopperPublisher(
|
||||
environment,
|
||||
async () => (await readLinuxDistribution())?.packageFamily,
|
||||
);
|
||||
|
||||
/** Read-only authentication; persistent package retention belongs to approved setup. */
|
||||
export const inspectLinuxHopperLauncher = async (
|
||||
path: string,
|
||||
environment: Readonly<NodeJS.ProcessEnv>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<VerifiedLinuxHopperLauncher, HopperReleaseError>> => {
|
||||
const publisher = systemLinuxHopperPublisher(environment);
|
||||
const result = await publisher.inspectLauncher(path, signal);
|
||||
try {
|
||||
await publisher.close();
|
||||
await retryLinuxHopperPublisherCleanup();
|
||||
return result;
|
||||
} catch (cause: unknown) {
|
||||
return err(
|
||||
new HopperReleaseError(
|
||||
"cleanup_failed",
|
||||
"Hopper publisher verification retains incomplete cleanup.",
|
||||
{
|
||||
cause: new AggregateError(
|
||||
result.ok ? [cause] : [result.error, cause],
|
||||
"Publisher verification outcomes",
|
||||
),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/** Interpret ldd output conservatively so a present but broken launcher is unhealthy. */
|
||||
export const linuxSharedLibrariesAvailable = (output: string): boolean =>
|
||||
@@ -492,14 +583,6 @@ export const linuxPackageManagerCommands = (
|
||||
});
|
||||
};
|
||||
|
||||
const packageIntegrityMatches = (
|
||||
bytes: Uint8Array,
|
||||
release: LinuxHopperRelease,
|
||||
): boolean =>
|
||||
bytes.byteLength === Number(release.file_length) &&
|
||||
bytes.byteLength <= HOPPER_DOWNLOAD_BYTES &&
|
||||
createHash("sha1").update(bytes).digest("hex") === release.file_hash;
|
||||
|
||||
const packageFamilyFor = (
|
||||
id: string,
|
||||
idLike: string | undefined,
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -4,7 +4,9 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
installMacHopper,
|
||||
installMacHopperBundle,
|
||||
type MacHopperBundleVerification,
|
||||
type MacHopperBundleInstallResult,
|
||||
type MacHopperInstallHost,
|
||||
} from "./MacHopper.js";
|
||||
|
||||
@@ -15,7 +17,8 @@ class FakeMacHopperHost implements MacHopperInstallHost {
|
||||
existing = false;
|
||||
mountSucceeds = true;
|
||||
copySucceeds = true;
|
||||
launcherSucceeds = true;
|
||||
mountFailure: Error | undefined;
|
||||
verificationFailure: Error | undefined;
|
||||
badHash = false;
|
||||
unmounts = 0;
|
||||
cleanups = 0;
|
||||
@@ -46,25 +49,37 @@ class FakeMacHopperHost implements MacHopperInstallHost {
|
||||
createTemporaryDirectory = () => Promise.resolve("/tmp/rea-hopper-test");
|
||||
createMountDirectory = () => Promise.resolve();
|
||||
writePackage = () => Promise.resolve();
|
||||
mount = () => Promise.resolve(this.mountSucceeds);
|
||||
mount = () =>
|
||||
this.mountFailure === undefined
|
||||
? Promise.resolve(this.mountSucceeds)
|
||||
: Promise.reject(this.mountFailure);
|
||||
unmount = () => {
|
||||
this.unmounts += 1;
|
||||
return Promise.resolve();
|
||||
};
|
||||
appBundle = () => Promise.resolve("/mounted/Hopper Disassembler.app");
|
||||
validBundle = () => Promise.resolve(this.verification);
|
||||
validBundle = () =>
|
||||
this.verificationFailure === undefined
|
||||
? Promise.resolve(this.verification)
|
||||
: Promise.reject(this.verificationFailure);
|
||||
destinationExists = () => Promise.resolve(this.existing);
|
||||
installBundle = (
|
||||
_source: string,
|
||||
_destination: string,
|
||||
_stage: string,
|
||||
replaceExisting: boolean,
|
||||
) => {
|
||||
this.installs += 1;
|
||||
this.replaces = replaceExisting;
|
||||
return Promise.resolve(this.copySucceeds);
|
||||
return Promise.resolve<MacHopperBundleInstallResult>(
|
||||
this.copySucceeds
|
||||
? { status: "installed" }
|
||||
: {
|
||||
status: "failed",
|
||||
reason: "copy",
|
||||
diagnostic: "ditto: destination is read-only",
|
||||
},
|
||||
);
|
||||
};
|
||||
launcherReady = () => Promise.resolve(this.launcherSucceeds);
|
||||
openApplication = () => {
|
||||
this.opens += 1;
|
||||
return Promise.resolve();
|
||||
@@ -89,6 +104,22 @@ describe("macOS Hopper installation", () => {
|
||||
expect(host.cleanups).toBe(1);
|
||||
});
|
||||
|
||||
it("preserves installation when macOS cannot open the installed app", async () => {
|
||||
const host = new FakeMacHopperHost();
|
||||
host.openApplication = () =>
|
||||
Promise.reject(new Error("LaunchServices refused"));
|
||||
|
||||
await expect(installMacHopper({}, host)).resolves.toEqual({
|
||||
status: "installed",
|
||||
launcherPath:
|
||||
"/home/user/Applications/Hopper Disassembler.app/Contents/MacOS/hopper",
|
||||
openFailure: "LaunchServices refused",
|
||||
});
|
||||
expect(host.installs).toBe(1);
|
||||
expect(host.unmounts).toBe(1);
|
||||
expect(host.cleanups).toBe(1);
|
||||
});
|
||||
|
||||
it("rejects an integrity mismatch before mounting", async () => {
|
||||
const host = new FakeMacHopperHost();
|
||||
host.badHash = true;
|
||||
@@ -130,6 +161,33 @@ describe("macOS Hopper installation", () => {
|
||||
expect(host.cleanups).toBe(1);
|
||||
});
|
||||
|
||||
it("preserves a thrown mount-stage diagnostic and cleans the temporary image", async () => {
|
||||
const host = new FakeMacHopperHost();
|
||||
host.mountFailure = new Error("hdiutil attach: resource busy");
|
||||
|
||||
await expect(installMacHopper({}, host)).resolves.toMatchObject({
|
||||
status: "failed",
|
||||
reason: "mount",
|
||||
diagnostic: "hdiutil attach: resource busy",
|
||||
});
|
||||
expect(host.unmounts).toBe(0);
|
||||
expect(host.cleanups).toBe(1);
|
||||
});
|
||||
|
||||
it("preserves the copy diagnostic from the owned install operation", async () => {
|
||||
const host = new FakeMacHopperHost();
|
||||
host.copySucceeds = false;
|
||||
|
||||
await expect(installMacHopper({}, host)).resolves.toMatchObject({
|
||||
status: "failed",
|
||||
reason: "copy",
|
||||
diagnostic: "ditto: destination is read-only",
|
||||
});
|
||||
expect(host.installs).toBe(1);
|
||||
expect(host.unmounts).toBe(1);
|
||||
expect(host.cleanups).toBe(1);
|
||||
});
|
||||
|
||||
it("reports a publisher mismatch without copying or launching the bundle", async () => {
|
||||
const host = new FakeMacHopperHost();
|
||||
host.verification = "unverified_publisher";
|
||||
@@ -142,4 +200,18 @@ describe("macOS Hopper installation", () => {
|
||||
expect(host.unmounts).toBe(1);
|
||||
expect(host.cleanups).toBe(1);
|
||||
});
|
||||
|
||||
it("preserves a thrown signature-check diagnostic as a bundle failure", async () => {
|
||||
const host = new FakeMacHopperHost();
|
||||
host.verificationFailure = new Error("codesign: helper unavailable");
|
||||
|
||||
await expect(installMacHopper({}, host)).resolves.toMatchObject({
|
||||
status: "failed",
|
||||
reason: "bundle",
|
||||
diagnostic: "codesign: helper unavailable",
|
||||
});
|
||||
expect(host.installs).toBe(0);
|
||||
expect(host.unmounts).toBe(1);
|
||||
expect(host.cleanups).toBe(1);
|
||||
});
|
||||
});
|
||||
+242
-92
@@ -10,17 +10,19 @@ import {
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { dirname, join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { z } from "zod";
|
||||
import { homeDirectoryFromEnvironment } from "../config/homeDirectory.js";
|
||||
import { HOPPER_PUBLISHER } from "../domain/hopperPublisher.js";
|
||||
import { macDeveloperIdRequirement } from "../process/MacCodeSignature.js";
|
||||
import { macHopperUserApplicationPath } from "./MacHopperPaths.js";
|
||||
import {
|
||||
downloadHopperPackage,
|
||||
HOPPER_DOWNLOAD_BYTES,
|
||||
} from "./HopperDownload.js";
|
||||
import type { HopperInstallFailureReason } from "./SetupInstallFailure.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const RELEASES_URL =
|
||||
@@ -37,6 +39,38 @@ export type MacHopperBundleVerification =
|
||||
| "invalid_signature"
|
||||
| "unverified_publisher";
|
||||
|
||||
type MacHopperInstallFailureReason = Extract<
|
||||
HopperInstallFailureReason,
|
||||
| "release_metadata"
|
||||
| "download"
|
||||
| "integrity"
|
||||
| "launcher_missing"
|
||||
| "destination_exists"
|
||||
| "mount"
|
||||
| "bundle"
|
||||
| "publisher_verification_failed"
|
||||
| "copy"
|
||||
>;
|
||||
|
||||
export type MacHopperBundleInstallResult =
|
||||
| { readonly status: "installed"; readonly cleanupFailure?: string }
|
||||
| {
|
||||
readonly status: "failed";
|
||||
readonly reason: "copy" | "launcher_missing";
|
||||
readonly diagnostic: string;
|
||||
readonly cleanupFailure?: string;
|
||||
};
|
||||
|
||||
/** Filesystem effects for one installer-owned bundle replacement transaction. */
|
||||
export interface MacHopperBundleInstallOperations {
|
||||
createStagingRoot(parent: string): Promise<string>;
|
||||
copyBundle(source: string, stage: string): Promise<void>;
|
||||
requireLauncher(path: string): Promise<void>;
|
||||
pathExists(path: string): Promise<boolean>;
|
||||
rename(source: string, destination: string): Promise<void>;
|
||||
removeStagingRoot(path: string): Promise<void>;
|
||||
}
|
||||
|
||||
/** Stable network and command effects disclosed before macOS Hopper setup. */
|
||||
export const macHopperInstallDisclosure = {
|
||||
networkOrigins: [RELEASES_URL, DOWNLOAD_PREFIX] as const,
|
||||
@@ -55,19 +89,12 @@ export type MacHopperInstallResult =
|
||||
readonly status: "installed";
|
||||
readonly launcherPath: string;
|
||||
readonly cleanupFailure?: string;
|
||||
readonly openFailure?: string;
|
||||
}
|
||||
| {
|
||||
readonly status: "failed";
|
||||
readonly reason:
|
||||
| "release_metadata"
|
||||
| "download"
|
||||
| "integrity"
|
||||
| "mount"
|
||||
| "bundle"
|
||||
| "publisher_verification_failed"
|
||||
| "destination_exists"
|
||||
| "copy"
|
||||
| "launcher_missing";
|
||||
readonly reason: MacHopperInstallFailureReason;
|
||||
readonly diagnostic?: string;
|
||||
readonly cleanupFailure?: string;
|
||||
};
|
||||
|
||||
@@ -87,10 +114,8 @@ export interface MacHopperInstallHost {
|
||||
installBundle(
|
||||
source: string,
|
||||
destination: string,
|
||||
stage: string,
|
||||
replaceExisting: boolean,
|
||||
): Promise<boolean>;
|
||||
launcherReady(path: string): Promise<boolean>;
|
||||
): Promise<MacHopperBundleInstallResult>;
|
||||
openApplication(path: string): Promise<void>;
|
||||
cleanup(path: string): Promise<void>;
|
||||
/** Retain the cleanup capability until the owning setup host retries it. */
|
||||
@@ -108,6 +133,8 @@ export const installMacHopper = async (
|
||||
): Promise<MacHopperInstallResult> => {
|
||||
let temporary: string | undefined;
|
||||
let mounted = false;
|
||||
let bundleCleanupFailure: string | undefined;
|
||||
let installStage: MacHopperInstallFailureReason = "release_metadata";
|
||||
let result: MacHopperInstallResult;
|
||||
try {
|
||||
result = await (async (): Promise<MacHopperInstallResult> => {
|
||||
@@ -119,15 +146,19 @@ export const installMacHopper = async (
|
||||
const release = parsed.data["OS X"];
|
||||
if (!release.filename.startsWith(DOWNLOAD_PREFIX))
|
||||
return { status: "failed", reason: "release_metadata" };
|
||||
installStage = "download";
|
||||
const archive = await host.download(release.filename);
|
||||
if (!archive.ok) return { status: "failed", reason: "download" };
|
||||
installStage = "integrity";
|
||||
if (!integrityMatches(archive.bytes, release))
|
||||
return { status: "failed", reason: "integrity" };
|
||||
|
||||
installStage = "destination_exists";
|
||||
const destination = host.destination();
|
||||
const destinationExists = await host.destinationExists(destination);
|
||||
if (destinationExists && options.replaceExisting !== true)
|
||||
return { status: "failed", reason: "destination_exists" };
|
||||
installStage = "mount";
|
||||
temporary = await host.createTemporaryDirectory();
|
||||
const packagePath = join(temporary, "hopper.dmg");
|
||||
const mountPath = join(temporary, "mounted");
|
||||
@@ -136,6 +167,7 @@ export const installMacHopper = async (
|
||||
if (!(await host.mount(packagePath, mountPath)))
|
||||
return { status: "failed", reason: "mount" };
|
||||
mounted = true;
|
||||
installStage = "bundle";
|
||||
const source = await host.appBundle(mountPath);
|
||||
if (source === undefined) return { status: "failed", reason: "bundle" };
|
||||
const verification = await host.validBundle(source);
|
||||
@@ -143,26 +175,41 @@ export const installMacHopper = async (
|
||||
return { status: "failed", reason: "bundle" };
|
||||
if (verification === "unverified_publisher")
|
||||
return { status: "failed", reason: "publisher_verification_failed" };
|
||||
const stage = `${destination}.rea-stage`;
|
||||
if (
|
||||
!(await host.installBundle(
|
||||
source,
|
||||
destination,
|
||||
stage,
|
||||
destinationExists,
|
||||
))
|
||||
)
|
||||
return { status: "failed", reason: "copy" };
|
||||
installStage = "copy";
|
||||
const installation = await host.installBundle(
|
||||
source,
|
||||
destination,
|
||||
destinationExists,
|
||||
);
|
||||
if (installation.status === "failed")
|
||||
return {
|
||||
status: "failed",
|
||||
reason: installation.reason,
|
||||
diagnostic: installation.diagnostic,
|
||||
...(installation.cleanupFailure === undefined
|
||||
? {}
|
||||
: { cleanupFailure: installation.cleanupFailure }),
|
||||
};
|
||||
bundleCleanupFailure = installation.cleanupFailure;
|
||||
const launcherPath = join(destination, "Contents/MacOS/hopper");
|
||||
if (!(await host.launcherReady(launcherPath)))
|
||||
return { status: "failed", reason: "launcher_missing" };
|
||||
await host.openApplication(destination);
|
||||
return { status: "installed", launcherPath };
|
||||
let openFailure: string | undefined;
|
||||
try {
|
||||
await host.openApplication(destination);
|
||||
} catch (cause: unknown) {
|
||||
openFailure = failureMessage(cause);
|
||||
}
|
||||
return {
|
||||
status: "installed",
|
||||
launcherPath,
|
||||
...(openFailure === undefined ? {} : { openFailure }),
|
||||
};
|
||||
})();
|
||||
} catch (cause: unknown) {
|
||||
// Install-stage failures share the download reason; the stage is cleaned below.
|
||||
void cause;
|
||||
result = { status: "failed", reason: "download" };
|
||||
result = {
|
||||
status: "failed",
|
||||
reason: installStage,
|
||||
diagnostic: failureMessage(cause),
|
||||
};
|
||||
}
|
||||
|
||||
let cleanupFailure: string | undefined;
|
||||
@@ -184,7 +231,106 @@ export const installMacHopper = async (
|
||||
}
|
||||
}
|
||||
|
||||
return cleanupFailure === undefined ? result : { ...result, cleanupFailure };
|
||||
const retainedCleanupFailure = [
|
||||
bundleCleanupFailure,
|
||||
cleanupFailure,
|
||||
result.cleanupFailure,
|
||||
]
|
||||
.filter((failure): failure is string => failure !== undefined)
|
||||
.join(" ");
|
||||
return retainedCleanupFailure === ""
|
||||
? result
|
||||
: { ...result, cleanupFailure: retainedCleanupFailure };
|
||||
};
|
||||
|
||||
/** Replace a bundle through a fresh same-volume root owned by this transaction. */
|
||||
export const installMacHopperBundle = async (
|
||||
source: string,
|
||||
destination: string,
|
||||
replaceExisting: boolean,
|
||||
operations: MacHopperBundleInstallOperations,
|
||||
retainCleanup: (cleanup: () => Promise<void>) => void,
|
||||
): Promise<MacHopperBundleInstallResult> => {
|
||||
const stagingRoot = await operations.createStagingRoot(dirname(destination));
|
||||
const stagedBundle = join(stagingRoot, "new.app");
|
||||
const previousBundle = join(stagingRoot, "previous.app");
|
||||
let originalBackedUp = false;
|
||||
let newCommitted = false;
|
||||
let failureReason: "copy" | "launcher_missing" = "copy";
|
||||
|
||||
try {
|
||||
await operations.copyBundle(source, stagedBundle);
|
||||
failureReason = "launcher_missing";
|
||||
await operations.requireLauncher(
|
||||
join(stagedBundle, "Contents/MacOS/hopper"),
|
||||
);
|
||||
failureReason = "copy";
|
||||
if (replaceExisting) {
|
||||
await operations.rename(destination, previousBundle);
|
||||
originalBackedUp = true;
|
||||
}
|
||||
await operations.rename(stagedBundle, destination);
|
||||
newCommitted = true;
|
||||
} catch (cause: unknown) {
|
||||
const diagnostics = [failureMessage(cause)];
|
||||
if (originalBackedUp && !newCommitted) {
|
||||
try {
|
||||
if (await operations.pathExists(destination))
|
||||
throw new Error(
|
||||
"destination now contains another installation; preserving the previous bundle in its owned staging root",
|
||||
);
|
||||
await operations.rename(previousBundle, destination);
|
||||
originalBackedUp = false;
|
||||
} catch (rollbackCause: unknown) {
|
||||
diagnostics.push(`rollback failed: ${failureMessage(rollbackCause)}`);
|
||||
const cleanupFailure = `The previous Hopper bundle remains owned at ${previousBundle}; cleanup will retry restoration only if the destination is empty.`;
|
||||
retainCleanup(async () => {
|
||||
if (originalBackedUp) {
|
||||
if (await operations.pathExists(destination))
|
||||
throw new Error(
|
||||
`Hopper destination is occupied; preserving owned backup ${previousBundle}`,
|
||||
);
|
||||
await operations.rename(previousBundle, destination);
|
||||
originalBackedUp = false;
|
||||
}
|
||||
await operations.removeStagingRoot(stagingRoot);
|
||||
});
|
||||
return {
|
||||
status: "failed",
|
||||
reason: failureReason,
|
||||
diagnostic: diagnostics.join("; "),
|
||||
cleanupFailure,
|
||||
};
|
||||
}
|
||||
}
|
||||
try {
|
||||
await operations.removeStagingRoot(stagingRoot);
|
||||
} catch (cleanupCause: unknown) {
|
||||
const cleanupFailure = `Hopper staging root cleanup failed: ${failureMessage(cleanupCause)}`;
|
||||
retainCleanup(() => operations.removeStagingRoot(stagingRoot));
|
||||
diagnostics.push(cleanupFailure);
|
||||
return {
|
||||
status: "failed",
|
||||
reason: failureReason,
|
||||
diagnostic: diagnostics.join("; "),
|
||||
cleanupFailure,
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: "failed",
|
||||
reason: failureReason,
|
||||
diagnostic: diagnostics.join("; "),
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await operations.removeStagingRoot(stagingRoot);
|
||||
return { status: "installed" };
|
||||
} catch (cause: unknown) {
|
||||
const cleanupFailure = `Hopper staging root cleanup failed after installation committed: ${failureMessage(cause)}`;
|
||||
retainCleanup(() => operations.removeStagingRoot(stagingRoot));
|
||||
return { status: "installed", cleanupFailure };
|
||||
}
|
||||
};
|
||||
|
||||
/** Compose installer paths and subprocesses from one selected caller context. */
|
||||
@@ -199,7 +345,7 @@ export const systemMacHopperInstallHost = (
|
||||
createMountDirectory: (path) => mkdir(path),
|
||||
writePackage: (path, bytes) => writeFile(path, bytes, { mode: 0o600 }),
|
||||
async mount(packagePath, mountPath) {
|
||||
return commandSucceeds(
|
||||
await execFileAsync(
|
||||
"hdiutil",
|
||||
[
|
||||
"attach",
|
||||
@@ -209,20 +355,14 @@ export const systemMacHopperInstallHost = (
|
||||
mountPath,
|
||||
packagePath,
|
||||
],
|
||||
environment,
|
||||
{ env: environment },
|
||||
);
|
||||
return true;
|
||||
},
|
||||
async unmount(mountPath) {
|
||||
if (
|
||||
!(await commandSucceeds(
|
||||
"hdiutil",
|
||||
["detach", mountPath, "-force"],
|
||||
environment,
|
||||
))
|
||||
)
|
||||
throw new Error(
|
||||
`hdiutil detach failed for ${JSON.stringify(mountPath)}`,
|
||||
);
|
||||
await execFileAsync("hdiutil", ["detach", mountPath, "-force"], {
|
||||
env: environment,
|
||||
});
|
||||
},
|
||||
async appBundle(mountPath) {
|
||||
const entries = await readdir(mountPath, { withFileTypes: true });
|
||||
@@ -260,49 +400,32 @@ export const systemMacHopperInstallHost = (
|
||||
await access(path);
|
||||
return true;
|
||||
} catch (cause: unknown) {
|
||||
// best-effort cleanup: optional existence probing; absence means missing.
|
||||
void cause;
|
||||
return false;
|
||||
}
|
||||
},
|
||||
async installBundle(source, destination, stage, replaceExisting) {
|
||||
const previous = `${stage}-previous`;
|
||||
try {
|
||||
await mkdir(join(homeDirectory, "Applications"), { recursive: true });
|
||||
await rm(stage, { recursive: true, force: true });
|
||||
await rm(previous, { recursive: true, force: true });
|
||||
await execFileAsync("ditto", [source, stage], { env: environment });
|
||||
await access(join(stage, "Contents/MacOS/hopper"));
|
||||
if (replaceExisting) await rename(destination, previous);
|
||||
await rename(stage, destination);
|
||||
if (replaceExisting)
|
||||
await rm(previous, { recursive: true, force: true });
|
||||
return true;
|
||||
} catch (cause: unknown) {
|
||||
// Install failure rolls back below; the false return reports it.
|
||||
void cause;
|
||||
await rm(stage, { recursive: true, force: true });
|
||||
if (replaceExisting) {
|
||||
try {
|
||||
await rename(previous, destination);
|
||||
} catch (rollbackCause: unknown) {
|
||||
// The original destination was not moved, or rollback is impossible.
|
||||
void rollbackCause;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
},
|
||||
async launcherReady(path) {
|
||||
try {
|
||||
await access(path);
|
||||
return true;
|
||||
} catch (cause: unknown) {
|
||||
// best-effort cleanup: optional launcher probing; absence means not ready.
|
||||
void cause;
|
||||
return false;
|
||||
if (isMissingPath(cause)) return false;
|
||||
throw cause;
|
||||
}
|
||||
},
|
||||
installBundle: (source, destination, replaceExisting) =>
|
||||
installMacHopperBundle(
|
||||
source,
|
||||
destination,
|
||||
replaceExisting,
|
||||
{
|
||||
async createStagingRoot(parent) {
|
||||
await mkdir(parent, { recursive: true });
|
||||
return mkdtemp(join(parent, ".rea-hopper-stage-"));
|
||||
},
|
||||
copyBundle: async (bundle, staged) => {
|
||||
await execFileAsync("ditto", [bundle, staged], {
|
||||
env: environment,
|
||||
});
|
||||
},
|
||||
requireLauncher: (path) => access(path).then(() => undefined),
|
||||
pathExists,
|
||||
rename,
|
||||
removeStagingRoot: (path) => rm(path, { recursive: true }),
|
||||
},
|
||||
(cleanup) => pendingCleanup.add(cleanup),
|
||||
),
|
||||
async openApplication(path) {
|
||||
await execFileAsync("open", [path], { env: environment });
|
||||
},
|
||||
@@ -315,13 +438,12 @@ export const systemMacHopperInstallHost = (
|
||||
await cleanup();
|
||||
pendingCleanup.delete(cleanup);
|
||||
} catch (cause: unknown) {
|
||||
failure ??= `Hopper DMG cleanup remains incomplete: ${failureMessage(cause)}`;
|
||||
failure ??= `Hopper installer cleanup remains incomplete: ${failureMessage(cause)}`;
|
||||
}
|
||||
}
|
||||
return failure;
|
||||
},
|
||||
destination: () =>
|
||||
join(homeDirectory, "Applications/Hopper Disassembler.app"),
|
||||
destination: () => macHopperUserApplicationPath(homeDirectory),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -343,13 +465,41 @@ const commandSucceeds = async (
|
||||
await execFileAsync(command, args, { env: environment });
|
||||
return true;
|
||||
} catch (cause: unknown) {
|
||||
// best-effort cleanup: optional command probing; failure means unavailable.
|
||||
void cause;
|
||||
return false;
|
||||
if (
|
||||
typeof cause === "object" &&
|
||||
cause !== null &&
|
||||
"code" in cause &&
|
||||
typeof cause.code === "number"
|
||||
)
|
||||
return false;
|
||||
throw cause;
|
||||
}
|
||||
};
|
||||
const failureMessage = (cause: unknown): string =>
|
||||
cause instanceof Error ? cause.message : String(cause);
|
||||
cause instanceof Error
|
||||
? [
|
||||
cause.message,
|
||||
"stderr" in cause && typeof cause.stderr === "string"
|
||||
? cause.stderr.trim()
|
||||
: "",
|
||||
]
|
||||
.filter((value) => value !== "")
|
||||
.join(": ")
|
||||
: String(cause);
|
||||
const isMissingPath = (cause: unknown): boolean =>
|
||||
typeof cause === "object" &&
|
||||
cause !== null &&
|
||||
"code" in cause &&
|
||||
cause.code === "ENOENT";
|
||||
const pathExists = async (path: string): Promise<boolean> => {
|
||||
try {
|
||||
await access(path);
|
||||
return true;
|
||||
} catch (cause: unknown) {
|
||||
if (isMissingPath(cause)) return false;
|
||||
throw cause;
|
||||
}
|
||||
};
|
||||
const releaseSchema = z.object({
|
||||
filename: z.string().url(),
|
||||
file_length: z.string().regex(/^\d+$/u),
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { mkdtemp, mkdir, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
macHopperCanonicalLauncherCandidates,
|
||||
macHopperLauncherCandidates,
|
||||
macHopperRenamedLauncherCandidates,
|
||||
} from "./MacHopperPaths.js";
|
||||
|
||||
describe("macOS Hopper launcher paths", () => {
|
||||
const home = "/fixture/home";
|
||||
|
||||
it("keeps canonical system and user candidates ahead of renamed bundles", () => {
|
||||
expect(macHopperCanonicalLauncherCandidates(home)).toEqual([
|
||||
"/Applications/Hopper Disassembler.app/Contents/MacOS/hopper",
|
||||
"/fixture/home/Applications/Hopper Disassembler.app/Contents/MacOS/hopper",
|
||||
]);
|
||||
expect(
|
||||
macHopperRenamedLauncherCandidates(
|
||||
home,
|
||||
new Map([
|
||||
[
|
||||
"/Applications",
|
||||
["Hopper z.app", "Hopper a.app", "Hopper Disassembler.app"],
|
||||
],
|
||||
["/fixture/home/Applications", ["Hopper User.app"]],
|
||||
]),
|
||||
),
|
||||
).toEqual([
|
||||
"/Applications/Hopper a.app/Contents/MacOS/hopper",
|
||||
"/Applications/Hopper z.app/Contents/MacOS/hopper",
|
||||
"/fixture/home/Applications/Hopper User.app/Contents/MacOS/hopper",
|
||||
]);
|
||||
});
|
||||
|
||||
it("always includes the installer's canonical user destination", () => {
|
||||
expect(macHopperLauncherCandidates(home)).toContain(
|
||||
"/fixture/home/Applications/Hopper Disassembler.app/Contents/MacOS/hopper",
|
||||
);
|
||||
});
|
||||
|
||||
it("finds renamed user bundles through the runtime filesystem scan", async () => {
|
||||
const temporary = await mkdtemp(join(tmpdir(), "rea-hopper-paths-"));
|
||||
const appDirectory = join(temporary, "Applications", "Hopper Preview.app");
|
||||
try {
|
||||
await mkdir(appDirectory, { recursive: true });
|
||||
expect([...macHopperLauncherCandidates(temporary)]).toContain(
|
||||
join(appDirectory, "Contents/MacOS/hopper"),
|
||||
);
|
||||
} finally {
|
||||
await rm(temporary, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { readdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
export const MAC_HOPPER_APPLICATION_DIRECTORY = "/Applications";
|
||||
export const MAC_HOPPER_APPLICATION_NAME = "Hopper Disassembler.app";
|
||||
export const MAC_HOPPER_USER_APPLICATIONS_DIRECTORY = "Applications";
|
||||
|
||||
export const MAC_HOPPER_SYSTEM_LAUNCHER = join(
|
||||
MAC_HOPPER_APPLICATION_DIRECTORY,
|
||||
MAC_HOPPER_APPLICATION_NAME,
|
||||
"Contents/MacOS/hopper",
|
||||
);
|
||||
|
||||
export const macHopperUserApplicationPath = (homeDirectory: string): string =>
|
||||
join(
|
||||
homeDirectory,
|
||||
MAC_HOPPER_USER_APPLICATIONS_DIRECTORY,
|
||||
MAC_HOPPER_APPLICATION_NAME,
|
||||
);
|
||||
|
||||
export const macHopperApplicationRoots = (
|
||||
homeDirectory: string,
|
||||
): readonly string[] => [
|
||||
MAC_HOPPER_APPLICATION_DIRECTORY,
|
||||
join(homeDirectory, MAC_HOPPER_USER_APPLICATIONS_DIRECTORY),
|
||||
];
|
||||
|
||||
export const macHopperCanonicalLauncherCandidates = (
|
||||
homeDirectory: string,
|
||||
): readonly string[] => [
|
||||
MAC_HOPPER_SYSTEM_LAUNCHER,
|
||||
join(macHopperUserApplicationPath(homeDirectory), "Contents/MacOS/hopper"),
|
||||
];
|
||||
|
||||
/** Build deterministic candidates from the discovered app bundle names. */
|
||||
export const macHopperRenamedLauncherCandidates = (
|
||||
homeDirectory: string,
|
||||
bundleNamesByRoot: ReadonlyMap<string, readonly string[]>,
|
||||
): readonly string[] =>
|
||||
macHopperApplicationRoots(homeDirectory).flatMap((root) =>
|
||||
[...new Set(bundleNamesByRoot.get(root) ?? [])]
|
||||
.filter(
|
||||
(name) =>
|
||||
/^Hopper.*\.app$/iu.test(name) &&
|
||||
name.toLocaleLowerCase("en-US") !==
|
||||
MAC_HOPPER_APPLICATION_NAME.toLocaleLowerCase("en-US"),
|
||||
)
|
||||
.sort()
|
||||
.map((name) => join(root, name, "Contents/MacOS/hopper")),
|
||||
);
|
||||
|
||||
const localBundleNames = (root: string): readonly string[] => {
|
||||
try {
|
||||
return readdirSync(root, { withFileTypes: true })
|
||||
.filter(
|
||||
(entry) => entry.isDirectory() && /^Hopper.*\.app$/iu.test(entry.name),
|
||||
)
|
||||
.map(({ name }) => name);
|
||||
} catch (cause: unknown) {
|
||||
// Missing or unreadable optional app directories contain no candidates.
|
||||
void cause;
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
/** Yield canonical paths first; enumerate renamed bundles only if needed. */
|
||||
export function* macHopperLauncherCandidates(
|
||||
homeDirectory: string,
|
||||
): Generator<string> {
|
||||
yield* macHopperCanonicalLauncherCandidates(homeDirectory);
|
||||
for (const root of macHopperApplicationRoots(homeDirectory))
|
||||
yield* macHopperRenamedLauncherCandidates(
|
||||
homeDirectory,
|
||||
new Map([[root, localBundleNames(root)]]),
|
||||
);
|
||||
}
|
||||
@@ -30,8 +30,8 @@ export const windowsAppDataRoots = (
|
||||
home: string,
|
||||
env: Readonly<NodeJS.ProcessEnv>,
|
||||
): { readonly roaming: string; readonly local: string } => ({
|
||||
roaming: env.APPDATA?.trim() || join(home, "AppData", "Roaming"),
|
||||
local: env.LOCALAPPDATA?.trim() || join(home, "AppData", "Local"),
|
||||
roaming: env.APPDATA || join(home, "AppData", "Roaming"),
|
||||
local: env.LOCALAPPDATA || join(home, "AppData", "Local"),
|
||||
});
|
||||
|
||||
/** The per-user root of MSIX package data containers. */
|
||||
@@ -165,6 +165,14 @@ export const redirectedWriteBlocker = (
|
||||
return `This process was started by MSIX package ${view.packageFamilyName}, which redirects its writes under ${view.virtualizedRoots.join(" and ")} into the package's private storage. Changes to ${redirected.join(", ")} would not reach the files other apps read. Rerun ${operation} from a terminal that this app did not start.`;
|
||||
};
|
||||
|
||||
/** An unavailable read-target observation does not establish a target change. */
|
||||
interface MsixReadTargetInspectionFailure {
|
||||
readonly _tag: "MsixReadTargetInspectionFailure";
|
||||
readonly path: string;
|
||||
readonly detail: string;
|
||||
readonly cause: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the package began reading its private copy after the AppData file
|
||||
* was chosen, for example by saving its settings when it quit.
|
||||
@@ -172,16 +180,23 @@ export const redirectedWriteBlocker = (
|
||||
export const packageReadTargetChanged = async (
|
||||
view: MsixPackageView,
|
||||
configPath: string,
|
||||
): Promise<boolean> => {
|
||||
if (configPath !== view.appDataConfigPath) return false;
|
||||
): Promise<Result<boolean, MsixReadTargetInspectionFailure>> => {
|
||||
if (configPath !== view.appDataConfigPath) return ok(false);
|
||||
try {
|
||||
await lstat(view.privateConfigPath);
|
||||
return true;
|
||||
return ok(true);
|
||||
} catch (cause: unknown) {
|
||||
return !(
|
||||
if (
|
||||
cause instanceof Error &&
|
||||
"code" in cause &&
|
||||
(cause.code === "ENOENT" || cause.code === "ENOTDIR")
|
||||
);
|
||||
)
|
||||
return ok(false);
|
||||
return err({
|
||||
_tag: "MsixReadTargetInspectionFailure",
|
||||
path: view.privateConfigPath,
|
||||
detail: cause instanceof Error ? cause.message : String(cause),
|
||||
cause,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,5 +1,8 @@
|
||||
import { z } from "zod";
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import type {
|
||||
AnalysisError,
|
||||
AnalysisErrorOptions,
|
||||
} from "../domain/analysisErrorBase.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisInputError,
|
||||
@@ -44,7 +47,9 @@ export const executeProviderOperation = async <
|
||||
confidence: "observed" | "derived",
|
||||
): Promise<Result<Evidence, AnalysisError>> => {
|
||||
if (options?.signal?.aborted)
|
||||
return err(new AnalysisCancelledError(operation));
|
||||
return err(
|
||||
new AnalysisCancelledError(operation, { cause: options.signal.reason }),
|
||||
);
|
||||
const request = schema.safeParse({ operation, input });
|
||||
if (!request.success)
|
||||
return err(new AnalysisInputError(operation, { cause: request.error }));
|
||||
@@ -64,14 +69,22 @@ export const executeProviderOperation = async <
|
||||
confidence,
|
||||
},
|
||||
);
|
||||
// A provider that cannot observe the signal may finish its work after the
|
||||
// caller cancelled, so re-check rather than reporting a completed operation
|
||||
// the caller no longer wants. The observation it completed is still this
|
||||
// provider's Evidence, so the cancellation carries it as the partial
|
||||
// observation instead of discarding a finished result.
|
||||
if (options?.signal?.aborted)
|
||||
return completeProviderEvidence(evidence, options?.signal);
|
||||
};
|
||||
|
||||
/** Classify completion after Evidence assembly, preserving facts on late abort. */
|
||||
export const completeProviderEvidence = (
|
||||
evidence: Evidence,
|
||||
signal?: AbortSignal,
|
||||
capturedOutput?: AnalysisErrorOptions["capturedOutput"],
|
||||
): Result<Evidence, AnalysisCancelledError> => {
|
||||
if (signal?.aborted)
|
||||
return err(
|
||||
new AnalysisCancelledError(operation, { partialObservation: evidence }),
|
||||
new AnalysisCancelledError(evidence.operation, {
|
||||
cause: signal.reason,
|
||||
partialObservation: evidence,
|
||||
...(capturedOutput === undefined ? {} : { capturedOutput }),
|
||||
}),
|
||||
);
|
||||
return ok(evidence);
|
||||
};
|
||||
@@ -36,6 +36,14 @@ export interface ReconstructionObligationCandidateSet {
|
||||
readonly limitations: readonly string[];
|
||||
}
|
||||
|
||||
/** Caller input cites review Evidence that its bundle does not contain. */
|
||||
export class MissingReviewEvidenceError extends TypeError {
|
||||
constructor(evidenceId: string) {
|
||||
super(`Reviewed obligation Evidence is missing: ${evidenceId}`);
|
||||
this.name = "MissingReviewEvidenceError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Derive conservative candidates from graph, process, and reviewed inputs. */
|
||||
export const deriveReconstructionObligationCandidates = (
|
||||
bundleInput: EvidenceBundle,
|
||||
@@ -322,9 +330,7 @@ const reviewedCandidate = (
|
||||
authority_references: reviewEvidenceIds.map((evidenceId) => {
|
||||
const evidence = records.get(evidenceId);
|
||||
if (evidence === undefined)
|
||||
throw new TypeError(
|
||||
`Reviewed obligation Evidence is missing: ${evidenceId}`,
|
||||
);
|
||||
throw new MissingReviewEvidenceError(evidenceId);
|
||||
return {
|
||||
evidence_id: evidenceId,
|
||||
authority: evidence.authority,
|
||||
@@ -413,7 +419,22 @@ const addCandidate = (
|
||||
...current.authority_references,
|
||||
...candidate.authority_references,
|
||||
]),
|
||||
definitionCount: current.definitionCount + candidate.definitionCount,
|
||||
// Each observation keeps its own unresolved authority and next steps.
|
||||
unavailable_authority: uniqueBy(
|
||||
[...current.unavailable_authority, ...candidate.unavailable_authority],
|
||||
(item) => item,
|
||||
),
|
||||
required_next_evidence: uniqueBy(
|
||||
[...current.required_next_evidence, ...candidate.required_next_evidence],
|
||||
(item) => item,
|
||||
),
|
||||
// A generated ID digests its derivation key, so two generated candidates
|
||||
// with one ID are the same claim seen by several records. Only another
|
||||
// reviewed definition can introduce a competing definition.
|
||||
definitionCount:
|
||||
current.origin === "generated" && candidate.origin === "generated"
|
||||
? current.definitionCount
|
||||
: current.definitionCount + candidate.definitionCount,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -12,6 +12,62 @@ import {
|
||||
import { deriveReconstructionObligationCandidates } from "./ReconstructionObligationCandidates.js";
|
||||
import { evaluateReconstructionObligationLedger } from "./ReconstructionObligationLedgerEvaluation.js";
|
||||
|
||||
/** Build a request whose packaged-process obligation closes as ready. */
|
||||
const readyProcessLedgerRequest = () => {
|
||||
const capture = processEvidence();
|
||||
const initial = build(request([capture]));
|
||||
const obligation = initial.obligations[0];
|
||||
if (obligation === undefined) throw new Error("Expected process obligation");
|
||||
const fixtureIds = obligation.required_case_kinds.map(
|
||||
(caseKind) => `packaged.${caseKind}`,
|
||||
);
|
||||
const proof = boundProofEvidence("packaged", {
|
||||
obligation_ids: [obligation.obligation_id],
|
||||
fixture_ids: fixtureIds,
|
||||
case_kinds: obligation.required_case_kinds,
|
||||
verifier_ids: ["verifier.packaged"],
|
||||
claim_ids: ["claim.process"],
|
||||
});
|
||||
const ledgerRequest = request([capture, proof], {
|
||||
manifest: {
|
||||
bindings: [
|
||||
{
|
||||
obligation_id: obligation.obligation_id,
|
||||
owner: {
|
||||
module_path: "src/processOwner.ts",
|
||||
symbol: "runProcess",
|
||||
owner_sha256: "2".repeat(64),
|
||||
},
|
||||
parser_type: null,
|
||||
original_cases: originalCases(obligation, capture.evidence_id),
|
||||
fixtures: obligation.required_case_kinds.map((caseKind) => ({
|
||||
fixture_id: `packaged.${caseKind}`,
|
||||
case_kind: caseKind,
|
||||
authority: "packaged-process",
|
||||
evidence_ids: [proof.evidence_id],
|
||||
})),
|
||||
verifier: {
|
||||
verifier_id: "verifier.packaged",
|
||||
claim_id: "claim.process",
|
||||
command: "npm run verify:package -- process",
|
||||
authority: "packaged-process",
|
||||
status: "pass",
|
||||
result_evidence_id: proof.evidence_id,
|
||||
enumerated_obligation_ids: [obligation.obligation_id],
|
||||
nondeterminism: {
|
||||
mode: "partial-order",
|
||||
specification:
|
||||
"Startup precedes teardown; worker completion is unordered.",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
contradictions: [],
|
||||
},
|
||||
});
|
||||
return ledgerRequest;
|
||||
};
|
||||
|
||||
describe("reconstruction obligation ledger", () => {
|
||||
it("emits the complete deterministic ledger and closure identity", () => {
|
||||
const evidence = proofEvidence("review");
|
||||
@@ -31,6 +87,49 @@ describe("reconstruction obligation ledger", () => {
|
||||
expect(first.status).toBe("open");
|
||||
});
|
||||
|
||||
it("merges repeated captures of one process scenario into one definition", () => {
|
||||
const first = processEvidence();
|
||||
const second = processEvidence({
|
||||
residual_unknowns: [{ scope: "network", reason: "Second run only." }],
|
||||
});
|
||||
expect(second.evidence_id).not.toBe(first.evidence_id);
|
||||
const single = build(request([first]));
|
||||
const repeated = build(request([first, second]));
|
||||
|
||||
const obligation = repeated.obligations[0];
|
||||
expect(repeated.obligations).toHaveLength(1);
|
||||
expect(obligation?.obligation_id).toBe(
|
||||
single.obligations[0]?.obligation_id,
|
||||
);
|
||||
expect(obligation?.diagnostics.map(({ code }) => code)).not.toContain(
|
||||
"duplicate-definition",
|
||||
);
|
||||
expect(
|
||||
obligation?.authority_references.map(({ evidence_id: id }) => id).sort(),
|
||||
).toEqual([first.evidence_id, second.evidence_id].sort());
|
||||
// The second capture's own uncertainty stays visible after the merge.
|
||||
expect(obligation?.unavailable_authority).toContain(
|
||||
"network: Second run only.",
|
||||
);
|
||||
});
|
||||
|
||||
it("still reports a reviewed definition that reuses a generated ID", () => {
|
||||
const capture = processEvidence();
|
||||
const generated = build(request([capture])).obligations[0];
|
||||
if (generated === undefined) throw new Error("Expected process obligation");
|
||||
const ledger = build(
|
||||
request([capture], {
|
||||
reviewed_obligations: [
|
||||
reviewedObligation(generated.obligation_id, capture.evidence_id),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(
|
||||
ledger.obligations[0]?.diagnostics.map(({ code }) => code),
|
||||
).toContain("duplicate-definition");
|
||||
});
|
||||
|
||||
it("does not let green unit evidence close a packaged-process obligation", () => {
|
||||
const capture = processEvidence();
|
||||
const proof = proofEvidence("green-unit");
|
||||
@@ -181,58 +280,7 @@ describe("reconstruction obligation ledger case closure", () => {
|
||||
});
|
||||
|
||||
it("closes only when every required case and verifier has comparable authority", () => {
|
||||
const capture = processEvidence();
|
||||
const initial = build(request([capture]));
|
||||
const obligation = initial.obligations[0];
|
||||
if (obligation === undefined)
|
||||
throw new Error("Expected process obligation");
|
||||
const fixtureIds = obligation.required_case_kinds.map(
|
||||
(caseKind) => `packaged.${caseKind}`,
|
||||
);
|
||||
const proof = boundProofEvidence("packaged", {
|
||||
obligation_ids: [obligation.obligation_id],
|
||||
fixture_ids: fixtureIds,
|
||||
case_kinds: obligation.required_case_kinds,
|
||||
verifier_ids: ["verifier.packaged"],
|
||||
claim_ids: ["claim.process"],
|
||||
});
|
||||
const ledgerRequest = request([capture, proof], {
|
||||
manifest: {
|
||||
bindings: [
|
||||
{
|
||||
obligation_id: obligation.obligation_id,
|
||||
owner: {
|
||||
module_path: "src/processOwner.ts",
|
||||
symbol: "runProcess",
|
||||
owner_sha256: "2".repeat(64),
|
||||
},
|
||||
parser_type: null,
|
||||
original_cases: originalCases(obligation, capture.evidence_id),
|
||||
fixtures: obligation.required_case_kinds.map((caseKind) => ({
|
||||
fixture_id: `packaged.${caseKind}`,
|
||||
case_kind: caseKind,
|
||||
authority: "packaged-process",
|
||||
evidence_ids: [proof.evidence_id],
|
||||
})),
|
||||
verifier: {
|
||||
verifier_id: "verifier.packaged",
|
||||
claim_id: "claim.process",
|
||||
command: "npm run verify:package -- process",
|
||||
authority: "packaged-process",
|
||||
status: "pass",
|
||||
result_evidence_id: proof.evidence_id,
|
||||
enumerated_obligation_ids: [obligation.obligation_id],
|
||||
nondeterminism: {
|
||||
mode: "partial-order",
|
||||
specification:
|
||||
"Startup precedes teardown; worker completion is unordered.",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
contradictions: [],
|
||||
},
|
||||
});
|
||||
const ledgerRequest = readyProcessLedgerRequest();
|
||||
const result = build(ledgerRequest);
|
||||
|
||||
expect(result.obligations[0]).toMatchObject({
|
||||
@@ -257,3 +305,54 @@ describe("reconstruction obligation ledger case closure", () => {
|
||||
expect(limited.coverage.status).toBe("partial");
|
||||
});
|
||||
});
|
||||
|
||||
describe("reconstruction obligation manifest references", () => {
|
||||
it("withholds readiness when a manifest entry names absent Evidence", () => {
|
||||
const ledgerRequest = readyProcessLedgerRequest();
|
||||
// A valid entry for the same case kind must not hide an absent reference.
|
||||
const absent = `ev_${"a".repeat(64)}`;
|
||||
const binding = ledgerRequest.manifest.bindings[0];
|
||||
if (binding === undefined) throw new Error("Expected binding");
|
||||
const variants: {
|
||||
readonly code: string;
|
||||
readonly binding: typeof binding;
|
||||
}[] = [
|
||||
{
|
||||
code: "missing-original-case",
|
||||
binding: {
|
||||
...binding,
|
||||
original_cases: [
|
||||
...binding.original_cases,
|
||||
{ kind: "positive", evidence_id: absent, location: "/old" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "missing-case",
|
||||
binding: {
|
||||
...binding,
|
||||
fixtures: [
|
||||
...binding.fixtures,
|
||||
{
|
||||
fixture_id: "packaged.old",
|
||||
case_kind: "positive",
|
||||
authority: "packaged-process",
|
||||
evidence_ids: [absent],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
for (const { code, binding: dangling } of variants) {
|
||||
const open = build({
|
||||
...ledgerRequest,
|
||||
manifest: { ...ledgerRequest.manifest, bindings: [dangling] },
|
||||
});
|
||||
expect(open.status).not.toBe("ready");
|
||||
expect(open.obligations[0]?.status).toBe("implemented");
|
||||
expect(open.obligations[0]?.diagnostics).toEqual([
|
||||
{ code, detail: expect.stringContaining(absent) },
|
||||
]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,10 @@ import {
|
||||
type ReviewedReconstructionObligation,
|
||||
} from "../domain/reconstructionObligationLedgerSchemas.js";
|
||||
|
||||
import { processCaptureSchema } from "../domain/process/processCapture.js";
|
||||
import {
|
||||
processCaptureSchema,
|
||||
type UnverifiedProcessCapture,
|
||||
} from "../domain/process/processCapture.js";
|
||||
/** Build controlled fixture Evidence used by reconstruction-ledger scenarios. */
|
||||
export const proofEvidence = (id: string) =>
|
||||
createEvidence(
|
||||
@@ -31,13 +34,18 @@ export const proofEvidence = (id: string) =>
|
||||
);
|
||||
|
||||
/** Use the canonical empty process capture as a controlled Evidence example. */
|
||||
export const processEvidence = () =>
|
||||
export const processEvidence = (
|
||||
overrides: Partial<UnverifiedProcessCapture> = {},
|
||||
) =>
|
||||
createEvidence(undefined, PROCESS_PROVIDER, {
|
||||
predicateType: "rea.process-capture",
|
||||
operation: "capture_process_scenario",
|
||||
parameters: {},
|
||||
result: jsonValueSchema.parse(
|
||||
processCaptureSchema.parse(EMPTY_PROCESS_CAPTURE_EXAMPLE),
|
||||
processCaptureSchema.parse({
|
||||
...EMPTY_PROCESS_CAPTURE_EXAMPLE,
|
||||
...overrides,
|
||||
}),
|
||||
),
|
||||
confidence: "observed",
|
||||
authority: "controlled-replay",
|
||||
|
||||
@@ -268,6 +268,14 @@ const evaluateBinding = (
|
||||
);
|
||||
continue;
|
||||
}
|
||||
for (const fixture of fixtures)
|
||||
for (const evidenceId of fixture.evidence_ids)
|
||||
if (!context.evidence.has(evidenceId))
|
||||
addDiagnostic(
|
||||
diagnostics,
|
||||
"missing-case",
|
||||
`${caseKind} fixture ${fixture.fixture_id} references Evidence ${evidenceId}, which is absent from the bundle.`,
|
||||
);
|
||||
if (
|
||||
!fixtures.some(
|
||||
(fixture) =>
|
||||
|
||||
@@ -2,6 +2,10 @@ import { z } from "zod";
|
||||
|
||||
import { AnalysisProtocolError } from "../domain/analysisErrorCore.js";
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import {
|
||||
DuplicateEvidenceRecordError,
|
||||
NonCanonicalEvidenceBundleError,
|
||||
} from "../domain/evidenceBundle.js";
|
||||
import { createEvidence, type Evidence } from "../domain/evidence.js";
|
||||
import { jsonObjectSchema, jsonValueSchema } from "../domain/jsonValue.js";
|
||||
import {
|
||||
@@ -13,7 +17,10 @@ import {
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { requestInputError, workflowInputError } from "./workflowInputError.js";
|
||||
import { JAVASCRIPT_APPLICATION_WORKFLOW_PROVIDER } from "./InvestigationProviders.js";
|
||||
import { deriveReconstructionObligationCandidates } from "./ReconstructionObligationCandidates.js";
|
||||
import {
|
||||
deriveReconstructionObligationCandidates,
|
||||
MissingReviewEvidenceError,
|
||||
} from "./ReconstructionObligationCandidates.js";
|
||||
import { evaluateReconstructionObligationLedger } from "./ReconstructionObligationLedgerEvaluation.js";
|
||||
|
||||
const OPERATION = "build_reconstruction_obligation_ledger" as const;
|
||||
@@ -45,6 +52,12 @@ export const buildReconstructionObligationLedgerEvidenceValidated = (
|
||||
});
|
||||
return ok(createLedgerEvidence(input, ledger));
|
||||
} catch (cause: unknown) {
|
||||
if (
|
||||
cause instanceof NonCanonicalEvidenceBundleError ||
|
||||
cause instanceof DuplicateEvidenceRecordError ||
|
||||
cause instanceof MissingReviewEvidenceError
|
||||
)
|
||||
return err(workflowInputError(OPERATION, cause));
|
||||
if (cause instanceof z.ZodError)
|
||||
return err(workflowInputError(OPERATION, cause));
|
||||
return err(
|
||||
|
||||
@@ -45,6 +45,14 @@ export const originalCaseDiagnostics = (
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// A valid entry for this case kind does not resolve another entry's
|
||||
// reference; every referenced Evidence ID must be in the bundle.
|
||||
for (const { evidence_id: evidenceId } of cases)
|
||||
if (!evidenceById.has(evidenceId))
|
||||
diagnostics.push({
|
||||
code: "missing-original-case",
|
||||
detail: `Original ${caseKind} case references Evidence ${evidenceId}, which is absent from the bundle.`,
|
||||
});
|
||||
if (
|
||||
!cases.some(({ evidence_id: evidenceId }) => {
|
||||
const evidence = evidenceById.get(evidenceId);
|
||||
|
||||
@@ -116,3 +116,204 @@ it("retains Kotlin source identity and language across the inventory projection"
|
||||
expect(parsed.parseFailures).toEqual([]);
|
||||
expect(parsed.relationships).toEqual([]);
|
||||
});
|
||||
|
||||
it("resolves runtime require package mains without an index and before typed indexes", () => {
|
||||
const files = [
|
||||
["main.cjs", 'require("./pkg");\nrequire("./dep");\n'],
|
||||
[
|
||||
"main.ts",
|
||||
'import dep = require("./typed");\n' +
|
||||
'require("./typed");\n' +
|
||||
'import data = require("./json-package");\n' +
|
||||
'require("./emitted.js");\n',
|
||||
],
|
||||
["pkg/package.json", '{"main":"actual.js"}\n'],
|
||||
["pkg/actual.js", 'module.exports = "package main";\n'],
|
||||
["dep.js", 'module.exports = "file";\n'],
|
||||
["dep/index.ts", 'export const value = "index";\n'],
|
||||
["typed.js", 'module.exports = "runtime file";\n'],
|
||||
["typed/index.ts", 'export const value = "source index";\n'],
|
||||
["json-package/package.json", '{"main":"data"}\n'],
|
||||
["json-package/data.json", '{"value":"package main"}\n'],
|
||||
["json-package/index.js", 'module.exports = "fallback index";\n'],
|
||||
["emitted.ts", 'export const value = "source counterpart";\n'],
|
||||
] as const;
|
||||
const read: ReferenceSourceRead = {
|
||||
root: "/selected/source",
|
||||
entries: files.map(([path, content]) => ({
|
||||
status: "read",
|
||||
kind: "file",
|
||||
path,
|
||||
bytes: Buffer.from(content),
|
||||
size: Buffer.byteLength(content),
|
||||
})),
|
||||
bytesRead: files.reduce(
|
||||
(sum, [, content]) => sum + Buffer.byteLength(content),
|
||||
0,
|
||||
),
|
||||
limitations: [],
|
||||
};
|
||||
|
||||
const parsed = parseReferenceSourceEntries(
|
||||
read,
|
||||
new Set(files.map(([path]) => path)),
|
||||
);
|
||||
|
||||
expect(parsed.relationships).toContainEqual({
|
||||
from_path: "main.cjs",
|
||||
to: "pkg/actual.js",
|
||||
kind: "requires",
|
||||
resolution: "internal",
|
||||
parse_state: "parsed",
|
||||
});
|
||||
expect(parsed.relationships).toContainEqual({
|
||||
from_path: "main.cjs",
|
||||
to: "dep.js",
|
||||
kind: "requires",
|
||||
resolution: "internal",
|
||||
parse_state: "parsed",
|
||||
});
|
||||
expect(parsed.relationships).toContainEqual({
|
||||
from_path: "main.ts",
|
||||
to: "typed/index.ts",
|
||||
kind: "requires",
|
||||
resolution: "internal",
|
||||
parse_state: "parsed",
|
||||
});
|
||||
expect(parsed.relationships).toContainEqual({
|
||||
from_path: "main.ts",
|
||||
to: "typed.js",
|
||||
kind: "requires",
|
||||
resolution: "internal",
|
||||
parse_state: "parsed",
|
||||
});
|
||||
expect(parsed.relationships).toContainEqual({
|
||||
from_path: "main.ts",
|
||||
to: "json-package/data.json",
|
||||
kind: "requires",
|
||||
resolution: "internal",
|
||||
parse_state: "parsed",
|
||||
});
|
||||
expect(parsed.relationships).toContainEqual({
|
||||
from_path: "main.ts",
|
||||
to: "emitted.ts",
|
||||
kind: "requires",
|
||||
resolution: "internal",
|
||||
parse_state: "parsed",
|
||||
});
|
||||
});
|
||||
|
||||
it("matches package manifests by exact or unique canonical directory identity", () => {
|
||||
const composed = "caf\u00e9";
|
||||
const decomposed = "cafe\u0301";
|
||||
const composedTwin = "r\u00e9sum\u00e9";
|
||||
const decomposedTwin = "re\u0301sume\u0301";
|
||||
const files = [
|
||||
[
|
||||
"main.cjs",
|
||||
[composed, composedTwin, decomposedTwin]
|
||||
.map((name) => `require(${JSON.stringify(`./${name}`)});`)
|
||||
.join("\n"),
|
||||
],
|
||||
[`${decomposed}/package.json`, '{"main":"unique.js"}\n'],
|
||||
[`${decomposed}/unique.js`, 'module.exports = "unique";\n'],
|
||||
[`${composed}/unique.js`, 'module.exports = "unrelated exact spelling";\n'],
|
||||
[`${composedTwin}/package.json`, '{"main":"exact-composed.js"}\n'],
|
||||
[`${composedTwin}/exact-composed.js`, 'module.exports = "composed";\n'],
|
||||
[`${decomposedTwin}/package.json`, '{"main":"exact-decomposed.js"}\n'],
|
||||
[
|
||||
`${decomposedTwin}/exact-decomposed.js`,
|
||||
'module.exports = "decomposed";\n',
|
||||
],
|
||||
] as const;
|
||||
const read: ReferenceSourceRead = {
|
||||
root: "/selected/source",
|
||||
entries: files.map(([path, content]) => ({
|
||||
status: "read",
|
||||
kind: "file",
|
||||
path,
|
||||
bytes: Buffer.from(content),
|
||||
size: Buffer.byteLength(content),
|
||||
})),
|
||||
bytesRead: files.reduce(
|
||||
(sum, [, content]) => sum + Buffer.byteLength(content),
|
||||
0,
|
||||
),
|
||||
limitations: [],
|
||||
};
|
||||
|
||||
const parsed = parseReferenceSourceEntries(
|
||||
read,
|
||||
new Set(files.map(([path]) => path)),
|
||||
);
|
||||
|
||||
expect(parsed.relationships.map(({ to }) => to)).toEqual([
|
||||
`${decomposed}/unique.js`,
|
||||
`${composedTwin}/exact-composed.js`,
|
||||
`${decomposedTwin}/exact-decomposed.js`,
|
||||
]);
|
||||
});
|
||||
|
||||
it("resolves extensionless imports and requires to .mts and .cts files and directory indexes", () => {
|
||||
const files = [
|
||||
[
|
||||
"src/index.ts",
|
||||
'import "./esm";\nimport "./pkg-esm";\nimport cjs = require("./cjs");\nimport pkgCjs = require("./pkg-cjs");\n',
|
||||
],
|
||||
["src/esm.mts", "export const esm = 1;\n"],
|
||||
["src/pkg-esm/index.mts", "export const pkgEsm = 2;\n"],
|
||||
["src/cjs.cts", "module.exports = { cjs: 3 };\n"],
|
||||
["src/pkg-cjs/index.cts", "module.exports = { pkgCjs: 4 };\n"],
|
||||
] as const;
|
||||
const read: ReferenceSourceRead = {
|
||||
root: "/selected/source",
|
||||
entries: files.map(([path, content]) => ({
|
||||
status: "read",
|
||||
kind: "file",
|
||||
path,
|
||||
bytes: Buffer.from(content),
|
||||
size: Buffer.byteLength(content),
|
||||
})),
|
||||
bytesRead: files.reduce(
|
||||
(sum, [, content]) => sum + Buffer.byteLength(content),
|
||||
0,
|
||||
),
|
||||
limitations: [],
|
||||
};
|
||||
const parsed = parseReferenceSourceEntries(
|
||||
read,
|
||||
new Set(files.map(([path]) => path)),
|
||||
);
|
||||
expect(parsed.relationships).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
from_path: "src/index.ts",
|
||||
to: "src/esm.mts",
|
||||
kind: "imports",
|
||||
resolution: "internal",
|
||||
parse_state: "parsed",
|
||||
},
|
||||
{
|
||||
from_path: "src/index.ts",
|
||||
to: "src/pkg-esm/index.mts",
|
||||
kind: "imports",
|
||||
resolution: "internal",
|
||||
parse_state: "parsed",
|
||||
},
|
||||
{
|
||||
from_path: "src/index.ts",
|
||||
to: "src/cjs.cts",
|
||||
kind: "requires",
|
||||
resolution: "internal",
|
||||
parse_state: "parsed",
|
||||
},
|
||||
{
|
||||
from_path: "src/index.ts",
|
||||
to: "src/pkg-cjs/index.cts",
|
||||
kind: "requires",
|
||||
resolution: "internal",
|
||||
parse_state: "parsed",
|
||||
},
|
||||
]),
|
||||
);
|
||||
});
|
||||
@@ -94,10 +94,183 @@ const failedEntry = (
|
||||
};
|
||||
};
|
||||
|
||||
/** A package directory's legacy `main` entry point, as its manifest declares it. */
|
||||
type PackageMain =
|
||||
| { readonly state: "declared"; readonly main: string }
|
||||
| { readonly state: "none" }
|
||||
| { readonly state: "unknown" };
|
||||
|
||||
/**
|
||||
* Read each inventoried `package.json` once. Node selects a non-empty string
|
||||
* `main` before a directory index; an unreadable or invalid manifest leaves
|
||||
* that selection unknown.
|
||||
*/
|
||||
const readPackageMains = (
|
||||
read: ReferenceSourceRead,
|
||||
): {
|
||||
readonly mains: ReadonlyMap<string, PackageMain>;
|
||||
readonly pathLookup: ReferenceSourcePathLookup;
|
||||
} => {
|
||||
const mains = new Map<string, PackageMain>();
|
||||
const manifestPaths: string[] = [];
|
||||
for (const entry of read.entries) {
|
||||
if (posix.basename(entry.path) !== "package.json") continue;
|
||||
manifestPaths.push(entry.path);
|
||||
const directory = posix.dirname(entry.path);
|
||||
if (mains.has(directory)) {
|
||||
mains.set(directory, { state: "unknown" });
|
||||
continue;
|
||||
}
|
||||
if (entry.status !== "read" || entry.kind !== "file") {
|
||||
mains.set(directory, { state: "unknown" });
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const manifest: unknown = JSON.parse(
|
||||
new TextDecoder("utf-8", { fatal: true }).decode(entry.bytes),
|
||||
);
|
||||
const main =
|
||||
typeof manifest === "object" && manifest !== null
|
||||
? Reflect.get(manifest, "main")
|
||||
: undefined;
|
||||
mains.set(
|
||||
directory,
|
||||
typeof main === "string" && main !== ""
|
||||
? { state: "declared", main }
|
||||
: { state: "none" },
|
||||
);
|
||||
} catch {
|
||||
mains.set(directory, { state: "unknown" });
|
||||
}
|
||||
}
|
||||
return {
|
||||
mains,
|
||||
pathLookup: createReferenceSourcePathLookup(manifestPaths),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Extension slots in probe order. Within a slot, files precede directory
|
||||
* indexes. The CommonJS loader tries `X.json` after `X.js` and before any
|
||||
* directory index; ESM and TypeScript require an explicit `.json`.
|
||||
*/
|
||||
const RESOLUTION_SUFFIXES: readonly (readonly string[])[] = [
|
||||
[""],
|
||||
[".ts"],
|
||||
[".mts"],
|
||||
[".cts"],
|
||||
[".js"],
|
||||
[".mjs"],
|
||||
[".cjs"],
|
||||
[".tsx"],
|
||||
[".jsx"],
|
||||
];
|
||||
const REQUIRE_RESOLUTION_SUFFIXES = RESOLUTION_SUFFIXES.map((slot) =>
|
||||
slot.includes(".js") ? [...slot, ".json"] : slot,
|
||||
);
|
||||
const NODE_REQUIRE_SUFFIXES = ["", ".js", ".json", ".node"] as const;
|
||||
|
||||
type InternalSpecifierResolutionMode =
|
||||
| "source-import"
|
||||
| "source-require"
|
||||
| "runtime-require";
|
||||
|
||||
const resolutionSuffixes = (
|
||||
mode: InternalSpecifierResolutionMode,
|
||||
): readonly (readonly string[])[] =>
|
||||
mode === "source-require" ? REQUIRE_RESOLUTION_SUFFIXES : RESOLUTION_SUFFIXES;
|
||||
|
||||
const nodeRequireFileCandidates = (path: string): string[] =>
|
||||
NODE_REQUIRE_SUFFIXES.map((suffix) => `${path}${suffix}`);
|
||||
|
||||
const nodeRequireIndexCandidates = (directory: string): string[] =>
|
||||
NODE_REQUIRE_SUFFIXES.filter((suffix) => suffix !== "").map((suffix) =>
|
||||
posix.join(directory, `index${suffix}`),
|
||||
);
|
||||
|
||||
/** Inventory facts an internal specifier resolves against. */
|
||||
interface SpecifierContext {
|
||||
readonly lookupPath: ReferenceSourcePathLookup;
|
||||
readonly packageMains: ReadonlyMap<string, PackageMain>;
|
||||
readonly packageManifestPathLookup: ReferenceSourcePathLookup;
|
||||
}
|
||||
|
||||
const packageMainForDirectory = (
|
||||
directory: string,
|
||||
context: SpecifierContext,
|
||||
): { readonly directory: string; readonly main: PackageMain } | undefined => {
|
||||
const manifestPath = context.packageManifestPathLookup.resolve(
|
||||
posix.join(directory, "package.json"),
|
||||
);
|
||||
if (manifestPath !== undefined) {
|
||||
const manifestDirectory = posix.dirname(manifestPath);
|
||||
const main = context.packageMains.get(manifestDirectory);
|
||||
return main === undefined
|
||||
? undefined
|
||||
: { directory: manifestDirectory, main };
|
||||
}
|
||||
return context.packageManifestPathLookup.has(
|
||||
posix.join(directory, "package.json"),
|
||||
)
|
||||
? { directory, main: { state: "unknown" } }
|
||||
: undefined;
|
||||
};
|
||||
|
||||
/** Apply a package main, retaining an existing index as the fallback target. */
|
||||
const selectPackageMain = (
|
||||
directory: string,
|
||||
indexFallback: string | undefined,
|
||||
context: SpecifierContext,
|
||||
mode: InternalSpecifierResolutionMode,
|
||||
): { to: string; resolution: "internal" | "unresolved" } => {
|
||||
const packageSelection = packageMainForDirectory(directory, context);
|
||||
const packageDirectory = packageSelection?.directory ?? directory;
|
||||
const manifest = packageSelection?.main;
|
||||
if (manifest === undefined || manifest.state === "none")
|
||||
return indexFallback === undefined
|
||||
? { to: directory, resolution: "unresolved" }
|
||||
: { to: indexFallback, resolution: "internal" };
|
||||
if (manifest.state === "unknown")
|
||||
return { to: packageDirectory, resolution: "unresolved" };
|
||||
// Node resolves `main` with path.resolve: an absolute main leaves the
|
||||
// package, and a trailing separator does not make the entry a directory.
|
||||
if (posix.isAbsolute(manifest.main))
|
||||
return { to: posix.normalize(manifest.main), resolution: "unresolved" };
|
||||
const target = posix
|
||||
.normalize(posix.join(packageDirectory, manifest.main))
|
||||
.replace(/(?<=.)\/+$/u, "");
|
||||
// Node would load a declared entry outside the imported root.
|
||||
if (logicalPathEscapesRoot(target))
|
||||
return { to: target, resolution: "unresolved" };
|
||||
const slots = resolutionSuffixes(mode);
|
||||
const candidates =
|
||||
mode === "runtime-require"
|
||||
? [
|
||||
...nodeRequireFileCandidates(target),
|
||||
...nodeRequireIndexCandidates(target),
|
||||
]
|
||||
: [
|
||||
...slots.flatMap((slot) =>
|
||||
slot.map((suffix) => `${target}${suffix}`),
|
||||
),
|
||||
...slots
|
||||
.flat()
|
||||
.filter((suffix) => suffix !== "")
|
||||
.map((suffix) => posix.join(target, `index${suffix}`)),
|
||||
];
|
||||
const selected = context.lookupPath.resolveCandidates(candidates);
|
||||
return selected === undefined
|
||||
? indexFallback === undefined
|
||||
? { to: packageDirectory, resolution: "unresolved" }
|
||||
: { to: indexFallback, resolution: "internal" }
|
||||
: { to: selected, resolution: "internal" };
|
||||
};
|
||||
|
||||
const resolveInternalSpecifier = (
|
||||
fromPath: string,
|
||||
specifier: string,
|
||||
lookupPath: ReferenceSourcePathLookup,
|
||||
context: SpecifierContext,
|
||||
mode: InternalSpecifierResolutionMode,
|
||||
): { to: string; resolution: "internal" | "unresolved" } => {
|
||||
if (posix.isAbsolute(specifier))
|
||||
return { to: specifier, resolution: "unresolved" };
|
||||
@@ -109,11 +282,23 @@ const resolveInternalSpecifier = (
|
||||
// Preserve directory intent before normalization removes terminal dot segments.
|
||||
const directoryReference =
|
||||
specifier.endsWith("/") || [".", ".."].includes(posix.basename(specifier));
|
||||
const suffixes = ["", ".ts", ".js", ".mjs", ".cjs", ".tsx", ".jsx"];
|
||||
const candidates = suffixes.flatMap((suffix) => [
|
||||
...(directoryReference ? [] : [`${normalized}${suffix}`]),
|
||||
posix.join(normalized, `index${suffix}`),
|
||||
]);
|
||||
// A directory resolves only to an index with an extension; neither Node nor
|
||||
// TypeScript selects an extensionless `index` file.
|
||||
const slots = resolutionSuffixes(mode);
|
||||
const candidates =
|
||||
mode === "runtime-require"
|
||||
? [
|
||||
...(directoryReference ? [] : nodeRequireFileCandidates(normalized)),
|
||||
...nodeRequireIndexCandidates(normalized),
|
||||
]
|
||||
: slots.flatMap((slot) => [
|
||||
...(directoryReference
|
||||
? []
|
||||
: slot.map((suffix) => `${normalized}${suffix}`)),
|
||||
...slot
|
||||
.filter((suffix) => suffix !== "")
|
||||
.map((suffix) => posix.join(normalized, `index${suffix}`)),
|
||||
]);
|
||||
// Retain present runtime files first; substitute only a missing counterpart.
|
||||
// Counterparts cover explicit filenames, not directory/index resolution.
|
||||
if (!directoryReference)
|
||||
@@ -122,9 +307,16 @@ const resolveInternalSpecifier = (
|
||||
({ path }) => path,
|
||||
),
|
||||
);
|
||||
const match = lookupPath.resolveCandidates(candidates);
|
||||
return match === undefined
|
||||
? { to: normalized, resolution: "unresolved" }
|
||||
const match = context.lookupPath.resolveCandidates(candidates);
|
||||
if (match === undefined)
|
||||
return packageMainForDirectory(normalized, context) !== undefined
|
||||
? selectPackageMain(normalized, undefined, context, mode)
|
||||
: { to: normalized, resolution: "unresolved" };
|
||||
const resolvedThroughIndex =
|
||||
posix.basename(match).startsWith("index.") &&
|
||||
posix.dirname(match).normalize("NFC") === normalized.normalize("NFC");
|
||||
return resolvedThroughIndex
|
||||
? selectPackageMain(posix.dirname(match), match, context, mode)
|
||||
: { to: match, resolution: "internal" };
|
||||
};
|
||||
|
||||
@@ -132,7 +324,7 @@ type ReadFile = Extract<ReferenceSourceEntry, { status: "read"; kind: "file" }>;
|
||||
|
||||
const appendFile = (
|
||||
entry: ReadFile,
|
||||
lookupPath: ReferenceSourcePathLookup,
|
||||
context: SpecifierContext,
|
||||
output: ParsedReferenceSourceEntries,
|
||||
): void => {
|
||||
const language = detectReferenceSourceLanguage(entry.path);
|
||||
@@ -152,17 +344,25 @@ const appendFile = (
|
||||
});
|
||||
if (parsed === undefined) return;
|
||||
for (const relationship of parsed.relationships) {
|
||||
if (relationship.resolution !== "internal") {
|
||||
output.relationships.push(relationship);
|
||||
const { resolutionStrategy, ...graphRelationship } = relationship;
|
||||
if (graphRelationship.resolution !== "internal") {
|
||||
output.relationships.push(graphRelationship);
|
||||
continue;
|
||||
}
|
||||
const mode: InternalSpecifierResolutionMode =
|
||||
resolutionStrategy === "runtime-require"
|
||||
? "runtime-require"
|
||||
: graphRelationship.kind === "requires"
|
||||
? "source-require"
|
||||
: "source-import";
|
||||
const resolved = resolveInternalSpecifier(
|
||||
relationship.from_path,
|
||||
relationship.to,
|
||||
lookupPath,
|
||||
graphRelationship.from_path,
|
||||
graphRelationship.to,
|
||||
context,
|
||||
mode,
|
||||
);
|
||||
output.relationships.push({
|
||||
...relationship,
|
||||
...graphRelationship,
|
||||
to: resolved.to,
|
||||
resolution: resolved.resolution,
|
||||
});
|
||||
@@ -172,11 +372,11 @@ const appendFile = (
|
||||
|
||||
const appendReadEntry = (
|
||||
entry: Extract<ReferenceSourceEntry, { status: "read" }>,
|
||||
lookupPath: ReferenceSourcePathLookup,
|
||||
context: SpecifierContext,
|
||||
output: ParsedReferenceSourceEntries,
|
||||
): void => {
|
||||
if (entry.kind === "file") {
|
||||
appendFile(entry, lookupPath, output);
|
||||
appendFile(entry, context, output);
|
||||
return;
|
||||
}
|
||||
const classifications = classifyReferenceSourcePath(entry.path);
|
||||
@@ -219,11 +419,16 @@ export const parseReferenceSourceEntries = (
|
||||
parseFailures: [],
|
||||
limitations: [...read.limitations],
|
||||
};
|
||||
const lookupPath = createReferenceSourcePathLookup(filePaths);
|
||||
const packageMainInventory = readPackageMains(read);
|
||||
const context: SpecifierContext = {
|
||||
lookupPath: createReferenceSourcePathLookup(filePaths),
|
||||
packageMains: packageMainInventory.mains,
|
||||
packageManifestPathLookup: packageMainInventory.pathLookup,
|
||||
};
|
||||
for (const entry of read.entries) {
|
||||
if (signal?.aborted === true) break;
|
||||
if (entry.status === "failed") output.entries.push(failedEntry(entry));
|
||||
else appendReadEntry(entry, lookupPath, output);
|
||||
else appendReadEntry(entry, context, output);
|
||||
}
|
||||
return output;
|
||||
};
|
||||
@@ -112,8 +112,8 @@ const buildIgnored = async (
|
||||
const retry = await retryRegularFileCleanup(cause, resources);
|
||||
if (retry.cleanup !== undefined) {
|
||||
const primary =
|
||||
cause.outcome.kind === "failed"
|
||||
? cause.outcome.cause
|
||||
retry.outcome.kind === "failed"
|
||||
? retry.outcome.cause
|
||||
: cause.cleanupCause;
|
||||
return err({
|
||||
...failure(
|
||||
|
||||
@@ -2,12 +2,17 @@ import type { Stats } from "node:fs";
|
||||
import { lstat, stat, type FileHandle } from "node:fs/promises";
|
||||
|
||||
import {
|
||||
RegularFileAdmissionFailure,
|
||||
openRegularFile,
|
||||
sameRegularFileState,
|
||||
RegularFileChangedError,
|
||||
RegularFileOperationCancelled,
|
||||
assertRegularFileActive,
|
||||
} from "../filesystem/RegularFile.js";
|
||||
import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
|
||||
import {
|
||||
RegularFileCleanupFailure,
|
||||
RegularFileCancelled,
|
||||
withOwnedRegularFile,
|
||||
type RegularFileOperationOutcome,
|
||||
} from "../filesystem/OwnedRegularFile.js";
|
||||
import { readBoundedFileBytes } from "../process/BoundedFileBytes.js";
|
||||
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
|
||||
import type { AnalysisCleanupObservation } from "../domain/analysisErrorBase.js";
|
||||
@@ -30,24 +35,9 @@ export class InvalidUtf8FileError extends TypeError {
|
||||
}
|
||||
}
|
||||
|
||||
export type RegularFileReadOutcome<Value> =
|
||||
| { readonly kind: "completed"; readonly value: Value }
|
||||
| { readonly kind: "failed"; readonly cause: unknown };
|
||||
export { RegularFileCleanupFailure } from "../filesystem/OwnedRegularFile.js";
|
||||
|
||||
/** A close failed after reading; this error transfers the exact handle owner. */
|
||||
export class RegularFileCleanupFailure<Value = unknown> extends Error {
|
||||
constructor(
|
||||
readonly path: string,
|
||||
readonly owner: OwnedFileHandle,
|
||||
readonly outcome: RegularFileReadOutcome<Value>,
|
||||
readonly cleanupCause: unknown,
|
||||
) {
|
||||
super(`Regular file cleanup failed: ${path}`, {
|
||||
cause: outcome.kind === "failed" ? outcome.cause : cleanupCause,
|
||||
});
|
||||
this.name = "RegularFileCleanupFailure";
|
||||
}
|
||||
}
|
||||
export type RegularFileReadOutcome<Value> = RegularFileOperationOutcome<Value>;
|
||||
|
||||
/** Retry a failed close through its retained owner and report any remaining cleanup. */
|
||||
export const retryRegularFileCleanup = async <Value>(
|
||||
@@ -57,14 +47,19 @@ export const retryRegularFileCleanup = async <Value>(
|
||||
readonly outcome: RegularFileReadOutcome<Value>;
|
||||
readonly cleanup?: AnalysisCleanupObservation;
|
||||
}> => {
|
||||
const outcome: RegularFileReadOutcome<Value> =
|
||||
failure.outcome.kind === "failed" &&
|
||||
failure.outcome.cause instanceof RegularFileOperationCancelled
|
||||
? { kind: "failed", cause: failure.outcome.cause.reason }
|
||||
: failure.outcome;
|
||||
const released = await resources.release({
|
||||
kind: "file-handle",
|
||||
handle: failure.owner,
|
||||
resource: failure.path,
|
||||
});
|
||||
if (released.kind === "released") return { outcome: failure.outcome };
|
||||
if (released.kind === "released") return { outcome };
|
||||
return {
|
||||
outcome: failure.outcome,
|
||||
outcome,
|
||||
cleanup: {
|
||||
reason: `${failureMessage(failure.cleanupCause)}; retry: ${failureMessage(released.cause)}`,
|
||||
resources: [failure.path],
|
||||
@@ -80,73 +75,61 @@ export const withRegularFile = async <Value>(
|
||||
path: string,
|
||||
read: (handle: FileHandle, stats: Stats) => Promise<Value>,
|
||||
options: RegularFileReadOptions = {},
|
||||
): Promise<Value> =>
|
||||
options.resources === undefined
|
||||
? withRegularFileOwned(path, read, options)
|
||||
: options.resources.run(() => withRegularFileOwned(path, read, options));
|
||||
|
||||
const withRegularFileOwned = async <Value>(
|
||||
path: string,
|
||||
read: (handle: FileHandle, stats: Stats) => Promise<Value>,
|
||||
options: RegularFileReadOptions,
|
||||
): Promise<Value> => {
|
||||
const { signal } = options;
|
||||
let handle: FileHandle;
|
||||
try {
|
||||
handle = await openRegularFile(path, {
|
||||
symlinks: options.symlinks ?? "follow",
|
||||
signal,
|
||||
});
|
||||
} catch (cause: unknown) {
|
||||
if (!(cause instanceof RegularFileAdmissionFailure)) throw cause;
|
||||
const failure = new RegularFileCleanupFailure(
|
||||
path,
|
||||
cause.owner,
|
||||
{ kind: "failed", cause: cause.cause },
|
||||
cause.cleanupCause,
|
||||
);
|
||||
if (options.resources === undefined) throw failure;
|
||||
const retry = await retryRegularFileCleanup(failure, options.resources);
|
||||
if (retry.cleanup !== undefined) throw failure;
|
||||
throw cause.cause;
|
||||
}
|
||||
const owner = new OwnedFileHandle(handle);
|
||||
let outcome: RegularFileReadOutcome<Value>;
|
||||
try {
|
||||
const stats = await handle.stat();
|
||||
signal?.throwIfAborted();
|
||||
const value = await read(handle, stats);
|
||||
signal?.throwIfAborted();
|
||||
const [opened, currentPath] = await Promise.all([
|
||||
handle.stat(),
|
||||
options.symlinks === "reject" ? lstat(path) : stat(path),
|
||||
]);
|
||||
if (
|
||||
!sameRegularFileState(stats, opened) ||
|
||||
!sameRegularFileState(stats, currentPath)
|
||||
)
|
||||
throw new RegularFileChangedError(path);
|
||||
outcome = { kind: "completed", value };
|
||||
} catch (cause: unknown) {
|
||||
outcome = { kind: "failed", cause };
|
||||
}
|
||||
try {
|
||||
await owner.close();
|
||||
} catch (cleanupCause: unknown) {
|
||||
const failure = new RegularFileCleanupFailure(
|
||||
path,
|
||||
owner,
|
||||
outcome,
|
||||
cleanupCause,
|
||||
);
|
||||
if (options.resources === undefined) throw failure;
|
||||
const retry = await retryRegularFileCleanup(failure, options.resources);
|
||||
if (retry.cleanup !== undefined) throw failure;
|
||||
if (retry.outcome.kind === "failed") throw retry.outcome.cause;
|
||||
return retry.outcome.value;
|
||||
}
|
||||
if (outcome.kind === "failed") throw outcome.cause;
|
||||
return outcome.value;
|
||||
const { signal, resources } = options;
|
||||
let admitted: FileHandle | undefined;
|
||||
const operation = async () => {
|
||||
try {
|
||||
return await withOwnedRegularFile(
|
||||
path,
|
||||
{
|
||||
...(signal === undefined ? {} : { signal }),
|
||||
symlinks: options.symlinks ?? "follow",
|
||||
},
|
||||
async (handle) => {
|
||||
admitted = handle;
|
||||
const stats = await handle.stat();
|
||||
assertRegularFileActive(signal);
|
||||
const value = await read(handle, stats);
|
||||
assertRegularFileActive(signal);
|
||||
const [opened, currentPath] = await Promise.all([
|
||||
handle.stat(),
|
||||
options.symlinks === "reject" ? lstat(path) : stat(path),
|
||||
]);
|
||||
if (
|
||||
!sameRegularFileState(stats, opened) ||
|
||||
!sameRegularFileState(stats, currentPath)
|
||||
)
|
||||
throw new RegularFileChangedError(path);
|
||||
assertRegularFileActive(signal);
|
||||
return value;
|
||||
},
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof RegularFileOperationCancelled) throw cause.reason;
|
||||
if (
|
||||
cause instanceof RegularFileCancelled &&
|
||||
cause.owner.handle === admitted
|
||||
)
|
||||
throw cause.reason;
|
||||
if (
|
||||
!(cause instanceof RegularFileCleanupFailure) ||
|
||||
resources === undefined
|
||||
)
|
||||
throw cause;
|
||||
const retry = await retryRegularFileCleanup(cause, resources);
|
||||
if (retry.cleanup !== undefined) throw cause;
|
||||
if (retry.outcome.kind === "failed") throw retry.outcome.cause;
|
||||
return retry.outcome.value;
|
||||
}
|
||||
};
|
||||
const value = await (resources === undefined
|
||||
? operation()
|
||||
: resources.run(operation));
|
||||
// A retained close may have recovered after the descriptor owner's outcome
|
||||
// was captured. Include that retry and the scope's finalization in cancellation.
|
||||
signal?.throwIfAborted();
|
||||
return value;
|
||||
};
|
||||
|
||||
/** Admit a regular file without waiting for a pipe, then read its verified handle. */
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { join, resolve } from "node:path";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { FakeSetupHost, options } from "./Setup.fixture.js";
|
||||
import { runSetup } from "./Setup.js";
|
||||
import { setupRegistrationCommand } from "./SetupHost.js";
|
||||
import { CURRENT_ENTRYPOINT_PATH } from "../process/CurrentEntrypointPath.js";
|
||||
import { discoverSetupState } from "./SetupPlan.js";
|
||||
import type { MsixPackageView } from "./MsixPackageStorage.js";
|
||||
import type { SetupClient } from "./SupportedClients.js";
|
||||
@@ -205,9 +206,10 @@ describe("configured Linux Hopper selection", () => {
|
||||
host.hopperLauncherConfigured = true;
|
||||
host.linuxDemoRuntimeMissing = true;
|
||||
const doctor = await host.doctor();
|
||||
const initial = await host.initialSetupState();
|
||||
const discovery = await discoverSetupState({
|
||||
host,
|
||||
providerEnvironment: { HOPPER_LAUNCHER_PATH: host.hopper },
|
||||
providerConfigured: initial.providerConfigured,
|
||||
forceHopperInstall: false,
|
||||
proposeHopper: true,
|
||||
hopperLauncherConfigured: true,
|
||||
@@ -226,9 +228,10 @@ describe("configured Linux Hopper selection", () => {
|
||||
};
|
||||
host.hopper = "/opt/hopper/bin/Hopper";
|
||||
host.unsupportedHopperVersion = true;
|
||||
const initial = await host.initialSetupState();
|
||||
const discovery = await discoverSetupState({
|
||||
host,
|
||||
providerEnvironment: { HOPPER_LAUNCHER_PATH: host.hopper },
|
||||
providerConfigured: initial.providerConfigured,
|
||||
forceHopperInstall: false,
|
||||
proposeHopper: true,
|
||||
initialDoctor: await host.doctor(),
|
||||
@@ -333,13 +336,13 @@ describe("setup workflow action selection", () => {
|
||||
|
||||
expect(result.status).toBe("ready");
|
||||
expect(host.configuredCommands).toEqual([
|
||||
setupRegistrationCommand("win32", undefined),
|
||||
]);
|
||||
expect(setupRegistrationCommand("win32", undefined)).toEqual([
|
||||
process.execPath,
|
||||
resolve(process.argv[1] ?? "rea"),
|
||||
"mcp",
|
||||
setupRegistrationCommand("win32", undefined, CURRENT_ENTRYPOINT_PATH),
|
||||
]);
|
||||
expect(
|
||||
setupRegistrationCommand("win32", undefined, CURRENT_ENTRYPOINT_PATH),
|
||||
).toEqual([process.execPath, CURRENT_ENTRYPOINT_PATH, "mcp"]);
|
||||
expect(setupRegistrationCommand("win32", undefined, undefined)).toEqual([]);
|
||||
expect(setupRegistrationCommand("win32", "npx", undefined)).not.toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -357,7 +360,7 @@ describe("setup workflow action selection", () => {
|
||||
],
|
||||
[
|
||||
"readback",
|
||||
"Agent configuration could not be verified after writing. Repair the configuration file or restore its `.rea.backup`, then rerun setup.",
|
||||
"Agent configuration could not be verified. Repair the configuration file, then rerun setup.",
|
||||
],
|
||||
] as const)("explains %s configuration recovery", async (reason, message) => {
|
||||
const host = new FakeSetupHost();
|
||||
@@ -372,6 +375,25 @@ describe("setup workflow action selection", () => {
|
||||
expect(result.remediation).toBe(message);
|
||||
expect(result.remediation).not.toContain("internal_client_key");
|
||||
});
|
||||
|
||||
it("names the recovery backup that holds the restored original after a readback failure", async () => {
|
||||
const host = new FakeSetupHost();
|
||||
host.hopper = "/Applications/Hopper";
|
||||
host.clients = [{ name: "cursor", configPath: "/agent.json" }];
|
||||
const backupPath = `/agent.json.rea.backup.${"a".repeat(64)}.recovery`;
|
||||
host.clientResults.set("cursor", {
|
||||
status: "failed",
|
||||
reason: "readback",
|
||||
backupPath,
|
||||
});
|
||||
const result = await runSetup(
|
||||
{ ...options(true), clientIds: ["cursor"] },
|
||||
host,
|
||||
);
|
||||
expect(result.remediation).toBe(
|
||||
`Agent configuration could not be verified after writing. The original was restored and its backup at ${backupPath} was retained. Repair the configuration file or restore that backup, then rerun setup.`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MSIX Claude Desktop setup", () => {
|
||||
|
||||
@@ -16,6 +16,7 @@ import { PRODUCT_IDENTITY, SDK_IDENTITY } from "../identity.js";
|
||||
import { CATALOG_IDENTITY } from "../catalogIdentity.js";
|
||||
import type { ClientRegistrationStatus } from "./ClientRegistrationStatus.js";
|
||||
import { setupRegistrationCommand } from "./SetupHost.js";
|
||||
import { CURRENT_ENTRYPOINT_PATH } from "../process/CurrentEntrypointPath.js";
|
||||
|
||||
/** Recording setup host for service-level planning and recovery tests. */
|
||||
export class FakeSetupHost implements SetupHost {
|
||||
@@ -24,7 +25,7 @@ export class FakeSetupHost implements SetupHost {
|
||||
skillDestinations(clientIds: readonly string[]) {
|
||||
return skillDestinations(this.homeDirectory, clientIds);
|
||||
}
|
||||
readonly registrationCommand: readonly string[];
|
||||
registrationCommand: readonly string[];
|
||||
registrationCommandBlocker?: string;
|
||||
nodeVersion = "24.18.0";
|
||||
version: string | undefined = "14.5";
|
||||
@@ -57,7 +58,11 @@ export class FakeSetupHost implements SetupHost {
|
||||
|
||||
constructor(platform: NodeJS.Platform = "darwin") {
|
||||
this.platform = platform;
|
||||
this.registrationCommand = setupRegistrationCommand(platform, undefined);
|
||||
this.registrationCommand = setupRegistrationCommand(
|
||||
platform,
|
||||
undefined,
|
||||
CURRENT_ENTRYPOINT_PATH,
|
||||
);
|
||||
}
|
||||
|
||||
macosVersion = (): Promise<string | undefined> =>
|
||||
@@ -69,6 +74,7 @@ export class FakeSetupHost implements SetupHost {
|
||||
...(this.hopperLauncherConfigured
|
||||
? { hopperLauncherConfigured: true }
|
||||
: {}),
|
||||
providerConfigured: this.hopper !== undefined || this.ghidra !== undefined,
|
||||
providerEnvironment: {
|
||||
...(this.hopper === undefined
|
||||
? {}
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { SetupProgressEvent } from "./SetupTypes.js";
|
||||
import { FakeSetupHost, options } from "./Setup.fixture.js";
|
||||
import { SetupSkillInstallError } from "./SetupSkill.js";
|
||||
import { err, ok } from "../domain/result.js";
|
||||
import { HopperReleaseError } from "./HopperReleaseFeed.js";
|
||||
|
||||
const linkedSkillFailure = () =>
|
||||
new SetupSkillInstallError({
|
||||
@@ -16,6 +17,44 @@ const linkedSkillFailure = () =>
|
||||
cause: undefined,
|
||||
});
|
||||
|
||||
describe("Linux publisher cache approval", () => {
|
||||
it.each([true, false])(
|
||||
"discloses the selected cache or blocks an unresolved path before approval: %s",
|
||||
async (valid) => {
|
||||
const host = Object.assign(new FakeSetupHost("linux"), {
|
||||
linuxHopperReleaseCache: valid
|
||||
? ok("/caller/cache/rea/hopper-releases")
|
||||
: err(
|
||||
new HopperReleaseError(
|
||||
"verification_unavailable",
|
||||
"Invalid selected cache path",
|
||||
),
|
||||
),
|
||||
});
|
||||
host.distribution = {
|
||||
id: "ubuntu",
|
||||
versionId: "24.04",
|
||||
packageFamily: "deb",
|
||||
hopperSetupSupported: true,
|
||||
};
|
||||
const result = await runSetup(options(false, true), host);
|
||||
if (valid)
|
||||
expect(
|
||||
result.plannedActions.find(({ kind }) => kind === "install_hopper")
|
||||
?.detail,
|
||||
).toContain("/caller/cache/rea/hopper-releases/<launcher-sha256>");
|
||||
else
|
||||
expect(result).toMatchObject({
|
||||
status: "needs_human",
|
||||
plannedActions: [],
|
||||
});
|
||||
expect(host.hopperInstalls).toBe(0);
|
||||
expect(host.configurations).toBe(0);
|
||||
expect(host.skillInstalls).toBe(0);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("selected skill admission", () => {
|
||||
it.each(["codex", "shared"] as const)(
|
||||
"keeps %s choices when an unselected client is linked",
|
||||
@@ -136,6 +175,112 @@ describe("skill plan destinations", () => {
|
||||
});
|
||||
|
||||
describe("setup workflow", () => {
|
||||
it("blocks client registration when no executable entrypoint is available", async () => {
|
||||
const host = new FakeSetupHost("win32");
|
||||
host.registrationCommand = [];
|
||||
host.registrationCommandBlocker =
|
||||
"REA could not determine the current executable entrypoint.";
|
||||
host.clients = [{ name: "codex", configPath: "C:\\config\\codex.toml" }];
|
||||
|
||||
const result = await runSetup(
|
||||
{ ...options(true), clientIds: ["codex"], installSkill: false },
|
||||
host,
|
||||
);
|
||||
|
||||
expect(result.status).toBe("needs_human");
|
||||
expect(result.plannedActions).toEqual([]);
|
||||
expect(result.remediation).toBe(host.registrationCommandBlocker);
|
||||
expect(host.configurations).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps a relative Node registration out of setup defaults when cwd happens to match", async () => {
|
||||
const host = new FakeSetupHost();
|
||||
host.clients = [{ name: "cursor", configPath: "/cursor.json" }];
|
||||
host.productRegistrations = [
|
||||
{
|
||||
client: "cursor",
|
||||
config_path: "/cursor.json",
|
||||
command: [process.execPath, "./rea.mjs", "mcp"],
|
||||
state: "stale",
|
||||
remediation: "Run rea setup to refresh this registration.",
|
||||
},
|
||||
];
|
||||
|
||||
const result = await runSetup(
|
||||
{ ...options(false), clientIds: [], installSkill: false },
|
||||
host,
|
||||
);
|
||||
|
||||
expect(result.clientStates).toContainEqual(
|
||||
expect.objectContaining({
|
||||
client: expect.objectContaining({ name: "cursor" }),
|
||||
status: "unknown",
|
||||
configured: false,
|
||||
}),
|
||||
);
|
||||
expect(result.plannedActions).toEqual([]);
|
||||
expect(host.configurations).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setup client selection", () => {
|
||||
it.each([true, false])(
|
||||
"handles repeated clients once in first-selected order (dry run: %s)",
|
||||
async (dryRun) => {
|
||||
const host = new FakeSetupHost();
|
||||
host.hopper = "/Applications/Hopper";
|
||||
host.skill = "unchanged";
|
||||
host.clients = [
|
||||
{ name: "cursor", configPath: "/cursor.json" },
|
||||
{ name: "codex", configPath: "/codex.toml", format: "toml" },
|
||||
];
|
||||
const inspected: string[] = [];
|
||||
const configured: string[] = [];
|
||||
const inspect = host.inspectClientConfiguration;
|
||||
const configure = host.configureClient;
|
||||
host.inspectClientConfiguration = (client) => {
|
||||
inspected.push(client.name);
|
||||
return inspect(client);
|
||||
};
|
||||
host.configureClient = (client, environment, command) => {
|
||||
configured.push(client.name);
|
||||
return configure(client, environment, command);
|
||||
};
|
||||
|
||||
const result = await runSetup(
|
||||
{
|
||||
...options(true),
|
||||
clientIds: ["codex", "cursor", "codex", "cursor"],
|
||||
installSkill: false,
|
||||
dryRun,
|
||||
},
|
||||
host,
|
||||
);
|
||||
|
||||
expect(result.status).toBe(dryRun ? "planned" : "ready");
|
||||
expect(result.plannedActions.map(({ id }) => id)).toEqual([
|
||||
"configure_client:codex",
|
||||
"configure_client:cursor",
|
||||
]);
|
||||
expect(inspected).toEqual(["codex", "cursor"]);
|
||||
expect(configured).toEqual(dryRun ? [] : ["codex", "cursor"]);
|
||||
expect(result.appliedActions).toEqual(
|
||||
dryRun ? [] : ["configured_codex", "configured_cursor"],
|
||||
);
|
||||
if (!dryRun) {
|
||||
expect(result.clients).toEqual({
|
||||
codex: { status: "configured" },
|
||||
cursor: { status: "configured" },
|
||||
});
|
||||
expect(host.doctorScopes.at(-1)?.clients).toEqual(["codex", "cursor"]);
|
||||
}
|
||||
expect(host.hopperInstalls).toBe(0);
|
||||
expect(host.skillInstalls).toBe(0);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("setup plan application", () => {
|
||||
it("returns a complete plan without mutation", async () => {
|
||||
const host = new FakeSetupHost();
|
||||
host.clients = [{ name: "cursor", configPath: "/cursor.json" }];
|
||||
@@ -262,6 +407,25 @@ describe("setup workflow", () => {
|
||||
expect(result.remediation).toBeUndefined();
|
||||
});
|
||||
|
||||
it("records an installed Hopper when opening the app needs manual recovery", async () => {
|
||||
const host = new FakeSetupHost("darwin");
|
||||
host.installHopper = () =>
|
||||
Promise.resolve({
|
||||
status: "installed",
|
||||
launcherPath: "/Applications/Hopper.app/Contents/MacOS/hopper",
|
||||
warning:
|
||||
"Hopper was installed, but macOS could not open it. Open it manually.",
|
||||
});
|
||||
|
||||
const result = await runSetup(options(true, true), host);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: "needs_human",
|
||||
appliedActions: ["installed_hopper"],
|
||||
remediation: expect.stringContaining("Open it manually"),
|
||||
});
|
||||
});
|
||||
|
||||
it("reuses existing Hopper without selecting every detected client", async () => {
|
||||
const host = new FakeSetupHost();
|
||||
host.hopper = "/Applications/Hopper";
|
||||
|
||||
+25
-10
@@ -111,7 +111,7 @@ const runSetupWorkflow = async (
|
||||
let { hopperPath, providerEnvironment } = initialState;
|
||||
const discovery = await discoverSetupState({
|
||||
host,
|
||||
providerEnvironment,
|
||||
providerConfigured: initialState.providerConfigured,
|
||||
forceHopperInstall: options.installHopper,
|
||||
hopperLauncherConfigured: initialState.hopperLauncherConfigured === true,
|
||||
proposeHopper:
|
||||
@@ -128,11 +128,15 @@ const runSetupWorkflow = async (
|
||||
options.installSkill === undefined;
|
||||
const interactiveSelection =
|
||||
confirm !== undefined && !options.structured && clientSelectionAllowed;
|
||||
let selectedClientIds = options.allDetectedClients
|
||||
? discovery.detectedClientIds
|
||||
: (options.clientIds ??
|
||||
options.readinessScope?.clients ??
|
||||
discovery.defaultClientIds);
|
||||
let selectedClientIds = [
|
||||
...new Set(
|
||||
options.allDetectedClients
|
||||
? discovery.detectedClientIds
|
||||
: (options.clientIds ??
|
||||
options.readinessScope?.clients ??
|
||||
discovery.defaultClientIds),
|
||||
),
|
||||
];
|
||||
let selectedSkillClientIds =
|
||||
options.skillClientIds ??
|
||||
options.readinessScope?.skillClients ??
|
||||
@@ -306,6 +310,7 @@ const runSetupWorkflow = async (
|
||||
}
|
||||
let selectedClients: readonly SetupClient[] =
|
||||
planSelection.clientsToConfigure;
|
||||
let hopperWarning: string | undefined;
|
||||
if (
|
||||
planSelection.installHopper &&
|
||||
(interactiveApproval || options.installHopper)
|
||||
@@ -322,7 +327,12 @@ const runSetupWorkflow = async (
|
||||
clientStates,
|
||||
});
|
||||
if ("failure" in install) return install.failure;
|
||||
({ hopperPath, providerEnvironment, selectedClients } = install);
|
||||
({
|
||||
hopperPath,
|
||||
providerEnvironment,
|
||||
selectedClients,
|
||||
warning: hopperWarning,
|
||||
} = install);
|
||||
}
|
||||
const clientFailure = await configureDetectedClients({
|
||||
host,
|
||||
@@ -355,6 +365,7 @@ const runSetupWorkflow = async (
|
||||
doctor.healthy,
|
||||
hopperPath,
|
||||
),
|
||||
hopperWarning,
|
||||
manualRemediation,
|
||||
);
|
||||
return {
|
||||
@@ -407,14 +418,17 @@ const installHopperAction = async (input: {
|
||||
};
|
||||
}
|
||||
input.appliedActions.push("installed_hopper");
|
||||
const warning = [installed.warning, installed.cleanupFailure]
|
||||
.filter((value): value is string => value !== undefined)
|
||||
.join(" ");
|
||||
emitProgress(input.options, {
|
||||
actionId: "install_hopper",
|
||||
label,
|
||||
state: installed.cleanupFailure === undefined ? "completed" : "warning",
|
||||
state: warning === "" ? "completed" : "warning",
|
||||
detail:
|
||||
installed.cleanupFailure === undefined
|
||||
warning === ""
|
||||
? installed.launcherPath
|
||||
: `${installed.launcherPath}; ${installed.cleanupFailure}`,
|
||||
: `${installed.launcherPath}; ${warning}`,
|
||||
});
|
||||
const providerEnvironment = {
|
||||
...input.providerEnvironment,
|
||||
@@ -423,6 +437,7 @@ const installHopperAction = async (input: {
|
||||
return {
|
||||
hopperPath: installed.launcherPath,
|
||||
providerEnvironment,
|
||||
...(warning === "" ? {} : { warning }),
|
||||
selectedClients: await filterClientsNeedingConfigure(
|
||||
input.host,
|
||||
input.selectedClients,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type ClientConfigurationSource,
|
||||
} from "./ClientConfigurationFiles.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { ConfigurationError } from "../domain/configurationErrors.js";
|
||||
import {
|
||||
clientRegistrationEntry,
|
||||
clientConfigurationValuesEqual,
|
||||
@@ -23,11 +24,19 @@ import { dshDocument, DshCordisDocument } from "./DshCordisPatch.js";
|
||||
import { mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { dirname } from "node:path";
|
||||
import writeFileAtomic from "write-file-atomic";
|
||||
import { clientRegistrationEnvironment } from "./ClientRegistrationEnvironment.js";
|
||||
import {
|
||||
clientRegistrationEnvironment,
|
||||
clientRegistrationEnvironmentDetails,
|
||||
type ProviderRequestBudgetResolver,
|
||||
} from "./ClientRegistrationEnvironment.js";
|
||||
|
||||
import { PRODUCT_IDENTITY } from "../identity.js";
|
||||
import { npxRegistrationCommand } from "./ClientRegistrationIdentity.js";
|
||||
import { MCP_STARTUP_POLICY } from "../mcpStartupPolicy.js";
|
||||
import {
|
||||
MCP_STARTUP_POLICY,
|
||||
recommendedMcpToolTimeoutMs,
|
||||
recommendedMcpToolTimeoutSeconds,
|
||||
} from "../mcpStartupPolicy.js";
|
||||
import { resolveClientConfigTransactionPath } from "./ClientConfigPath.js";
|
||||
import { packageReadTargetChanged } from "./MsixPackageStorage.js";
|
||||
import {
|
||||
@@ -35,7 +44,7 @@ import {
|
||||
RegularFileCleanupFailure,
|
||||
} from "./RegularFileRead.js";
|
||||
import {
|
||||
preserveRecoveryBackup,
|
||||
preserveCurrentRecoveryBackup,
|
||||
selectCurrentRecoveryBackup,
|
||||
} from "./SetupRecoveryBackup.js";
|
||||
import type { ClientConfigurationFileError } from "./ClientConfigurationFiles.js";
|
||||
@@ -65,6 +74,8 @@ export class ClientConfigurationCleanupRollbackFailure extends RegularFileCleanu
|
||||
[failure.cleanupCause, new Error(configurationFailure.failure.detail)],
|
||||
`Configuration readback cleanup and rollback failed: ${configurationFailure.failure.path}`,
|
||||
),
|
||||
failure.retention,
|
||||
failure.completionCancellation,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -74,16 +85,23 @@ export const configureClientConfiguration = (
|
||||
client: SetupClient,
|
||||
environment: SetupProviderEnvironment = {},
|
||||
command: readonly string[] = npxRegistrationCommand(),
|
||||
resolveProviderRequestBudget?: ProviderRequestBudgetResolver,
|
||||
): Promise<ClientConfigurationResult> => {
|
||||
if (client.configPathError !== undefined)
|
||||
return Promise.resolve({ status: "failed", reason: "path" });
|
||||
return configureClientDocument(client, environment, command);
|
||||
return configureClientDocument(
|
||||
client,
|
||||
environment,
|
||||
command,
|
||||
resolveProviderRequestBudget,
|
||||
);
|
||||
};
|
||||
|
||||
const configureClientDocument = async (
|
||||
client: SetupClient,
|
||||
environment: SetupProviderEnvironment,
|
||||
command: readonly string[],
|
||||
resolveProviderRequestBudget?: ProviderRequestBudgetResolver,
|
||||
): Promise<ClientConfigurationResult> => {
|
||||
const format = client.format;
|
||||
if (format === undefined || format === "unsupported")
|
||||
@@ -111,6 +129,7 @@ const configureClientDocument = async (
|
||||
command,
|
||||
existing,
|
||||
files.value,
|
||||
resolveProviderRequestBudget,
|
||||
),
|
||||
);
|
||||
if (!policy.ok) {
|
||||
@@ -149,6 +168,7 @@ const configureClientDocument = async (
|
||||
command,
|
||||
parsed,
|
||||
files.value,
|
||||
resolveProviderRequestBudget,
|
||||
),
|
||||
);
|
||||
if (!policy.ok) {
|
||||
@@ -177,6 +197,7 @@ const configureClientDocument = async (
|
||||
servers: { ...fallback.value, ...parsed.servers },
|
||||
},
|
||||
files.value,
|
||||
resolveProviderRequestBudget,
|
||||
);
|
||||
if (registrationCurrent(parsed, desired)) return { status: "unchanged" };
|
||||
return persistClientConfiguration({
|
||||
@@ -211,32 +232,32 @@ const persistClientConfiguration = async ({
|
||||
}: ClientConfigurationUpdate): Promise<ClientConfigurationResult> => {
|
||||
// The plan chose this file; an MSIX package may have switched to its
|
||||
// private copy since, for example when it quit.
|
||||
if (
|
||||
client.msixPackage !== undefined &&
|
||||
(await packageReadTargetChanged(client.msixPackage, client.configPath))
|
||||
)
|
||||
return { status: "failed", reason: "target_changed" };
|
||||
const backupPath =
|
||||
original === undefined ? undefined : `${client.configPath}.rea.backup`;
|
||||
if (client.msixPackage !== undefined) {
|
||||
const target = await packageReadTargetChanged(
|
||||
client.msixPackage,
|
||||
client.configPath,
|
||||
);
|
||||
if (!target.ok)
|
||||
return {
|
||||
status: "failed",
|
||||
reason: "target_unavailable",
|
||||
failure: {
|
||||
kind: "inspect",
|
||||
path: target.error.path,
|
||||
detail: target.error.detail,
|
||||
},
|
||||
};
|
||||
if (target.value) return { status: "failed", reason: "target_changed" };
|
||||
}
|
||||
let recoveryBackupPath: string | undefined;
|
||||
if (backupPath !== undefined && original !== undefined) {
|
||||
const selection = await selectCurrentRecoveryBackup(backupPath, original);
|
||||
if (!selection.ok) return { status: "failed", reason: "backup" };
|
||||
if (
|
||||
!(await preserveRecoveryBackup(
|
||||
transactionPath,
|
||||
selection.value.backupPath,
|
||||
))
|
||||
)
|
||||
return { status: "failed", reason: "backup" };
|
||||
const verified = await selectCurrentRecoveryBackup(backupPath, original);
|
||||
if (
|
||||
!verified.ok ||
|
||||
verified.value.backupPath !== selection.value.backupPath ||
|
||||
!verified.value.present
|
||||
)
|
||||
return { status: "failed", reason: "backup" };
|
||||
recoveryBackupPath = verified.value.backupPath;
|
||||
if (original !== undefined) {
|
||||
const preserved = await preserveCurrentRecoveryBackup(
|
||||
`${client.configPath}.rea.backup`,
|
||||
transactionPath,
|
||||
original,
|
||||
);
|
||||
if (!preserved.ok) return { status: "failed", reason: "backup" };
|
||||
recoveryBackupPath = preserved.value;
|
||||
}
|
||||
// A native OpenCode V2 entry replaces REA's legacy V1 entry, which would
|
||||
// otherwise conflict with it.
|
||||
@@ -346,6 +367,7 @@ export const clientConfigurationAligned = async (
|
||||
client: SetupClient,
|
||||
providerEnvironment: SetupProviderEnvironment,
|
||||
command: readonly string[],
|
||||
resolveProviderRequestBudget?: ProviderRequestBudgetResolver,
|
||||
): Promise<boolean> => {
|
||||
if (client.configPathError !== undefined) return false;
|
||||
try {
|
||||
@@ -365,6 +387,7 @@ export const clientConfigurationAligned = async (
|
||||
command,
|
||||
parsed,
|
||||
files.value,
|
||||
resolveProviderRequestBudget,
|
||||
),
|
||||
);
|
||||
if (!policy.ok) {
|
||||
@@ -381,6 +404,7 @@ export const clientConfigurationAligned = async (
|
||||
command,
|
||||
parsed,
|
||||
files.value,
|
||||
resolveProviderRequestBudget,
|
||||
),
|
||||
)
|
||||
);
|
||||
@@ -397,11 +421,13 @@ export const inspectClientConfiguration = async (
|
||||
client: SetupClient,
|
||||
providerEnvironment: SetupProviderEnvironment,
|
||||
command: readonly string[],
|
||||
resolveProviderRequestBudget?: ProviderRequestBudgetResolver,
|
||||
): Promise<ClientConfigurationInspection> => {
|
||||
const inspection = await inspectClientDocument(
|
||||
client,
|
||||
providerEnvironment,
|
||||
command,
|
||||
resolveProviderRequestBudget,
|
||||
);
|
||||
if (inspection.status !== "create" && inspection.status !== "update")
|
||||
return inspection;
|
||||
@@ -416,6 +442,7 @@ const inspectClientDocument = async (
|
||||
client: SetupClient,
|
||||
providerEnvironment: SetupProviderEnvironment,
|
||||
command: readonly string[],
|
||||
resolveProviderRequestBudget?: ProviderRequestBudgetResolver,
|
||||
): Promise<ClientConfigurationInspection> => {
|
||||
if (client.configPathError !== undefined)
|
||||
return { status: "invalid", remediation: client.configPathError };
|
||||
@@ -449,6 +476,7 @@ const inspectClientDocument = async (
|
||||
command,
|
||||
existing,
|
||||
files.value,
|
||||
resolveProviderRequestBudget,
|
||||
),
|
||||
);
|
||||
if (!policy.ok) {
|
||||
@@ -498,6 +526,7 @@ const inspectClientDocument = async (
|
||||
command,
|
||||
parsed,
|
||||
files.value,
|
||||
resolveProviderRequestBudget,
|
||||
),
|
||||
);
|
||||
if (!policy.ok) {
|
||||
@@ -515,6 +544,7 @@ const inspectClientDocument = async (
|
||||
command,
|
||||
parsed,
|
||||
files.value,
|
||||
resolveProviderRequestBudget,
|
||||
);
|
||||
if (registrationCurrent(parsed, desired))
|
||||
return { status: "already_current" };
|
||||
@@ -525,6 +555,8 @@ const inspectClientDocument = async (
|
||||
}
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof RegularFileCleanupFailure) throw cause;
|
||||
if (cause instanceof ConfigurationError)
|
||||
return { status: "invalid", remediation: cause.message };
|
||||
// Malformed configuration is reported with the invalid remediation.
|
||||
void cause;
|
||||
return {
|
||||
@@ -622,7 +654,11 @@ const rollbackOrReadbackFailure = async (
|
||||
): Promise<ClientConfigurationResult> => {
|
||||
const rollback = await restoreConfig(targetPath, original);
|
||||
return rollback.ok
|
||||
? { status: "failed", reason: "readback" }
|
||||
? {
|
||||
status: "failed",
|
||||
reason: "readback",
|
||||
...(backupPath === undefined ? {} : { backupPath }),
|
||||
}
|
||||
: {
|
||||
status: "failed",
|
||||
reason: "rollback",
|
||||
@@ -653,20 +689,21 @@ const clientConfigurationDesired = (
|
||||
command: readonly string[],
|
||||
parsed: ClientConfigurationDocument | undefined,
|
||||
sources: readonly ClientConfigurationSource[],
|
||||
resolveProviderRequestBudget?: ProviderRequestBudgetResolver,
|
||||
) => {
|
||||
const effective = effectiveClientConfiguration(sources, {
|
||||
client,
|
||||
document: parsed,
|
||||
});
|
||||
const validated = clientRegistrationEnvironment(
|
||||
effective,
|
||||
providerEnvironment,
|
||||
);
|
||||
if (!validated.ok) throw validated.error;
|
||||
const format = parsed?.dialect ?? client.format ?? "json";
|
||||
const launcher =
|
||||
command.length === 0 ? [PRODUCT_IDENTITY.cliBinary, "mcp"] : command;
|
||||
if (format === "dsh") {
|
||||
const validated = clientRegistrationEnvironment(
|
||||
effective,
|
||||
providerEnvironment,
|
||||
);
|
||||
if (!validated.ok) throw validated.error;
|
||||
const desired = (
|
||||
parsed === undefined
|
||||
? new DshCordisDocument("")
|
||||
@@ -675,18 +712,36 @@ const clientConfigurationDesired = (
|
||||
if (!desired.ok) throw desired.error;
|
||||
return desired.value;
|
||||
}
|
||||
const validated = clientRegistrationEnvironmentDetails(
|
||||
effective,
|
||||
providerEnvironment,
|
||||
"effective",
|
||||
client.name === "codex" || client.name === "kimi"
|
||||
? resolveProviderRequestBudget
|
||||
: undefined,
|
||||
);
|
||||
if (!validated.ok) throw validated.error;
|
||||
// Preserve explicitly configured server settings, never the ambient process
|
||||
// environment. Newly discovered provider paths win only for their own keys.
|
||||
// environment. The effective layered view validates precedence and supplies
|
||||
// timeout facts; the target file's fragment remains the value being written.
|
||||
const environment = clientRegistrationEnvironment(
|
||||
parsed,
|
||||
providerEnvironment,
|
||||
"fragment",
|
||||
);
|
||||
if (!environment.ok) throw environment.error;
|
||||
const registrationEnvironment =
|
||||
client.name === "kimi" &&
|
||||
environment.value.REA_MCP_INPUT_SCHEMA_PROFILE === undefined
|
||||
? {
|
||||
...environment.value,
|
||||
REA_MCP_INPUT_SCHEMA_PROFILE: "compact",
|
||||
}
|
||||
: environment.value;
|
||||
const registration = clientRegistrationEntry(
|
||||
format,
|
||||
launcher,
|
||||
environment.value,
|
||||
registrationEnvironment,
|
||||
);
|
||||
const previous = parsed?.servers[PRODUCT_IDENTITY.mcpServerKey];
|
||||
if (
|
||||
@@ -700,14 +755,40 @@ const clientConfigurationDesired = (
|
||||
...registration,
|
||||
...(client.name === "codex" || client.name === "grok_build"
|
||||
? {
|
||||
startup_timeout_sec: MCP_STARTUP_POLICY.codexStartupTimeoutSeconds,
|
||||
startup_timeout_sec:
|
||||
client.name === "codex"
|
||||
? (positiveTimeout(previous, "startup_timeout_sec") ??
|
||||
MCP_STARTUP_POLICY.codexStartupTimeoutSeconds)
|
||||
: MCP_STARTUP_POLICY.codexStartupTimeoutSeconds,
|
||||
}
|
||||
: {}),
|
||||
...(client.name === "codex"
|
||||
? {
|
||||
tool_timeout_sec:
|
||||
positiveTimeout(previous, "tool_timeout_sec") ??
|
||||
recommendedMcpToolTimeoutSeconds(validated.value.timeoutInputs),
|
||||
}
|
||||
: {}),
|
||||
...(client.name === "kimi"
|
||||
? {
|
||||
startupTimeoutMs: MCP_STARTUP_POLICY.kimiStartupTimeoutMs,
|
||||
toolTimeoutMs: MCP_STARTUP_POLICY.kimiToolTimeoutMs,
|
||||
startupTimeoutMs:
|
||||
positiveTimeout(previous, "startupTimeoutMs") ??
|
||||
MCP_STARTUP_POLICY.kimiStartupTimeoutMs,
|
||||
toolTimeoutMs:
|
||||
positiveTimeout(previous, "toolTimeoutMs") ??
|
||||
recommendedMcpToolTimeoutMs(validated.value.timeoutInputs),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
};
|
||||
|
||||
const positiveTimeout = (
|
||||
registration: unknown,
|
||||
key: string,
|
||||
): number | undefined => {
|
||||
if (typeof registration !== "object" || registration === null) return;
|
||||
const value: unknown = Reflect.get(registration, key);
|
||||
return typeof value === "number" && Number.isFinite(value) && value > 0
|
||||
? value
|
||||
: undefined;
|
||||
};
|
||||
@@ -9,19 +9,26 @@ import type { SetupClient } from "./SupportedClients.js";
|
||||
export const failedConfigurationMessage = (
|
||||
result: Extract<ClientConfigurationResult, { readonly status: "failed" }>,
|
||||
): string => {
|
||||
if (result.reason === "target_changed")
|
||||
return "The client began reading a different configuration file after the plan was shown, so no change was made. Rerun setup to review the new target.";
|
||||
if (result.reason === "path")
|
||||
return "Agent configuration path could not be safely verified. Check its permissions and, if it is a symbolic link, verify that the link resolves to a regular file owned by the current user, then rerun setup.";
|
||||
if (result.reason === "backup")
|
||||
return "Agent configuration could not be backed up, so no change was made. Check file permissions, then rerun setup.";
|
||||
if (result.reason === "write")
|
||||
return "Agent configuration could not be updated. Check file permissions, then rerun setup.";
|
||||
if (result.reason === "rollback")
|
||||
return result.backupPath === undefined
|
||||
? `Agent configuration rollback failed at ${result.failure.path}: ${result.failure.detail}; the failed first-run file may remain. Remove or repair it before rerunning setup.`
|
||||
: `Agent configuration rollback failed at ${result.failure.path}: ${result.failure.detail}. Inspect the preserved backup at ${result.backupPath} and restore the intended configuration, then rerun setup.`;
|
||||
return "Agent configuration could not be verified after writing. Repair the configuration file or restore its `.rea.backup`, then rerun setup.";
|
||||
switch (result.reason) {
|
||||
case "target_changed":
|
||||
return "The client began reading a different configuration file after the plan was shown, so no change was made. Rerun setup to review the new target.";
|
||||
case "target_unavailable":
|
||||
return `The client configuration read target could not be established because ${result.failure.path} could not be inspected: ${result.failure.detail}. Repair access to that path, then rerun setup.`;
|
||||
case "path":
|
||||
return "Agent configuration path could not be safely verified. Check its permissions and, if it is a symbolic link, verify that the link resolves to a regular file owned by the current user, then rerun setup.";
|
||||
case "backup":
|
||||
return "Agent configuration could not be backed up, so no change was made. Check file permissions, then rerun setup.";
|
||||
case "write":
|
||||
return "Agent configuration could not be updated. Check file permissions, then rerun setup.";
|
||||
case "rollback":
|
||||
return result.backupPath === undefined
|
||||
? `Agent configuration rollback failed at ${result.failure.path}: ${result.failure.detail}; the failed first-run file may remain. Remove or repair it before rerunning setup.`
|
||||
: `Agent configuration rollback failed at ${result.failure.path}: ${result.failure.detail}. Inspect the preserved backup at ${result.backupPath} and restore the intended configuration, then rerun setup.`;
|
||||
case "readback":
|
||||
return result.backupPath === undefined
|
||||
? "Agent configuration could not be verified. Repair the configuration file, then rerun setup."
|
||||
: `Agent configuration could not be verified after writing. The original was restored and its backup at ${result.backupPath} was retained. Repair the configuration file or restore that backup, then rerun setup.`;
|
||||
}
|
||||
};
|
||||
|
||||
/** Configure every detected agent and report the first failed transaction. */
|
||||
|
||||
+136
-41
@@ -20,8 +20,10 @@ import {
|
||||
LinuxHopperTemporaryCleanupOwner,
|
||||
readLinuxDistribution,
|
||||
systemLinuxHopperInstallHost,
|
||||
type LinuxHopperInstallHost,
|
||||
} from "./LinuxHopper.js";
|
||||
import { installMacHopper, systemMacHopperInstallHost } from "./MacHopper.js";
|
||||
import { linuxHopperReleaseCacheDirectory } from "./LinuxHopperPublisher.js";
|
||||
import {
|
||||
clientEvidencePaths,
|
||||
supportedClients,
|
||||
@@ -45,7 +47,10 @@ import {
|
||||
RegularFileCleanupFailure,
|
||||
retryRegularFileCleanup,
|
||||
} from "./RegularFileRead.js";
|
||||
import { providerRegistrationEnvironment } from "./SetupRegistrationEnvironment.js";
|
||||
import {
|
||||
explicitSetupRegistrationEnvironment,
|
||||
providerRegistrationEnvironment,
|
||||
} from "./SetupRegistrationEnvironment.js";
|
||||
import {
|
||||
msixPackagesDirectory,
|
||||
packagePrivateLauncherBlocker,
|
||||
@@ -56,21 +61,22 @@ import type {
|
||||
SetupProviderEnvironment,
|
||||
} from "./SetupTypes.js";
|
||||
import type { DoctorScope } from "./Doctor.js";
|
||||
import { CURRENT_ENTRYPOINT_PATH } from "../process/CurrentEntrypointPath.js";
|
||||
import { analysisProviderSelectorSchema } from "../contracts/providerSelection.js";
|
||||
|
||||
/** Resolve the executable and arguments used in a managed MCP registration. */
|
||||
export const setupRegistrationCommand = (
|
||||
platform: NodeJS.Platform,
|
||||
packageRunner: PackageRunner | undefined,
|
||||
): readonly string[] =>
|
||||
packageRunner !== undefined
|
||||
? packageRunnerRegistrationCommand(packageRunner, platform)
|
||||
: platform === "win32"
|
||||
? [
|
||||
process.execPath,
|
||||
resolve(process.argv[1] ?? PRODUCT_IDENTITY.cliBinary),
|
||||
"mcp",
|
||||
]
|
||||
: [resolve(process.argv[1] ?? PRODUCT_IDENTITY.cliBinary), "mcp"];
|
||||
entrypointPath: string | undefined,
|
||||
): readonly string[] => {
|
||||
if (packageRunner !== undefined)
|
||||
return packageRunnerRegistrationCommand(packageRunner, platform);
|
||||
if (entrypointPath === undefined || entrypointPath.length === 0) return [];
|
||||
return platform === "win32"
|
||||
? [process.execPath, entrypointPath, "mcp"]
|
||||
: [entrypointPath, "mcp"];
|
||||
};
|
||||
|
||||
/**
|
||||
* The launcher setup registers, and why it must not be registered when it
|
||||
@@ -84,14 +90,17 @@ const setupRegistration = (
|
||||
const registrationCommand = setupRegistrationCommand(
|
||||
platform,
|
||||
invokingPackageRunner(environment, platform),
|
||||
CURRENT_ENTRYPOINT_PATH,
|
||||
);
|
||||
const registrationCommandBlocker =
|
||||
platform === "win32"
|
||||
? packagePrivateLauncherBlocker(
|
||||
registrationCommand,
|
||||
msixPackagesDirectory(homeDirectory, environment),
|
||||
)
|
||||
: undefined;
|
||||
registrationCommand.length === 0
|
||||
? "REA could not determine the current executable entrypoint. Run setup through the installed REA CLI or a supported package runner before configuring clients."
|
||||
: platform === "win32"
|
||||
? packagePrivateLauncherBlocker(
|
||||
registrationCommand,
|
||||
msixPackagesDirectory(homeDirectory, environment),
|
||||
)
|
||||
: undefined;
|
||||
return registrationCommandBlocker === undefined
|
||||
? { registrationCommand }
|
||||
: { registrationCommand, registrationCommandBlocker };
|
||||
@@ -149,6 +158,12 @@ export const systemSetupHost = (
|
||||
return {
|
||||
platform,
|
||||
homeDirectory,
|
||||
...(platform === "linux"
|
||||
? {
|
||||
linuxHopperReleaseCache:
|
||||
linuxHopperReleaseCacheDirectory(environment),
|
||||
}
|
||||
: {}),
|
||||
skillDestinations: (clientIds) =>
|
||||
skillDestinations(homeDirectory, clientIds, environment, platform),
|
||||
...setupRegistration(platform, homeDirectory, environment),
|
||||
@@ -157,12 +172,23 @@ export const systemSetupHost = (
|
||||
linuxDistribution: readLinuxDistribution,
|
||||
close: () =>
|
||||
closeSetupResources(resources, macHopperHost, doctorHost, () =>
|
||||
pendingLinuxHopperCleanup.retry(linuxHopperHost.cleanup),
|
||||
closeLinuxHopperInstallHost(linuxHopperHost, pendingLinuxHopperCleanup),
|
||||
),
|
||||
initialSetupState: async (
|
||||
scope?: DoctorScope,
|
||||
): Promise<SetupInitialState> => {
|
||||
const diagnosis = await runDoctor(undefined, doctorHost, scope);
|
||||
const providerEnvironment: SetupProviderEnvironment = {
|
||||
...providerRegistrationEnvironment(diagnosis.providerInspections ?? []),
|
||||
...doctorHost.providerRegistrationEnvironment?.(),
|
||||
...explicitSetupRegistrationEnvironment(environment),
|
||||
...(diagnosis.hopperPath === undefined
|
||||
? {}
|
||||
: { HOPPER_LAUNCHER_PATH: diagnosis.hopperPath }),
|
||||
};
|
||||
const requestedProvider = analysisProviderSelectorSchema.safeParse(
|
||||
environment.REA_ANALYSIS_PROVIDER,
|
||||
);
|
||||
return {
|
||||
...(diagnosis.hopperPath === undefined
|
||||
? {}
|
||||
@@ -172,15 +198,14 @@ export const systemSetupHost = (
|
||||
diagnosis.hopperPath === environment.HOPPER_LAUNCHER_PATH
|
||||
? { hopperLauncherConfigured: true }
|
||||
: {}),
|
||||
providerEnvironment: {
|
||||
...providerRegistrationEnvironment(
|
||||
diagnosis.providerInspections ?? [],
|
||||
),
|
||||
...doctorHost.providerRegistrationEnvironment?.(),
|
||||
...(diagnosis.hopperPath === undefined
|
||||
? {}
|
||||
: { HOPPER_LAUNCHER_PATH: diagnosis.hopperPath }),
|
||||
},
|
||||
providerConfigured:
|
||||
diagnosis.hopperPath !== undefined ||
|
||||
diagnosis.providerInspections?.some(
|
||||
({ configured }) => configured,
|
||||
) === true ||
|
||||
providerEnvironment.REA_JEB_MCP_URL !== undefined ||
|
||||
(requestedProvider.success && requestedProvider.data !== "auto"),
|
||||
providerEnvironment,
|
||||
doctor: diagnosis,
|
||||
};
|
||||
},
|
||||
@@ -189,9 +214,19 @@ export const systemSetupHost = (
|
||||
const result = await installLinuxHopper(linuxHopperHost);
|
||||
if (result.cleanupFailure !== undefined)
|
||||
pendingLinuxHopperCleanup.retain(result.cleanupFailure);
|
||||
return result.status === "installed"
|
||||
? { status: "installed", launcherPath: result.launcherPath }
|
||||
: setupInstallFailure(result.reason, result.packageFailure);
|
||||
if (result.status === "installed")
|
||||
return { status: "installed", launcherPath: result.launcherPath };
|
||||
const failure = setupInstallFailure(
|
||||
result.reason,
|
||||
result.packageFailure,
|
||||
"linux",
|
||||
);
|
||||
return result.verificationFailure !== undefined
|
||||
? {
|
||||
...failure,
|
||||
remediation: `${result.verificationFailure.message} ${failure.remediation}`,
|
||||
}
|
||||
: failure;
|
||||
}
|
||||
if (macHopperHost === undefined)
|
||||
return setupInstallFailure("unsupported_host");
|
||||
@@ -200,23 +235,37 @@ export const systemSetupHost = (
|
||||
return {
|
||||
status: "installed",
|
||||
launcherPath: result.launcherPath,
|
||||
...(result.openFailure === undefined
|
||||
? {}
|
||||
: {
|
||||
warning: `Hopper was installed, but macOS could not open it: ${result.openFailure}. Open ${result.launcherPath} manually to complete first-run setup.`,
|
||||
}),
|
||||
...(result.cleanupFailure === undefined
|
||||
? {}
|
||||
: { cleanupFailure: result.cleanupFailure }),
|
||||
};
|
||||
const failure = setupInstallFailure(result.reason);
|
||||
if (result.cleanupFailure === undefined) return failure;
|
||||
return failure.status === "failed"
|
||||
? {
|
||||
...failure,
|
||||
remediation: `${failure.remediation} ${result.cleanupFailure}`,
|
||||
}
|
||||
: failure;
|
||||
const diagnostic = setupInstallerDiagnostic(result.diagnostic);
|
||||
return {
|
||||
...failure,
|
||||
remediation: [
|
||||
failure.remediation,
|
||||
...(diagnostic === undefined
|
||||
? []
|
||||
: [`Installer detail: ${diagnostic}`]),
|
||||
...(result.cleanupFailure === undefined
|
||||
? []
|
||||
: [result.cleanupFailure]),
|
||||
].join(" "),
|
||||
};
|
||||
},
|
||||
detectedClients: () => detectClients(homeDirectory, platform, environment),
|
||||
supportedClients: () =>
|
||||
Promise.resolve(supportedClients(homeDirectory, platform, environment)),
|
||||
...clientConfigurationOperations(resources),
|
||||
...clientConfigurationOperations(
|
||||
resources,
|
||||
doctorHost.resolveProviderRequestBudget,
|
||||
),
|
||||
skillNeedsInstall: (clientIds) =>
|
||||
canonicalSkillNeedsInstall(
|
||||
homeDirectory,
|
||||
@@ -245,7 +294,25 @@ export const systemSetupHost = (
|
||||
};
|
||||
};
|
||||
|
||||
const clientConfigurationOperations = (resources: ArtifactResourceScope) => ({
|
||||
const setupInstallerDiagnostic = (
|
||||
value: string | undefined,
|
||||
): string | undefined => {
|
||||
if (value === undefined) return undefined;
|
||||
const normalized = value
|
||||
.replace(/\u001b(?:\[[0-9;?]*[ -/]*[@-~]|\([0-9A-Za-z]|[@-Z\\^_])/gu, "")
|
||||
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/gu, "")
|
||||
.replace(/\s+/gu, " ")
|
||||
.trim();
|
||||
if (normalized === "") return undefined;
|
||||
return normalized.length <= 400
|
||||
? normalized
|
||||
: `${normalized.slice(0, 397).trimEnd()}...`;
|
||||
};
|
||||
|
||||
const clientConfigurationOperations = (
|
||||
resources: ArtifactResourceScope,
|
||||
resolveProviderRequestBudget: DoctorHost["resolveProviderRequestBudget"],
|
||||
) => ({
|
||||
configureClient: (
|
||||
client: SetupClient,
|
||||
providerEnvironment: SetupProviderEnvironment,
|
||||
@@ -254,7 +321,12 @@ const clientConfigurationOperations = (resources: ArtifactResourceScope) => ({
|
||||
runClientConfigurationOperation(resources, async () =>
|
||||
client.format === "unsupported"
|
||||
? { status: "skipped" as const }
|
||||
: configureClientConfiguration(client, providerEnvironment, command),
|
||||
: configureClientConfiguration(
|
||||
client,
|
||||
providerEnvironment,
|
||||
command,
|
||||
resolveProviderRequestBudget,
|
||||
),
|
||||
),
|
||||
clientNeedsConfigure: (
|
||||
client: SetupClient,
|
||||
@@ -268,6 +340,7 @@ const clientConfigurationOperations = (resources: ArtifactResourceScope) => ({
|
||||
client,
|
||||
providerEnvironment,
|
||||
command,
|
||||
resolveProviderRequestBudget,
|
||||
)),
|
||||
),
|
||||
inspectClientConfiguration: (
|
||||
@@ -276,7 +349,12 @@ const clientConfigurationOperations = (resources: ArtifactResourceScope) => ({
|
||||
command: readonly string[],
|
||||
) =>
|
||||
runClientConfigurationOperation(resources, () =>
|
||||
inspectClientConfiguration(client, providerEnvironment, command),
|
||||
inspectClientConfiguration(
|
||||
client,
|
||||
providerEnvironment,
|
||||
command,
|
||||
resolveProviderRequestBudget,
|
||||
),
|
||||
),
|
||||
});
|
||||
|
||||
@@ -365,6 +443,23 @@ const configurationCleanupError = (
|
||||
const errorMessage = (cause: unknown): string =>
|
||||
cause instanceof Error ? cause.message : String(cause);
|
||||
|
||||
const closeLinuxHopperInstallHost = async (
|
||||
host: LinuxHopperInstallHost,
|
||||
temporaryOwner: LinuxHopperTemporaryCleanupOwner,
|
||||
): Promise<string | undefined> => {
|
||||
const reasons: string[] = [];
|
||||
const temporary = await temporaryOwner.retry(host.cleanup);
|
||||
if (temporary !== undefined) reasons.push(temporary);
|
||||
try {
|
||||
await host.close?.();
|
||||
} catch (cause: unknown) {
|
||||
reasons.push(
|
||||
`Linux Hopper publisher cleanup failed: ${errorMessage(cause)}`,
|
||||
);
|
||||
}
|
||||
return reasons.length === 0 ? undefined : reasons.join("; ");
|
||||
};
|
||||
|
||||
/** Detect supported agents from their config files or stable installation markers. */
|
||||
export const detectClients = async (
|
||||
home: string,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { HOPPER_PUBLISHER } from "../domain/hopperPublisher.js";
|
||||
import { HOPPER_LINUX_PRIMARY_FINGERPRINT } from "./HopperReleaseFeed.js";
|
||||
import type { LinuxPackageInstallFailure } from "./LinuxHopper.js";
|
||||
|
||||
export type SetupFailureCode =
|
||||
@@ -23,6 +24,7 @@ export type SetupHopperInstallResult =
|
||||
readonly status: "installed";
|
||||
readonly launcherPath: string;
|
||||
readonly cleanupFailure?: string;
|
||||
readonly warning?: string;
|
||||
}
|
||||
| {
|
||||
readonly status: "failed";
|
||||
@@ -30,6 +32,11 @@ export type SetupHopperInstallResult =
|
||||
readonly remediation: string;
|
||||
};
|
||||
|
||||
export type SetupHopperInstallFailure = Extract<
|
||||
SetupHopperInstallResult,
|
||||
{ readonly status: "failed" }
|
||||
>;
|
||||
|
||||
export type HopperInstallFailureReason =
|
||||
| "unsupported_host"
|
||||
| "release_metadata"
|
||||
@@ -44,6 +51,8 @@ export type HopperInstallFailureReason =
|
||||
| "mount"
|
||||
| "bundle"
|
||||
| "publisher_verification_failed"
|
||||
| "verification_unavailable"
|
||||
| "cleanup_failed"
|
||||
| "copy";
|
||||
|
||||
const normalizePackageOutput = (output: string): string =>
|
||||
@@ -93,7 +102,8 @@ const packageManagerRemediation = (
|
||||
export const setupInstallFailure = (
|
||||
reason: HopperInstallFailureReason,
|
||||
packageFailure?: LinuxPackageInstallFailure,
|
||||
): SetupHopperInstallResult => {
|
||||
platform: NodeJS.Platform = "darwin",
|
||||
): SetupHopperInstallFailure => {
|
||||
switch (reason) {
|
||||
case "unsupported_host":
|
||||
return {
|
||||
@@ -133,7 +143,7 @@ export const setupInstallFailure = (
|
||||
status: "failed",
|
||||
code: "unsupported_hopper_build",
|
||||
remediation:
|
||||
"The installed Hopper launcher does not match the build supported by this REA release. Unset HOPPER_LAUNCHER_PATH, reinstall through rea setup, or update REA.",
|
||||
"The installed Hopper launcher does not match the authenticated vendor package. Check HOPPER_LAUNCHER_PATH and reinstall the official Hopper demo through rea setup.",
|
||||
};
|
||||
case "cancelled":
|
||||
return {
|
||||
@@ -167,7 +177,24 @@ export const setupInstallFailure = (
|
||||
return {
|
||||
status: "failed",
|
||||
code: "publisher_verification_failed",
|
||||
remediation: `The mounted Hopper application is not signed by the publisher REA pins (${HOPPER_PUBLISHER.bundleIdentifier}, Apple team ${HOPPER_PUBLISHER.teamIdentifier}); nothing was copied or launched. Install Hopper yourself and set HOPPER_LAUNCHER_PATH to its launcher, then rerun rea setup.`,
|
||||
remediation:
|
||||
platform === "linux"
|
||||
? `The Hopper package signature did not verify under the pinned primary PGP fingerprint ${HOPPER_LINUX_PRIMARY_FINGERPRINT}; the package manager was not run. Download the official package and retry setup.`
|
||||
: `The mounted Hopper application is not signed by the publisher REA pins (${HOPPER_PUBLISHER.bundleIdentifier}, Apple team ${HOPPER_PUBLISHER.teamIdentifier}); nothing was copied or launched. Install Hopper yourself and set HOPPER_LAUNCHER_PATH to its launcher, then rerun rea setup.`,
|
||||
};
|
||||
case "verification_unavailable":
|
||||
return {
|
||||
status: "failed",
|
||||
code: "publisher_verification_failed",
|
||||
remediation:
|
||||
"REA could not authenticate or retain the signed Hopper package. Check vendor access, native package inspection tools and caller-owned cache permissions, then retry setup.",
|
||||
};
|
||||
case "cleanup_failed":
|
||||
return {
|
||||
status: "failed",
|
||||
code: "cleanup_failed",
|
||||
remediation:
|
||||
"Hopper publisher verification retained temporary cleanup ownership. Retry cleanup before running setup again.",
|
||||
};
|
||||
case "copy":
|
||||
return {
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { join } from "node:path";
|
||||
|
||||
import { isOwnedClientRegistrationCommand } from "./ClientRegistrationIdentity.js";
|
||||
import { classifyClientRegistrationCommand } from "./ClientRegistrationIdentity.js";
|
||||
import {
|
||||
linuxHopperInstallDisclosure,
|
||||
type LinuxPackageFamily,
|
||||
} from "./LinuxHopper.js";
|
||||
import type { SkillDestination } from "./SetupSkill.js";
|
||||
import { macHopperInstallDisclosure } from "./MacHopper.js";
|
||||
import { macHopperUserApplicationPath } from "./MacHopperPaths.js";
|
||||
import type {
|
||||
ClientConfigurationInspection,
|
||||
SetupAction,
|
||||
@@ -22,6 +21,7 @@ import {
|
||||
import { redirectedWriteBlocker } from "./MsixPackageStorage.js";
|
||||
import type { DoctorReport } from "./Doctor.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
import { CURRENT_ENTRYPOINT_PATH } from "../process/CurrentEntrypointPath.js";
|
||||
|
||||
/** Read-only setup facts shared by client selection and preflight. */
|
||||
export interface SetupDiscovery {
|
||||
@@ -37,7 +37,7 @@ export interface SetupDiscovery {
|
||||
/** Discover supported clients and current registrations without mutation. */
|
||||
export const discoverSetupState = async (input: {
|
||||
readonly host: SetupHost;
|
||||
readonly providerEnvironment: SetupProviderEnvironment;
|
||||
readonly providerConfigured: boolean;
|
||||
readonly forceHopperInstall: boolean;
|
||||
readonly hopperLauncherConfigured?: boolean;
|
||||
readonly proposeHopper: boolean;
|
||||
@@ -65,8 +65,7 @@ export const discoverSetupState = async (input: {
|
||||
input.forceHopperInstall ||
|
||||
(hopperInstallSupported &&
|
||||
input.proposeHopper &&
|
||||
(Object.keys(input.providerEnvironment).length === 0 ||
|
||||
linuxHopperRepairNeeded));
|
||||
(!input.providerConfigured || linuxHopperRepairNeeded));
|
||||
const [clients, detectedClients] = await Promise.all([
|
||||
input.host.supportedClients(),
|
||||
input.host.detectedClients(),
|
||||
@@ -78,20 +77,29 @@ export const discoverSetupState = async (input: {
|
||||
);
|
||||
const clientStates = clients.map((client): SetupClientState => {
|
||||
const registration = registrationByClient.get(client.name);
|
||||
const commandIdentity =
|
||||
registration === undefined
|
||||
? undefined
|
||||
: classifyClientRegistrationCommand(
|
||||
registration.command,
|
||||
CURRENT_ENTRYPOINT_PATH,
|
||||
);
|
||||
const ownedRegistration =
|
||||
(registration?.state === "aligned" || registration?.state === "stale") &&
|
||||
isOwnedClientRegistrationCommand(registration.command);
|
||||
commandIdentity === "owned";
|
||||
const configured = ownedRegistration;
|
||||
const status: SetupClientState["status"] =
|
||||
registration?.state === "invalid"
|
||||
? "invalid"
|
||||
: ownedRegistration && registration.state === "aligned"
|
||||
? "configured"
|
||||
: ownedRegistration && registration.state === "stale"
|
||||
? "needs_configuration"
|
||||
: detectedIds.has(client.name)
|
||||
: registration?.state === "unknown" || commandIdentity === "unknown"
|
||||
? "unknown"
|
||||
: ownedRegistration && registration.state === "aligned"
|
||||
? "configured"
|
||||
: ownedRegistration && registration.state === "stale"
|
||||
? "needs_configuration"
|
||||
: "missing";
|
||||
: detectedIds.has(client.name)
|
||||
? "needs_configuration"
|
||||
: "missing";
|
||||
return {
|
||||
client,
|
||||
detected: detectedIds.has(client.name),
|
||||
@@ -103,7 +111,10 @@ export const discoverSetupState = async (input: {
|
||||
.filter(
|
||||
(registration) =>
|
||||
(registration.state === "aligned" || registration.state === "stale") &&
|
||||
isOwnedClientRegistrationCommand(registration.command),
|
||||
classifyClientRegistrationCommand(
|
||||
registration.command,
|
||||
CURRENT_ENTRYPOINT_PATH,
|
||||
) === "owned",
|
||||
)
|
||||
.map(({ client }) => client);
|
||||
return {
|
||||
@@ -150,6 +161,24 @@ export const planSetupActions = async (input: {
|
||||
const client = byId.get(id);
|
||||
return client === undefined ? [] : [client];
|
||||
});
|
||||
const hopperCache = input.host.linuxHopperReleaseCache;
|
||||
if (input.discovery.installHopper && hopperCache?.ok === false)
|
||||
return {
|
||||
plannedActions: [],
|
||||
selectedClients,
|
||||
clientsToConfigure: [],
|
||||
blocker: hopperCache.error.message,
|
||||
};
|
||||
if (
|
||||
selectedClients.length > 0 &&
|
||||
input.host.registrationCommandBlocker !== undefined
|
||||
)
|
||||
return {
|
||||
plannedActions: [],
|
||||
selectedClients,
|
||||
clientsToConfigure: [],
|
||||
blocker: input.host.registrationCommandBlocker,
|
||||
};
|
||||
const inspections = await Promise.all(
|
||||
selectedClients.map(async (client) => ({
|
||||
client,
|
||||
@@ -178,6 +207,9 @@ export const planSetupActions = async (input: {
|
||||
const plannedActions = setupPlan({
|
||||
platform: input.host.platform,
|
||||
homeDirectory: input.host.homeDirectory,
|
||||
...(hopperCache?.ok === true
|
||||
? { hopperCacheDirectory: hopperCache.value }
|
||||
: {}),
|
||||
installHopper: input.discovery.installHopper,
|
||||
installSkill: input.installSkill,
|
||||
skillClientIds: input.skillClientIds,
|
||||
@@ -243,6 +275,7 @@ const plannedClientWrites = (
|
||||
const setupPlan = (input: {
|
||||
readonly platform: NodeJS.Platform;
|
||||
readonly homeDirectory: string;
|
||||
readonly hopperCacheDirectory?: string;
|
||||
readonly installHopper: boolean;
|
||||
readonly installSkill: boolean;
|
||||
readonly skillClientIds: readonly string[];
|
||||
@@ -264,14 +297,11 @@ const setupPlan = (input: {
|
||||
label: "Hopper deep-analysis provider",
|
||||
target:
|
||||
input.platform === "darwin"
|
||||
? join(
|
||||
input.homeDirectory,
|
||||
"Applications/Hopper Disassembler.app",
|
||||
)
|
||||
? macHopperUserApplicationPath(input.homeDirectory)
|
||||
: "system package manager",
|
||||
detail:
|
||||
input.platform === "linux"
|
||||
? "Download, verify, and install Hopper plus its Xvfb demo-session dependencies. For the supported demo build, REA uses a private display and selects Hopper's offered demo mode for each analysis session."
|
||||
? `Download the current official Hopper demo, verify its PGP publisher and launcher bytes, and retain archive, public-key.asc and release.json under ${input.hopperCacheDirectory ?? `${input.homeDirectory}/.cache/rea/hopper-releases`}/<launcher-sha256> for offline verification. Preserve replaced damaged evidence beside it as <launcher-sha256>.backup-<id>. Install Hopper plus its Xvfb demo-session dependencies. REA uses a private display and selects Hopper's offered demo mode for each analysis session.`
|
||||
: "Download the official Hopper package, verify it, and install it. Hopper may show its demo or license prompt when first opened.",
|
||||
external: true,
|
||||
operation: "install" as const,
|
||||
@@ -328,9 +358,9 @@ const linuxDisclosure = (family: LinuxPackageFamily) => {
|
||||
process.getuid?.() === 0,
|
||||
);
|
||||
return {
|
||||
networkOrigins: [disclosure.downloadUrl],
|
||||
networkOrigins: disclosure.networkOrigins,
|
||||
commands: disclosure.commands,
|
||||
integrity: `${String(disclosure.expectedBytes)} bytes · SHA-1 ${disclosure.expectedSha1}`,
|
||||
integrity: disclosure.integrity,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
readRegularFile,
|
||||
RegularFileCleanupFailure,
|
||||
} from "./RegularFileRead.js";
|
||||
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
|
||||
|
||||
export interface RecoveryBackupSelection {
|
||||
readonly backupPath: string;
|
||||
@@ -28,15 +29,19 @@ const errorDetail = (cause: unknown): string =>
|
||||
export const selectCurrentRecoveryBackup = async (
|
||||
firstBackupPath: string,
|
||||
original: string | Buffer,
|
||||
resources?: ArtifactResourceScope,
|
||||
): Promise<Result<RecoveryBackupSelection, RecoveryBackupSelectionFailure>> => {
|
||||
const read = (path: string) =>
|
||||
readRegularFile(path, {
|
||||
symlinks: "reject",
|
||||
...(resources === undefined ? {} : { resources }),
|
||||
});
|
||||
const expected = Buffer.isBuffer(original)
|
||||
? original
|
||||
: Buffer.from(original, "utf8");
|
||||
let firstBackup: Buffer | undefined;
|
||||
try {
|
||||
firstBackup = await readRegularFile(firstBackupPath, {
|
||||
symlinks: "reject",
|
||||
});
|
||||
firstBackup = await read(firstBackupPath);
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof RegularFileCleanupFailure) throw cause;
|
||||
if (!isMissing(cause))
|
||||
@@ -55,7 +60,7 @@ export const selectCurrentRecoveryBackup = async (
|
||||
const recoveryPath = `${firstBackupPath}.${digest}.recovery`;
|
||||
let recovery: Buffer | undefined;
|
||||
try {
|
||||
recovery = await readRegularFile(recoveryPath, { symlinks: "reject" });
|
||||
recovery = await read(recoveryPath);
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof RegularFileCleanupFailure) throw cause;
|
||||
if (!isMissing(cause))
|
||||
@@ -74,15 +79,65 @@ export const selectCurrentRecoveryBackup = async (
|
||||
return ok({ backupPath: recoveryPath, present: recovery !== undefined });
|
||||
};
|
||||
|
||||
/** Preserve a snapshot without replacing an earlier recovery file. */
|
||||
export const preserveRecoveryBackup = async (
|
||||
export interface RecoveryBackupPreservationFailure {
|
||||
readonly kind: RecoveryBackupSelectionFailure["kind"] | "copy" | "unverified";
|
||||
readonly path: string;
|
||||
readonly detail: string;
|
||||
}
|
||||
|
||||
const copyExclusive = (source: string, destination: string): Promise<void> =>
|
||||
copyFile(source, destination, fsConstants.COPYFILE_EXCL);
|
||||
|
||||
export interface RecoveryBackupPreservationOptions {
|
||||
/** Copy without replacing an existing destination; reject one with EEXIST. */
|
||||
readonly copy?: (source: string, destination: string) => Promise<void>;
|
||||
/** Scope that retains a backup read whose handle cleanup failed. */
|
||||
readonly resources?: ArtifactResourceScope;
|
||||
}
|
||||
|
||||
/**
|
||||
* Preserve a transaction's original bytes before it edits the file: keep a
|
||||
* matching first backup, otherwise copy them to the content-addressed recovery
|
||||
* path without replacing an earlier file, then verify the selected snapshot.
|
||||
*/
|
||||
export const preserveCurrentRecoveryBackup = async (
|
||||
firstBackupPath: string,
|
||||
source: string,
|
||||
destination: string,
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
await copyFile(source, destination, fsConstants.COPYFILE_EXCL);
|
||||
return true;
|
||||
} catch (cause: unknown) {
|
||||
return cause instanceof Error && "code" in cause && cause.code === "EEXIST";
|
||||
original: string | Buffer,
|
||||
{ copy = copyExclusive, resources }: RecoveryBackupPreservationOptions = {},
|
||||
): Promise<Result<string, RecoveryBackupPreservationFailure>> => {
|
||||
const selection = await selectCurrentRecoveryBackup(
|
||||
firstBackupPath,
|
||||
original,
|
||||
resources,
|
||||
);
|
||||
if (!selection.ok) return selection;
|
||||
const { backupPath } = selection.value;
|
||||
if (!selection.value.present) {
|
||||
try {
|
||||
await copy(source, backupPath);
|
||||
} catch (cause: unknown) {
|
||||
if (
|
||||
!(cause instanceof Error && "code" in cause && cause.code === "EEXIST")
|
||||
)
|
||||
return err({
|
||||
kind: "copy",
|
||||
path: backupPath,
|
||||
detail: `The recovery backup could not be written: ${errorDetail(cause)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
const verified = await selectCurrentRecoveryBackup(
|
||||
firstBackupPath,
|
||||
original,
|
||||
resources,
|
||||
);
|
||||
if (!verified.ok) return verified;
|
||||
if (verified.value.backupPath !== backupPath || !verified.value.present)
|
||||
return err({
|
||||
kind: "unverified",
|
||||
path: backupPath,
|
||||
detail: `The recovery backup does not match the configuration it preserves: ${backupPath}`,
|
||||
});
|
||||
return ok(backupPath);
|
||||
};
|
||||
@@ -1,6 +1,25 @@
|
||||
import type { DoctorProviderInspection } from "./Doctor.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
|
||||
const SELECTED_REGISTRATION_SETTINGS = [
|
||||
"REA_ANALYSIS_PROVIDER",
|
||||
"REA_GHIDRA_STARTUP_TIMEOUT_MS",
|
||||
"REA_HOPPER_STARTUP_TIMEOUT_MS",
|
||||
"REA_JEB_MCP_TIMEOUT_MS",
|
||||
"REA_MCP_INPUT_SCHEMA_PROFILE",
|
||||
] as const;
|
||||
|
||||
/** Preserve selected setup settings without copying unrelated process values. */
|
||||
export const explicitSetupRegistrationEnvironment = (
|
||||
environment: Readonly<Record<string, string | undefined>>,
|
||||
): Readonly<Record<string, string>> =>
|
||||
Object.fromEntries(
|
||||
SELECTED_REGISTRATION_SETTINGS.flatMap((key) => {
|
||||
const value = environment[key];
|
||||
return value === undefined ? [] : [[key, value]];
|
||||
}),
|
||||
);
|
||||
|
||||
/** Preserve adapter-published settings independently of runtime readiness. */
|
||||
export const providerRegistrationEnvironment = (
|
||||
inspections: readonly DoctorProviderInspection[],
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
withRegularFile,
|
||||
} from "./RegularFileRead.js";
|
||||
import { clientSkillDirectories } from "./SupportedClients.js";
|
||||
import { selectCurrentRecoveryBackup } from "./SetupRecoveryBackup.js";
|
||||
import { preserveCurrentRecoveryBackup } from "./SetupRecoveryBackup.js";
|
||||
|
||||
export const MANAGED_SKILL_FILES = [
|
||||
"SKILL.md",
|
||||
@@ -153,15 +153,8 @@ const systemSkillInstallFileOperations: SkillInstallFileOperations = {
|
||||
mkdir: async (path) => {
|
||||
await mkdir(path, { recursive: true });
|
||||
},
|
||||
backup: async (source, destination) => {
|
||||
try {
|
||||
await copyFile(source, destination, fsConstants.COPYFILE_EXCL);
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof Error && "code" in cause && cause.code === "EEXIST")
|
||||
return;
|
||||
throw cause;
|
||||
}
|
||||
},
|
||||
backup: (source, destination) =>
|
||||
copyFile(source, destination, fsConstants.COPYFILE_EXCL),
|
||||
write: (path, content, mode) =>
|
||||
writeFileAtomic(path, content, { encoding: "utf8", mode }),
|
||||
remove: (path) => rm(path, { force: true }),
|
||||
@@ -361,6 +354,7 @@ const canonicalSkillFiles = async (
|
||||
interface CanonicalSkillInspection {
|
||||
readonly canonical: boolean;
|
||||
readonly manifest: string | undefined;
|
||||
readonly installedDestination: boolean;
|
||||
}
|
||||
|
||||
interface SkillInspectionFailure {
|
||||
@@ -372,7 +366,7 @@ interface SkillInspectionFailure {
|
||||
|
||||
const inspectCanonicalSkill = async (
|
||||
home: string,
|
||||
clientIds: readonly string[],
|
||||
clientIds: readonly string[] | undefined,
|
||||
environment: Readonly<NodeJS.ProcessEnv>,
|
||||
platform: NodeJS.Platform,
|
||||
resources?: ArtifactResourceScope,
|
||||
@@ -383,7 +377,10 @@ const inspectCanonicalSkill = async (
|
||||
environment,
|
||||
platform,
|
||||
);
|
||||
const issues = [...(await skillDirectoryIssues(destinations))];
|
||||
const issues =
|
||||
clientIds === undefined
|
||||
? []
|
||||
: [...(await skillDirectoryIssues(destinations))];
|
||||
if (issues.length > 0)
|
||||
return err({
|
||||
_tag: "SkillInspectionFailure",
|
||||
@@ -394,7 +391,21 @@ const inspectCanonicalSkill = async (
|
||||
let canonical = true;
|
||||
let manifest: string | undefined;
|
||||
let failureCause: unknown;
|
||||
for (const { path: root } of destinations) {
|
||||
let installedDestination = false;
|
||||
for (const destination of destinations) {
|
||||
const { path: root } = destination;
|
||||
const pathIssues =
|
||||
clientIds === undefined ? await skillDirectoryIssues([destination]) : [];
|
||||
if (pathIssues.length > 0) {
|
||||
issues.push(...pathIssues);
|
||||
if (failureCause === undefined) failureCause = pathIssues;
|
||||
installedDestination = true;
|
||||
canonical = false;
|
||||
continue;
|
||||
}
|
||||
let rootCanonical = true;
|
||||
let rootHasFiles = false;
|
||||
const rootIssues: SkillPathIssue[] = [];
|
||||
for (const relativePath of MANAGED_SKILL_FILES) {
|
||||
const destination = join(root, relativePath);
|
||||
let original: SkillFileState | undefined;
|
||||
@@ -406,27 +417,34 @@ const inspectCanonicalSkill = async (
|
||||
cause instanceof ArtifactReaderFailure
|
||||
)
|
||||
throw cause;
|
||||
issues.push({
|
||||
rootIssues.push({
|
||||
path: destination,
|
||||
kind: "unreadable",
|
||||
detail: causeReason(cause),
|
||||
});
|
||||
if (issues.length === 1) failureCause = cause;
|
||||
canonical = false;
|
||||
if (failureCause === undefined) failureCause = cause;
|
||||
rootCanonical = false;
|
||||
rootHasFiles = true;
|
||||
continue;
|
||||
}
|
||||
if (original !== undefined) rootHasFiles = true;
|
||||
if (relativePath === "SKILL.md" && manifest === undefined)
|
||||
manifest = original?.bytes.toString("utf8");
|
||||
// A mismatch settles freshness, but not coverage of later read failures.
|
||||
if (original === undefined) canonical = false;
|
||||
else if (canonical) {
|
||||
if (original === undefined) rootCanonical = false;
|
||||
else if (rootCanonical) {
|
||||
const content = await canonicalSkillContent(relativePath);
|
||||
canonical = original.bytes.equals(Buffer.from(content, "utf8"));
|
||||
rootCanonical = original.bytes.equals(Buffer.from(content, "utf8"));
|
||||
}
|
||||
}
|
||||
if (clientIds !== undefined || rootHasFiles || rootIssues.length > 0) {
|
||||
installedDestination ||= rootHasFiles;
|
||||
canonical = canonical && rootCanonical;
|
||||
issues.push(...rootIssues);
|
||||
}
|
||||
}
|
||||
return issues.length === 0
|
||||
? ok({ canonical, manifest })
|
||||
? ok({ canonical, manifest, installedDestination })
|
||||
: err({
|
||||
_tag: "SkillInspectionFailure",
|
||||
issues,
|
||||
@@ -488,32 +506,6 @@ const restoreSkillFiles = async (
|
||||
return failures;
|
||||
};
|
||||
|
||||
const preserveCurrentSkillBackup = async (
|
||||
destination: string,
|
||||
original: Buffer,
|
||||
operations: SkillInstallFileOperations,
|
||||
): Promise<string> => {
|
||||
const firstBackupPath = `${destination}.rea.backup`;
|
||||
const selection = await selectCurrentRecoveryBackup(
|
||||
firstBackupPath,
|
||||
original,
|
||||
);
|
||||
if (!selection.ok) throw new Error(selection.error.detail);
|
||||
if (selection.value.present) return selection.value.backupPath;
|
||||
|
||||
await operations.backup(destination, selection.value.backupPath);
|
||||
const verified = await selectCurrentRecoveryBackup(firstBackupPath, original);
|
||||
if (
|
||||
!verified.ok ||
|
||||
verified.value.backupPath !== selection.value.backupPath ||
|
||||
!verified.value.present
|
||||
)
|
||||
throw new Error(
|
||||
`The current file was not preserved at ${selection.value.backupPath}`,
|
||||
);
|
||||
return verified.value.backupPath;
|
||||
};
|
||||
|
||||
/** Transactionally install the canonical skill bundle and optional references. */
|
||||
export const installCanonicalSkill = async (
|
||||
home: string,
|
||||
@@ -546,14 +538,17 @@ export const installCanonicalSkill = async (
|
||||
failingPath = destination;
|
||||
await operations.mkdir(dirname(destination));
|
||||
if (original !== undefined) {
|
||||
recoveryPaths.set(
|
||||
const preserved = await preserveCurrentRecoveryBackup(
|
||||
`${destination}.rea.backup`,
|
||||
destination,
|
||||
await preserveCurrentSkillBackup(
|
||||
destination,
|
||||
original.bytes,
|
||||
operations,
|
||||
),
|
||||
original.bytes,
|
||||
{
|
||||
copy: operations.backup,
|
||||
...(resources === undefined ? {} : { resources }),
|
||||
},
|
||||
);
|
||||
if (!preserved.ok) throw new Error(preserved.error.detail);
|
||||
recoveryPaths.set(destination, preserved.value);
|
||||
}
|
||||
}
|
||||
stage = "write";
|
||||
@@ -604,7 +599,7 @@ export const installCanonicalSkill = async (
|
||||
/** Read installed skill metadata and compare every managed location. */
|
||||
export const readInstalledSkillIdentity = async (
|
||||
home: string,
|
||||
clientIds: readonly string[],
|
||||
clientIds: readonly string[] | undefined,
|
||||
environment: Readonly<NodeJS.ProcessEnv> = {},
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
resources?: ArtifactResourceScope,
|
||||
@@ -616,11 +611,11 @@ export const readInstalledSkillIdentity = async (
|
||||
platform,
|
||||
resources,
|
||||
);
|
||||
if (inspection.ok && !inspection.value.installedDestination) return undefined;
|
||||
const content = inspection.ok
|
||||
? inspection.value.manifest
|
||||
: inspection.error.manifest;
|
||||
const issues = inspection.ok ? [] : inspection.error.issues;
|
||||
if (content === undefined && issues.length === 0) return undefined;
|
||||
const toolCount =
|
||||
content === undefined
|
||||
? undefined
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { DoctorReport, DoctorScope, runDoctor } from "./Doctor.js";
|
||||
import type { LinuxDistribution } from "./LinuxHopper.js";
|
||||
import type { HopperReleaseError } from "./HopperReleaseFeed.js";
|
||||
import type {
|
||||
SkillDestination,
|
||||
SkillInstallResult,
|
||||
@@ -20,6 +21,8 @@ export interface SetupInitialState {
|
||||
readonly hopperPath?: string;
|
||||
/** Whether the caller explicitly selected a Hopper launcher in its environment. */
|
||||
readonly hopperLauncherConfigured?: boolean;
|
||||
/** Whether the caller or host has an analysis provider configuration. */
|
||||
readonly providerConfigured: boolean;
|
||||
readonly providerEnvironment: SetupProviderEnvironment;
|
||||
readonly doctor: DoctorReport;
|
||||
}
|
||||
@@ -32,12 +35,22 @@ export type ClientConfigurationResult =
|
||||
}
|
||||
| {
|
||||
readonly status: "failed";
|
||||
readonly reason:
|
||||
| "path"
|
||||
| "target_changed"
|
||||
| "backup"
|
||||
| "write"
|
||||
| "readback";
|
||||
readonly reason: "path" | "target_changed" | "backup" | "write";
|
||||
}
|
||||
| {
|
||||
readonly status: "failed";
|
||||
readonly reason: "readback";
|
||||
/** Snapshot of the restored original, when this transaction made one. */
|
||||
readonly backupPath?: string;
|
||||
}
|
||||
| {
|
||||
readonly status: "failed";
|
||||
readonly reason: "target_unavailable";
|
||||
readonly failure: {
|
||||
readonly kind: "inspect";
|
||||
readonly path: string;
|
||||
readonly detail: string;
|
||||
};
|
||||
}
|
||||
| {
|
||||
readonly status: "failed";
|
||||
@@ -65,9 +78,10 @@ export type ClientConfigurationInspection =
|
||||
export interface SetupHost {
|
||||
readonly platform: NodeJS.Platform;
|
||||
readonly homeDirectory: string;
|
||||
readonly linuxHopperReleaseCache?: Result<string, HopperReleaseError>;
|
||||
skillDestinations(clientIds: readonly string[]): readonly SkillDestination[];
|
||||
readonly registrationCommand: readonly string[];
|
||||
/** Why no client may be registered with `registrationCommand` from this host. */
|
||||
/** Why client registration cannot proceed from this host. */
|
||||
readonly registrationCommandBlocker?: string;
|
||||
readonly nodeVersion: string;
|
||||
macosVersion(): Promise<string | undefined>;
|
||||
@@ -157,7 +171,12 @@ export interface SetupClientState {
|
||||
readonly client: SetupClient;
|
||||
readonly detected: boolean;
|
||||
readonly configured: boolean;
|
||||
readonly status: "configured" | "missing" | "needs_configuration" | "invalid";
|
||||
readonly status:
|
||||
| "configured"
|
||||
| "missing"
|
||||
| "needs_configuration"
|
||||
| "invalid"
|
||||
| "unknown";
|
||||
}
|
||||
|
||||
/** One concrete setup mutation disclosed before approval. */
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import type {
|
||||
AnalysisOperation,
|
||||
ProviderIdentity,
|
||||
} from "./AnalysisProvider.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
|
||||
/** One provider-owned caveat tied to the operation that reported it. */
|
||||
export interface SourceLimitation {
|
||||
readonly provider: ProviderIdentity;
|
||||
readonly operation: AnalysisOperation;
|
||||
readonly limitation: string;
|
||||
}
|
||||
|
||||
/** Stable exact-provenance key for deduplication before rendering. */
|
||||
export const sourceLimitationKey = ({
|
||||
provider,
|
||||
operation,
|
||||
limitation,
|
||||
}: SourceLimitation): string =>
|
||||
JSON.stringify([
|
||||
provider.id,
|
||||
provider.name,
|
||||
provider.version,
|
||||
operation,
|
||||
limitation,
|
||||
]);
|
||||
|
||||
/** Render unique source caveats without losing their provider or operation. */
|
||||
export const renderSourceLimitations = (
|
||||
limitations: readonly SourceLimitation[],
|
||||
): string[] => {
|
||||
const groups = new Map<
|
||||
string,
|
||||
{
|
||||
readonly provider: ProviderIdentity;
|
||||
readonly limitation: string;
|
||||
readonly operations: Set<AnalysisOperation>;
|
||||
}
|
||||
>();
|
||||
for (const { provider, operation, limitation } of limitations) {
|
||||
const key = JSON.stringify([
|
||||
provider.id,
|
||||
provider.name,
|
||||
provider.version,
|
||||
limitation,
|
||||
]);
|
||||
const group = groups.get(key) ?? {
|
||||
provider,
|
||||
limitation,
|
||||
operations: new Set<AnalysisOperation>(),
|
||||
};
|
||||
group.operations.add(operation);
|
||||
groups.set(key, group);
|
||||
}
|
||||
return [...groups.values()]
|
||||
.map(({ provider, limitation, operations }) => {
|
||||
const version = provider.version === null ? "" : ` ${provider.version}`;
|
||||
const operationList = [...operations].sort(compareUnicodeCodePoints);
|
||||
return `${provider.name} (${provider.id}${version}) ${operationList.join(", ")}: ${limitation}`;
|
||||
})
|
||||
.sort(compareUnicodeCodePoints);
|
||||
};
|
||||
@@ -169,10 +169,10 @@ const claudeDesktopLocation = (
|
||||
};
|
||||
|
||||
const claudeCodeConfigDirectory = ({ home, env }: ClientPathContext): string =>
|
||||
env.CLAUDE_CONFIG_DIR ?? home;
|
||||
env.CLAUDE_CONFIG_DIR || home;
|
||||
|
||||
const claudeCodeMarkerDirectory = ({ home, env }: ClientPathContext): string =>
|
||||
env.CLAUDE_CONFIG_DIR ?? join(home, ".claude");
|
||||
env.CLAUDE_CONFIG_DIR || join(home, ".claude");
|
||||
|
||||
const codexDirectory = ({ home, env }: ClientPathContext): string =>
|
||||
env.CODEX_HOME || join(home, ".codex");
|
||||
@@ -505,8 +505,8 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [
|
||||
{
|
||||
name: "claude_code",
|
||||
displayName: "Claude Code",
|
||||
skillPath: ({ home, env }: ClientPathContext) =>
|
||||
join(env.CLAUDE_CONFIG_DIR ?? join(home, ".claude"), "skills"),
|
||||
skillPath: (context: ClientPathContext) =>
|
||||
join(claudeCodeMarkerDirectory(context), "skills"),
|
||||
configPath: (context: ClientPathContext) =>
|
||||
join(claudeCodeConfigDirectory(context), ".claude.json"),
|
||||
markerPath: claudeCodeMarkerDirectory,
|
||||
|
||||
@@ -23,8 +23,13 @@ import {
|
||||
skillDestinations,
|
||||
} from "./SetupSkill.js";
|
||||
import { isOwnedClientRegistrationCommand } from "./ClientRegistrationIdentity.js";
|
||||
import { CURRENT_ENTRYPOINT_PATH } from "../process/CurrentEntrypointPath.js";
|
||||
import { dshDocument } from "./DshCordisPatch.js";
|
||||
import { resolveClientConfigTransactionPath } from "./ClientConfigPath.js";
|
||||
import {
|
||||
preserveCurrentRecoveryBackup,
|
||||
type RecoveryBackupPreservationOptions,
|
||||
} from "./SetupRecoveryBackup.js";
|
||||
import {
|
||||
InvalidUtf8FileError,
|
||||
RegularFileCleanupFailure,
|
||||
@@ -328,6 +333,7 @@ export const systemUninstallHost = (
|
||||
removeClient(
|
||||
client,
|
||||
fileSystem,
|
||||
resources,
|
||||
() => clientWriteBlocker(client),
|
||||
preflightOwnedClients.has(client),
|
||||
),
|
||||
@@ -448,8 +454,11 @@ const readClientConfiguration = async (
|
||||
original = await fileSystem.readText(transactionPath);
|
||||
} catch (cause: unknown) {
|
||||
const readFailure =
|
||||
cause instanceof RegularFileCleanupFailure ? cause.cause : cause;
|
||||
return isMissing(cause)
|
||||
cause instanceof RegularFileCleanupFailure &&
|
||||
cause.outcome.kind === "failed"
|
||||
? cause.outcome.cause
|
||||
: cause;
|
||||
return isMissing(readFailure)
|
||||
? itemRead(item(client.name, "skipped", "Configuration does not exist."))
|
||||
: configurationFailure(
|
||||
readFailure instanceof InvalidUtf8FileError
|
||||
@@ -506,9 +515,42 @@ const clientRegistrationRemoval = (
|
||||
};
|
||||
};
|
||||
|
||||
/** Preserve the bytes uninstall is about to edit and return the backup holding them. */
|
||||
const backupClientConfiguration = async (
|
||||
client: SetupClient,
|
||||
transactionPath: string,
|
||||
original: string,
|
||||
options: RecoveryBackupPreservationOptions,
|
||||
): Promise<string | UninstallItem> => {
|
||||
const failed = (detail: string) =>
|
||||
item(
|
||||
client.name,
|
||||
"failed",
|
||||
`Configuration could not be backed up, so no change was made. ${detail}`,
|
||||
);
|
||||
try {
|
||||
const preserved = await preserveCurrentRecoveryBackup(
|
||||
`${client.configPath}.rea.backup`,
|
||||
transactionPath,
|
||||
original,
|
||||
options,
|
||||
);
|
||||
if (preserved.ok) return preserved.value;
|
||||
if (preserved.error.kind === "occupied")
|
||||
return failed(
|
||||
`${preserved.error.detail}. Move that file aside, then rerun uninstall.`,
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
// The resource scope retains a backup handle whose close failed.
|
||||
if (!(cause instanceof RegularFileCleanupFailure)) throw cause;
|
||||
}
|
||||
return failed("Check file permissions, then rerun uninstall.");
|
||||
};
|
||||
|
||||
const removeClient = async (
|
||||
client: UninstallClient,
|
||||
fileSystem: UninstallFileSystem,
|
||||
resources: ArtifactResourceScope,
|
||||
writeBlocker?: () => string | undefined,
|
||||
preflightOwned = false,
|
||||
): Promise<UninstallItem> => {
|
||||
@@ -555,17 +597,16 @@ const removeClient = async (
|
||||
...(removeNative ? [clientServerPath(parsed, name)] : []),
|
||||
...(legacyPath === undefined ? [] : [legacyPath]),
|
||||
];
|
||||
const backupPath = `${client.configPath}.rea.backup`;
|
||||
try {
|
||||
await fileSystem.copy(transactionPath, backupPath);
|
||||
} catch (cause: unknown) {
|
||||
if (!(cause instanceof Error && "code" in cause && cause.code === "EEXIST"))
|
||||
return item(
|
||||
client.name,
|
||||
"failed",
|
||||
"Configuration could not be backed up, so no change was made. Check file permissions, then rerun uninstall.",
|
||||
);
|
||||
}
|
||||
const backupPath = await backupClientConfiguration(
|
||||
client,
|
||||
transactionPath,
|
||||
original,
|
||||
{
|
||||
copy: (source, destination) => fileSystem.copy(source, destination),
|
||||
resources,
|
||||
},
|
||||
);
|
||||
if (typeof backupPath !== "string") return backupPath;
|
||||
try {
|
||||
await fileSystem.writeText(
|
||||
transactionPath,
|
||||
@@ -602,13 +643,13 @@ const removeClient = async (
|
||||
return item(
|
||||
client.name,
|
||||
"failed",
|
||||
"Configuration could not be updated or restored. Restore its `.rea.backup` manually, then rerun uninstall.",
|
||||
`Configuration could not be updated or restored. Restore the backup at ${backupPath} manually, then rerun uninstall.`,
|
||||
);
|
||||
}
|
||||
return item(
|
||||
client.name,
|
||||
"failed",
|
||||
"Configuration could not be updated. The original was restored and its `.rea.backup` was retained. Repair the configuration or restore the backup, then rerun uninstall.",
|
||||
`Configuration could not be updated. The original was restored and its backup at ${backupPath} was retained. Repair the configuration or restore that backup, then rerun uninstall.`,
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -642,12 +683,15 @@ const isOwnedRegistration = (value: unknown, client: SetupClient): boolean => {
|
||||
.safeParse(value);
|
||||
if (!parsed.success) return false;
|
||||
const command = parsed.data.command;
|
||||
return isOwnedClientRegistrationCommand(command);
|
||||
return isOwnedClientRegistrationCommand(command, CURRENT_ENTRYPOINT_PATH);
|
||||
}
|
||||
const parsed = registrationSchema.safeParse(value);
|
||||
if (!parsed.success) return false;
|
||||
const { command, args } = parsed.data;
|
||||
return isOwnedClientRegistrationCommand([command, ...args]);
|
||||
return isOwnedClientRegistrationCommand(
|
||||
[command, ...args],
|
||||
CURRENT_ENTRYPOINT_PATH,
|
||||
);
|
||||
};
|
||||
|
||||
const purgeManagedData = async (
|
||||
|
||||
@@ -4,6 +4,10 @@ import { historicalHar } from "../../tests/fixtures/historicalHar.js";
|
||||
import { ok } from "../domain/result.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import { parseEvidence } from "../domain/evidence.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisOutputError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import {
|
||||
webNetworkCaptureSchema,
|
||||
type WebNetworkCapture,
|
||||
@@ -125,6 +129,43 @@ it("rejects an adapter omitting its original observed digest before caller proje
|
||||
if (!result.ok) expect(result.error._tag).toBe("AnalysisOutputError");
|
||||
});
|
||||
|
||||
it("retains valid completed capture Evidence on late cancellation and preserves invalid output", async () => {
|
||||
const controller = new AbortController();
|
||||
const report = fixture();
|
||||
const late = await new WebNetworkCaptureService({
|
||||
inspect: () => {
|
||||
controller.abort();
|
||||
return Promise.resolve(ok(report));
|
||||
},
|
||||
}).inspect(
|
||||
{ capture_path: report.artifact.path, format: "har" },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
expect(late.ok).toBe(false);
|
||||
if (!late.ok) {
|
||||
expect(late.error).toBeInstanceOf(AnalysisCancelledError);
|
||||
expect(parseEvidence(late.error.partialObservation)).toMatchObject({
|
||||
operation: "inspect_web_network_capture",
|
||||
normalized_result: expect.objectContaining({ total_records: 1 }),
|
||||
});
|
||||
}
|
||||
|
||||
const malformed = fixture();
|
||||
malformed.artifact.path = "/other.har";
|
||||
const outputController = new AbortController();
|
||||
const invalid = await new WebNetworkCaptureService({
|
||||
inspect: () => {
|
||||
outputController.abort();
|
||||
return Promise.resolve(ok(malformed));
|
||||
},
|
||||
}).inspect(
|
||||
{ capture_path: "/capture.har", format: "har" },
|
||||
{ signal: outputController.signal },
|
||||
);
|
||||
expect(invalid.ok).toBe(false);
|
||||
if (!invalid.ok) expect(invalid.error).toBeInstanceOf(AnalysisOutputError);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["producer"],
|
||||
["artifact"],
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
captureSensitiveValues,
|
||||
webNetworkCaptureSchema,
|
||||
} from "../domain/webNetworkCapture.js";
|
||||
import { completeProviderEvidence } from "./ProviderOperationExecution.js";
|
||||
|
||||
const OPERATION = "inspect_web_network_capture";
|
||||
|
||||
@@ -74,8 +75,6 @@ export class WebNetworkCaptureService {
|
||||
);
|
||||
const loaded = await this.port.inspect(input, options);
|
||||
if (!loaded.ok) return failure(loaded.error);
|
||||
if (options?.signal?.aborted)
|
||||
return failure(new AnalysisCancelledError(OPERATION));
|
||||
const report = webNetworkCaptureSchema.safeParse(loaded.value);
|
||||
if (!report.success)
|
||||
return failure(
|
||||
@@ -159,7 +158,7 @@ export class WebNetworkCaptureService {
|
||||
record_ordinals: selected,
|
||||
sensitive_value_count: input.sensitive_values.length,
|
||||
});
|
||||
return ok(
|
||||
return completeProviderEvidence(
|
||||
createEvidence(
|
||||
safeSha256 === null
|
||||
? undefined
|
||||
@@ -174,7 +173,11 @@ export class WebNetworkCaptureService {
|
||||
parameters,
|
||||
result: {
|
||||
...safeValue,
|
||||
artifact: { ...value.artifact, path: safePath, sha256: safeSha256 },
|
||||
artifact: {
|
||||
...value.artifact,
|
||||
path: safePath,
|
||||
sha256: safeSha256,
|
||||
},
|
||||
records,
|
||||
limitations:
|
||||
safeSha256 === null
|
||||
@@ -198,6 +201,7 @@ export class WebNetworkCaptureService {
|
||||
safePath === "" ? [] : [{ kind: "artifact-path", path: safePath }],
|
||||
},
|
||||
),
|
||||
options?.signal,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@ import type {
|
||||
|
||||
/** Provider-neutral live source attribution operations with distinct instrumentation authority. */
|
||||
export interface WebRuntimePort {
|
||||
/** Release provider-owned transports during command or server shutdown. */
|
||||
close?(): Promise<void>;
|
||||
identity(): ProviderIdentity;
|
||||
observeExecution(
|
||||
input: ObserveWebExecutionInput,
|
||||
|
||||
@@ -2,10 +2,14 @@ import { expect, it } from "vitest";
|
||||
import { WebRuntimeService } from "./WebRuntimeService.js";
|
||||
import {
|
||||
recordingWebRuntimePort,
|
||||
webExecutionFixture,
|
||||
webListenersFixture,
|
||||
} from "./WebRuntimeService.fixture.js";
|
||||
import { err, ok } from "../domain/result.js";
|
||||
import { AnalysisTimeoutError } from "../domain/analysisErrorCore.js";
|
||||
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||
import { parseEvidence } from "../domain/evidence.js";
|
||||
import { AnalysisOutputError } from "../domain/analysisErrorCore.js";
|
||||
|
||||
it("rejects malformed input and prior cancellation without calling a provider", async () => {
|
||||
const provider = recordingWebRuntimePort();
|
||||
@@ -59,3 +63,96 @@ it("preserves actual provider failures and rejects changed target/selector ident
|
||||
if (changed.ok) throw new Error("Changed selection must be rejected");
|
||||
expect(changed.error._tag).toBe("AnalysisOutputError");
|
||||
});
|
||||
|
||||
it("converts raw execution retained by provider cleanup failure into partial Evidence", async () => {
|
||||
const args = {
|
||||
cdp_endpoint: "http://127.0.0.1:9222",
|
||||
target_id: "selected-page",
|
||||
observation_ms: 5,
|
||||
};
|
||||
const execution = webExecutionFixture(args.target_id, args.observation_ms);
|
||||
const failure = new ProviderCleanupError(
|
||||
"rea-cdp-browser",
|
||||
["selected-page"],
|
||||
{ reason: "target detach failed" },
|
||||
{ operation: "observe_web_execution", partialObservation: execution },
|
||||
);
|
||||
const result = await new WebRuntimeService({
|
||||
identity: () => ({
|
||||
id: "web-runtime",
|
||||
name: "Runtime fixture",
|
||||
version: "1",
|
||||
}),
|
||||
observeExecution: async () => err(failure),
|
||||
inspectEventListeners: async () => err(failure),
|
||||
}).observe(args);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toBe(failure);
|
||||
expect(result.error.cleanupIncomplete).toBe(true);
|
||||
expect(parseEvidence(result.error.partialObservation)).toMatchObject({
|
||||
operation: "observe_web_execution",
|
||||
normalized_result: execution,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("retains completed execution Evidence on late abort and keeps invalid output primary", async () => {
|
||||
const input = {
|
||||
cdp_endpoint: "http://127.0.0.1:9222",
|
||||
target_id: "selected-page",
|
||||
observation_ms: 5,
|
||||
};
|
||||
const unused = new AnalysisTimeoutError(
|
||||
"inspect_web_event_listeners",
|
||||
20_000,
|
||||
);
|
||||
const controller = new AbortController();
|
||||
const cancellationReason = new Error(
|
||||
"caller cancelled the runtime observation",
|
||||
);
|
||||
const completed = await new WebRuntimeService({
|
||||
identity: () => ({
|
||||
id: "web-runtime",
|
||||
name: "Runtime fixture",
|
||||
version: "1",
|
||||
}),
|
||||
observeExecution: async () => {
|
||||
controller.abort(cancellationReason);
|
||||
return ok(webExecutionFixture(input.target_id, input.observation_ms));
|
||||
},
|
||||
inspectEventListeners: async () => err(unused),
|
||||
}).observe(input, { signal: controller.signal });
|
||||
if (completed.ok) throw new Error("Expected late cancellation");
|
||||
expect(completed.error._tag).toBe("AnalysisCancelledError");
|
||||
expect(completed.error.cause).toBe(cancellationReason);
|
||||
expect(parseEvidence(completed.error.partialObservation)).toMatchObject({
|
||||
operation: "observe_web_execution",
|
||||
normalized_result: webExecutionFixture(
|
||||
input.target_id,
|
||||
input.observation_ms,
|
||||
),
|
||||
});
|
||||
|
||||
const late = new AbortController();
|
||||
const invalid = await new WebRuntimeService({
|
||||
identity: () => ({
|
||||
id: "web-runtime",
|
||||
name: "Runtime fixture",
|
||||
version: "1",
|
||||
}),
|
||||
observeExecution: async () => {
|
||||
late.abort();
|
||||
return ok(webExecutionFixture(input.target_id, input.observation_ms));
|
||||
},
|
||||
inspectEventListeners: async () => err(unused),
|
||||
}).observe(
|
||||
{ ...input, allowed_origins: ["https://allowed.example"] },
|
||||
{ signal: late.signal },
|
||||
);
|
||||
expect(invalid).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisOutputError" },
|
||||
});
|
||||
if (!invalid.ok) expect(invalid.error).toBeInstanceOf(AnalysisOutputError);
|
||||
});
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
AnalysisOutputError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { createEvidence, type Evidence } from "../domain/evidence.js";
|
||||
import { jsonObjectSchema, jsonValueSchema } from "../domain/jsonValue.js";
|
||||
import { jsonObjectSchema } from "../domain/jsonValue.js";
|
||||
import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import {
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
inspectWebEventListenersInputSchema,
|
||||
webEventListenersSchema,
|
||||
} from "../domain/webEventListeners.js";
|
||||
import { completeProviderEvidence } from "./ProviderOperationExecution.js";
|
||||
|
||||
/** Shared public workflows keep instrumentation and listener inspection independently selectable. */
|
||||
export class WebRuntimeService {
|
||||
@@ -37,7 +38,35 @@ export class WebRuntimeService {
|
||||
);
|
||||
if (!parsed.ok) return parsed;
|
||||
const result = await this.provider.observeExecution(parsed.value, options);
|
||||
if (!result.ok) return result;
|
||||
if (!result.ok) {
|
||||
const partial = result.error.partialObservation;
|
||||
if (
|
||||
partial !== undefined &&
|
||||
!("evidence_id" in partial) &&
|
||||
"instrumentation" in partial &&
|
||||
"window" in partial
|
||||
) {
|
||||
const verifiedPartial = webExecutionSchema.safeParse(partial);
|
||||
if (verifiedPartial.success) {
|
||||
const evidence = this.evidence(
|
||||
operation,
|
||||
parsed.value,
|
||||
verifiedPartial.data,
|
||||
);
|
||||
if (evidence.ok) result.error.partialObservation = evidence.value;
|
||||
else if (result.error instanceof AnalysisCancelledError)
|
||||
return evidence;
|
||||
} else if (result.error instanceof AnalysisCancelledError)
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
operation,
|
||||
"Provider retained a malformed completed execution observation.",
|
||||
{ cause: verifiedPartial.error },
|
||||
),
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
const verified = webExecutionSchema.safeParse(result.value);
|
||||
if (!verified.success)
|
||||
return err(new AnalysisOutputError(operation, verified.error.message));
|
||||
@@ -71,7 +100,35 @@ export class WebRuntimeService {
|
||||
parsed.value,
|
||||
options,
|
||||
);
|
||||
if (!result.ok) return result;
|
||||
if (!result.ok) {
|
||||
const partial = result.error.partialObservation;
|
||||
if (
|
||||
partial !== undefined &&
|
||||
!("evidence_id" in partial) &&
|
||||
"selected_node" in partial &&
|
||||
"listeners" in partial
|
||||
) {
|
||||
const verifiedPartial = webEventListenersSchema.safeParse(partial);
|
||||
if (verifiedPartial.success) {
|
||||
const evidence = this.evidence(
|
||||
operation,
|
||||
parsed.value,
|
||||
verifiedPartial.data,
|
||||
);
|
||||
if (evidence.ok) result.error.partialObservation = evidence.value;
|
||||
else if (result.error instanceof AnalysisCancelledError)
|
||||
return evidence;
|
||||
} else if (result.error instanceof AnalysisCancelledError)
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
operation,
|
||||
"Provider retained malformed completed event-listener data.",
|
||||
{ cause: verifiedPartial.error },
|
||||
),
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
const verified = webEventListenersSchema.safeParse(result.value);
|
||||
if (!verified.success)
|
||||
return err(new AnalysisOutputError(operation, verified.error.message));
|
||||
@@ -98,8 +155,6 @@ export class WebRuntimeService {
|
||||
| z.infer<typeof webEventListenersSchema>,
|
||||
options?: ExecutionOptions,
|
||||
): Result<Evidence, AnalysisError> {
|
||||
if (options?.signal?.aborted)
|
||||
return err(new AnalysisCancelledError(operation));
|
||||
if (
|
||||
input.allowed_origins.length > 0 &&
|
||||
!input.allowed_origins.includes(result.target.origin)
|
||||
@@ -110,16 +165,17 @@ export class WebRuntimeService {
|
||||
"Provider returned a document outside the selected origin scope.",
|
||||
),
|
||||
);
|
||||
return ok(
|
||||
return completeProviderEvidence(
|
||||
createEvidence(undefined, this.provider.identity(), {
|
||||
predicateType: `rea.${operation.replaceAll("_", "-")}`,
|
||||
operation,
|
||||
parameters: jsonObjectSchema.parse(input),
|
||||
result: jsonValueSchema.parse(result),
|
||||
result,
|
||||
confidence: "observed",
|
||||
authority: "external-service",
|
||||
limitations: result.limitations,
|
||||
}),
|
||||
options?.signal,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,11 +243,11 @@ const readCapture = async (
|
||||
incomplete: retry.cleanup !== undefined,
|
||||
cause: cause.cleanupCause,
|
||||
};
|
||||
if (cause.outcome.kind === "completed")
|
||||
return ok({ bytes: cause.outcome.value as Buffer, cleanup });
|
||||
if (retry.outcome.kind === "completed")
|
||||
return ok({ bytes: retry.outcome.value as Buffer, cleanup });
|
||||
const primary = captureReadFailure(
|
||||
path,
|
||||
cause.outcome.cause,
|
||||
retry.outcome.cause,
|
||||
signal,
|
||||
retry.cleanup === undefined ? { cause: cause.cleanupCause } : undefined,
|
||||
);
|
||||
|
||||
Loaded 100 of 1583 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user