mirror of
https://github.com/morluto/rea.git
synced 2026-11-13 10:12:50 +00:00
feat(session): add explicit provider registry and target binding
Introduce deterministic deep-provider discovery and immutable per-target routing while retaining disjoint artifact and native capability families. Expose provider selection consistently through MCP, CLI, environment configuration, session status, prompt completion, typed diagnostics, and generated documentation. Preserve exact provider/profile provenance without request racing or transparent fallback. Cover overlapping providers, ambiguity, rejection details, cancellation, restoration, package installation, and real Hopper target switching.
This commit is contained in:
1 parent
bd3cf964d6
commit
941d7101dd
174 files changed
+5370
-648
No files matched your search
@@ -0,0 +1,25 @@
|
||||
/** Sentinel returned when an abort signal wins an asynchronous race. */
|
||||
export const ABORTED = Symbol("aborted");
|
||||
|
||||
/**
|
||||
* Await a promise without depending on the producer to observe cancellation.
|
||||
* The producer still receives its signal separately and remains responsible for
|
||||
* releasing any resources that it owns.
|
||||
*/
|
||||
export const waitForAbortable = async <Value>(
|
||||
pending: Promise<Value>,
|
||||
signal: AbortSignal | undefined,
|
||||
): Promise<Value | typeof ABORTED> => {
|
||||
if (signal === undefined) return pending;
|
||||
if (signal.aborted) return ABORTED;
|
||||
let onAbort: (() => void) | undefined;
|
||||
const cancelled = new Promise<typeof ABORTED>((resolve) => {
|
||||
onAbort = () => resolve(ABORTED);
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
try {
|
||||
return await Promise.race([pending, cancelled]);
|
||||
} finally {
|
||||
if (onAbort !== undefined) signal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
};
|
||||
@@ -11,6 +11,7 @@ import type { EvidenceLocation } from "../domain/evidence.js";
|
||||
import type { EvidenceSubjectTarget } from "../domain/evidence.js";
|
||||
import { jsonValueSchema } from "../domain/jsonValue.js";
|
||||
import type { ProgressReporter } from "./ProgressReporter.js";
|
||||
import type { ProviderRejectionCode } from "../contracts/providerSelection.js";
|
||||
|
||||
export interface ExecutionOptions {
|
||||
readonly signal?: AbortSignal;
|
||||
@@ -93,6 +94,11 @@ export interface AnalysisProfileResolution {
|
||||
readonly compatibility: Readonly<Record<string, JsonValue>>;
|
||||
}
|
||||
|
||||
/** Cancellation context for side-effect-free provider profile resolution. */
|
||||
export interface AnalysisProfileResolutionOptions {
|
||||
readonly signal?: AbortSignal;
|
||||
}
|
||||
|
||||
interface CapabilityEffects {
|
||||
readonly mutatesArtifact: boolean;
|
||||
readonly launchesProcess: boolean;
|
||||
@@ -131,9 +137,53 @@ export interface AnalysisProvider {
|
||||
capabilities(): readonly CapabilityDescriptor[];
|
||||
resolveAnalysisProfile?(
|
||||
target: BinaryTarget,
|
||||
options?: AnalysisProfileResolutionOptions,
|
||||
): Promise<Result<AnalysisProfileResolution, AnalysisError>>;
|
||||
createClient(
|
||||
target: BinaryTarget,
|
||||
profile?: AnalysisProfileCommitment,
|
||||
): AnalysisClient;
|
||||
}
|
||||
|
||||
/** Bounded host observation made without starting an analysis process. */
|
||||
export type ProviderAvailability =
|
||||
| {
|
||||
readonly status: "available";
|
||||
readonly code: null;
|
||||
readonly reason: null;
|
||||
readonly diagnostics: Readonly<Record<string, JsonValue>>;
|
||||
}
|
||||
| {
|
||||
readonly status: "unavailable";
|
||||
readonly code: ProviderRejectionCode;
|
||||
readonly reason: string;
|
||||
readonly diagnostics: Readonly<Record<string, JsonValue>>;
|
||||
};
|
||||
|
||||
/** Provider-owned target support observation before profile resolution. */
|
||||
export type ProviderTargetSupport =
|
||||
| {
|
||||
readonly status: "supported";
|
||||
readonly code: null;
|
||||
readonly reason: null;
|
||||
readonly diagnostics: Readonly<Record<string, JsonValue>>;
|
||||
}
|
||||
| {
|
||||
readonly status: "unsupported";
|
||||
readonly code:
|
||||
| "target_kind_unsupported"
|
||||
| "target_format_unsupported"
|
||||
| "architecture_unsupported";
|
||||
readonly reason: string;
|
||||
readonly diagnostics: Readonly<Record<string, JsonValue>>;
|
||||
};
|
||||
|
||||
/** Deep provider candidate discoverable before one target-bound route is chosen. */
|
||||
export interface AnalysisProviderCandidate extends AnalysisProvider {
|
||||
resolveAnalysisProfile(
|
||||
target: BinaryTarget,
|
||||
options?: AnalysisProfileResolutionOptions,
|
||||
): Promise<Result<AnalysisProfileResolution, AnalysisError>>;
|
||||
inspectAvailability(): ProviderAvailability;
|
||||
inspectTargetSupport(target: BinaryTarget): ProviderTargetSupport;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import {
|
||||
analysisProfileSchema,
|
||||
type AnalysisProfileCommitment,
|
||||
} from "../domain/analysisProfile.js";
|
||||
import type { BinaryTarget } from "../domain/binaryTarget.js";
|
||||
import { AnalysisCancelledError } from "../domain/errors.js";
|
||||
import { jsonObjectSchema, type JsonValue } from "../domain/jsonValue.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import type { AnalysisProviderCandidate } from "./AnalysisProvider.js";
|
||||
import type { AnalysisProviderCandidateStatus } from "./AnalysisProviderRegistry.js";
|
||||
import { ABORTED, waitForAbortable } from "./AbortablePromise.js";
|
||||
|
||||
/** Profile-resolution truth for one discovered provider candidate. */
|
||||
export interface AnalysisProviderCandidateEvaluation {
|
||||
readonly candidate: AnalysisProviderCandidate;
|
||||
readonly status: AnalysisProviderCandidateStatus;
|
||||
readonly profile?: AnalysisProfileCommitment;
|
||||
readonly compatibility?: Readonly<Record<string, JsonValue>>;
|
||||
}
|
||||
|
||||
/** Resolve and validate one provider profile without starting its client. */
|
||||
export const evaluateAnalysisProviderCandidate = async (
|
||||
candidate: AnalysisProviderCandidate,
|
||||
status: AnalysisProviderCandidateStatus,
|
||||
target: BinaryTarget,
|
||||
signal: AbortSignal | undefined,
|
||||
): Promise<
|
||||
Result<AnalysisProviderCandidateEvaluation, AnalysisCancelledError>
|
||||
> => {
|
||||
if (signalIsAborted(signal))
|
||||
return err(new AnalysisCancelledError("open_binary"));
|
||||
let resolved: Awaited<ReturnType<typeof candidate.resolveAnalysisProfile>>;
|
||||
try {
|
||||
const resolution = await waitForAbortable(
|
||||
candidate.resolveAnalysisProfile(
|
||||
target,
|
||||
signal === undefined ? undefined : { signal },
|
||||
),
|
||||
signal,
|
||||
);
|
||||
if (resolution === ABORTED)
|
||||
return err(new AnalysisCancelledError("open_binary"));
|
||||
resolved = resolution;
|
||||
} catch {
|
||||
return ok(
|
||||
rejectedProfile(candidate, status, "Provider profile resolution threw"),
|
||||
);
|
||||
}
|
||||
if (signalIsAborted(signal))
|
||||
return err(new AnalysisCancelledError("open_binary"));
|
||||
if (!resolved.ok && resolved.error instanceof AnalysisCancelledError)
|
||||
return err(resolved.error);
|
||||
if (!resolved.ok)
|
||||
return ok(
|
||||
rejectedProfile(candidate, status, resolved.error.message, {
|
||||
error_tag: resolved.error._tag,
|
||||
}),
|
||||
);
|
||||
try {
|
||||
if (resolved.value.profile === null)
|
||||
return ok(
|
||||
rejectedProfile(
|
||||
candidate,
|
||||
status,
|
||||
"Provider version or analysis profile could not be resolved",
|
||||
),
|
||||
);
|
||||
const profile = analysisProfileSchema.parse(resolved.value.profile);
|
||||
const compatibility = jsonObjectSchema.parse(resolved.value.compatibility);
|
||||
const identity = candidate.identity();
|
||||
if (
|
||||
profile.provider.id !== identity.id ||
|
||||
profile.provider.name !== identity.name ||
|
||||
(identity.version !== null &&
|
||||
profile.provider.version !== identity.version)
|
||||
)
|
||||
return ok(
|
||||
rejectedProfile(
|
||||
candidate,
|
||||
status,
|
||||
"Resolved analysis profile has mismatched provider identity",
|
||||
),
|
||||
);
|
||||
return ok({ candidate, status, profile, compatibility });
|
||||
} catch {
|
||||
return ok(
|
||||
rejectedProfile(
|
||||
candidate,
|
||||
status,
|
||||
"Provider returned an invalid analysis profile",
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const signalIsAborted = (signal: AbortSignal | undefined): boolean =>
|
||||
signal?.aborted === true;
|
||||
|
||||
const rejectedProfile = (
|
||||
candidate: AnalysisProviderCandidate,
|
||||
status: AnalysisProviderCandidateStatus,
|
||||
reason: string,
|
||||
diagnostics: Readonly<Record<string, JsonValue>> = {},
|
||||
): AnalysisProviderCandidateEvaluation => ({
|
||||
candidate,
|
||||
status: {
|
||||
...status,
|
||||
availability: {
|
||||
status: "unavailable",
|
||||
code: "version_unresolved",
|
||||
reason,
|
||||
diagnostics: jsonObjectSchema.parse({
|
||||
...status.availability.diagnostics,
|
||||
...diagnostics,
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,453 @@
|
||||
import {
|
||||
analysisProviderIdSchema,
|
||||
analysisProviderSelectorSchema,
|
||||
type AnalysisProviderSelector,
|
||||
} from "../contracts/providerSelection.js";
|
||||
import type { AnalysisProfileCommitment } from "../domain/analysisProfile.js";
|
||||
import type { BinaryTarget } from "../domain/binaryTarget.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
ProviderSelectionError,
|
||||
type ProviderSelectionFailureReason,
|
||||
type ProviderSelectionRejection,
|
||||
} from "../domain/errors.js";
|
||||
import { jsonObjectSchema, type JsonValue } from "../domain/jsonValue.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import type {
|
||||
AnalysisProviderCandidate,
|
||||
CapabilityDescriptor,
|
||||
ProviderAvailability,
|
||||
ProviderIdentity,
|
||||
ProviderTargetSupport,
|
||||
} from "./AnalysisProvider.js";
|
||||
import {
|
||||
evaluateAnalysisProviderCandidate,
|
||||
type AnalysisProviderCandidateEvaluation,
|
||||
} from "./AnalysisProviderEvaluation.js";
|
||||
|
||||
/** Deterministic discovery truth for one configured deep provider. */
|
||||
export interface AnalysisProviderCandidateStatus {
|
||||
readonly provider: ProviderIdentity;
|
||||
readonly availability: ProviderAvailability;
|
||||
readonly targetSupport:
|
||||
| ProviderTargetSupport
|
||||
| {
|
||||
/** No target has been parsed, so support has not been evaluated. */
|
||||
readonly status: "unknown";
|
||||
readonly code: null;
|
||||
readonly reason: string;
|
||||
readonly diagnostics: Readonly<Record<string, JsonValue>>;
|
||||
};
|
||||
readonly selected: boolean;
|
||||
readonly capabilities: readonly CapabilityDescriptor[];
|
||||
}
|
||||
|
||||
/** One immutable deep-provider binding selected before client startup. */
|
||||
export interface AnalysisProviderBinding {
|
||||
readonly provider: AnalysisProviderCandidate;
|
||||
readonly identity: ProviderIdentity & { readonly version: string };
|
||||
readonly selectionSource: "request" | "environment" | "auto-single-candidate";
|
||||
readonly profile: AnalysisProfileCommitment;
|
||||
readonly compatibility: Readonly<Record<string, JsonValue>>;
|
||||
}
|
||||
|
||||
/** Complete result used for both a bound and intentionally unbound target. */
|
||||
export interface AnalysisProviderSelection {
|
||||
readonly requestedProviderId: string;
|
||||
readonly binding: AnalysisProviderBinding | null;
|
||||
readonly candidates: readonly AnalysisProviderCandidateStatus[];
|
||||
}
|
||||
|
||||
interface TargetSelectionContext {
|
||||
readonly target: BinaryTarget;
|
||||
readonly baseline: readonly AnalysisProviderCandidateStatus[];
|
||||
readonly signal: AbortSignal | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover and select overlapping deep providers without starting their clients.
|
||||
* Registration order never affects selection or caller-visible output.
|
||||
*/
|
||||
export class AnalysisProviderRegistry {
|
||||
readonly #providers: readonly AnalysisProviderCandidate[];
|
||||
readonly #byId: ReadonlyMap<string, AnalysisProviderCandidate>;
|
||||
readonly #declaredOperations: readonly string[];
|
||||
readonly #defaultSelector: AnalysisProviderSelector;
|
||||
|
||||
constructor(
|
||||
providers: readonly AnalysisProviderCandidate[],
|
||||
defaultSelector: AnalysisProviderSelector = "auto",
|
||||
) {
|
||||
this.#defaultSelector =
|
||||
analysisProviderSelectorSchema.parse(defaultSelector);
|
||||
const byId = new Map<string, AnalysisProviderCandidate>();
|
||||
const declaredOperations = new Set<string>();
|
||||
for (const provider of providers) {
|
||||
const identity = provider.identity();
|
||||
if (!analysisProviderIdSchema.safeParse(identity.id).success)
|
||||
throw new TypeError(`Invalid analysis provider ID: ${identity.id}`);
|
||||
if (byId.has(identity.id))
|
||||
throw new TypeError(`Duplicate analysis provider ID: ${identity.id}`);
|
||||
const providerOperations = new Set<string>();
|
||||
for (const capability of provider.capabilities()) {
|
||||
if (
|
||||
capability.provider.id !== identity.id ||
|
||||
capability.provider.name !== identity.name ||
|
||||
capability.provider.version !== identity.version
|
||||
)
|
||||
throw new TypeError(
|
||||
`Provider ${identity.id} published mismatched capability provenance for ${capability.operation}`,
|
||||
);
|
||||
if (providerOperations.has(capability.operation))
|
||||
throw new TypeError(
|
||||
`Provider ${identity.id} declares operation ${capability.operation} more than once`,
|
||||
);
|
||||
providerOperations.add(capability.operation);
|
||||
declaredOperations.add(capability.operation);
|
||||
}
|
||||
byId.set(identity.id, provider);
|
||||
}
|
||||
this.#providers = Object.freeze(
|
||||
[...providers].sort((left, right) =>
|
||||
left.identity().id.localeCompare(right.identity().id),
|
||||
),
|
||||
);
|
||||
this.#byId = byId;
|
||||
this.#declaredOperations = Object.freeze(
|
||||
[...declaredOperations].sort((left, right) => left.localeCompare(right)),
|
||||
);
|
||||
}
|
||||
|
||||
/** Return the configured default without interpreting it as priority. */
|
||||
defaultSelector(): AnalysisProviderSelector {
|
||||
return this.#defaultSelector;
|
||||
}
|
||||
|
||||
/** List configured candidate identities in canonical provider-ID order. */
|
||||
identities(): readonly ProviderIdentity[] {
|
||||
return this.#providers.map((provider) =>
|
||||
structuredClone(provider.identity()),
|
||||
);
|
||||
}
|
||||
|
||||
/** List the unique deep-operation declarations without probing candidates. */
|
||||
declaredOperations(): readonly string[] {
|
||||
return [...this.#declaredOperations];
|
||||
}
|
||||
|
||||
/** Discover candidates for status without resolving profiles or starting clients. */
|
||||
candidates(
|
||||
target?: BinaryTarget,
|
||||
): readonly AnalysisProviderCandidateStatus[] {
|
||||
return this.#providers.map((provider) => candidateStatus(provider, target));
|
||||
}
|
||||
|
||||
/** Select one whole deep-operation family for a parsed target. */
|
||||
async select(
|
||||
target: BinaryTarget,
|
||||
requestedSelector?: AnalysisProviderSelector,
|
||||
options: { readonly signal?: AbortSignal } = {},
|
||||
): Promise<
|
||||
Result<
|
||||
AnalysisProviderSelection,
|
||||
ProviderSelectionError | AnalysisCancelledError
|
||||
>
|
||||
> {
|
||||
if (signalIsAborted(options.signal))
|
||||
return err(new AnalysisCancelledError("open_binary"));
|
||||
const parsedRequest =
|
||||
requestedSelector === undefined
|
||||
? undefined
|
||||
: analysisProviderSelectorSchema.safeParse(requestedSelector);
|
||||
if (parsedRequest !== undefined && !parsedRequest.success)
|
||||
return err(
|
||||
selectionError(
|
||||
"invalid_options",
|
||||
typeof requestedSelector === "string"
|
||||
? requestedSelector
|
||||
: "<invalid>",
|
||||
this.candidates(target),
|
||||
),
|
||||
);
|
||||
const request = parsedRequest?.data;
|
||||
const selector = request ?? this.#defaultSelector;
|
||||
const baseline = this.candidates(target);
|
||||
if (signalIsAborted(options.signal))
|
||||
return err(new AnalysisCancelledError("open_binary"));
|
||||
if (
|
||||
request === undefined &&
|
||||
selector !== "auto" &&
|
||||
baseline.length > 0 &&
|
||||
baseline.every(
|
||||
({ targetSupport }) => targetSupport.status === "unsupported",
|
||||
)
|
||||
)
|
||||
return ok({
|
||||
requestedProviderId: selector,
|
||||
binding: null,
|
||||
candidates: baseline,
|
||||
});
|
||||
if (selector !== "auto")
|
||||
return this.#selectConcrete(
|
||||
{ target, baseline, signal: options.signal },
|
||||
selector,
|
||||
request === undefined ? "environment" : "request",
|
||||
);
|
||||
return this.#selectAutomatically({
|
||||
target,
|
||||
baseline,
|
||||
signal: options.signal,
|
||||
});
|
||||
}
|
||||
|
||||
/** Explain why a deep operation is unavailable on an unbound target. */
|
||||
unboundOperationError(
|
||||
operation: string,
|
||||
selection: AnalysisProviderSelection,
|
||||
): ProviderSelectionError {
|
||||
const reason: ProviderSelectionFailureReason =
|
||||
selection.candidates.length > 0 &&
|
||||
selection.candidates.every(
|
||||
({ targetSupport }) => targetSupport.status === "unsupported",
|
||||
)
|
||||
? "target_unsupported"
|
||||
: "provider_unavailable";
|
||||
return selectionError(
|
||||
reason,
|
||||
selection.requestedProviderId,
|
||||
selection.candidates,
|
||||
operation,
|
||||
);
|
||||
}
|
||||
|
||||
async #selectConcrete(
|
||||
context: TargetSelectionContext,
|
||||
providerId: string,
|
||||
source: AnalysisProviderBinding["selectionSource"],
|
||||
): Promise<
|
||||
Result<
|
||||
AnalysisProviderSelection,
|
||||
ProviderSelectionError | AnalysisCancelledError
|
||||
>
|
||||
> {
|
||||
const { baseline, signal, target } = context;
|
||||
const provider = this.#byId.get(providerId);
|
||||
if (provider === undefined)
|
||||
return err(selectionError("unknown_provider", providerId, baseline));
|
||||
const initial = baseline.find(
|
||||
({ provider: identity }) => identity.id === providerId,
|
||||
);
|
||||
if (initial === undefined)
|
||||
throw new TypeError(
|
||||
"Registered provider is absent from discovery output",
|
||||
);
|
||||
if (initial.targetSupport.status !== "supported")
|
||||
return err(selectionError("target_unsupported", providerId, baseline));
|
||||
if (initial.availability.status === "unavailable")
|
||||
return err(selectionError("provider_unavailable", providerId, baseline));
|
||||
const evaluation = await evaluateAnalysisProviderCandidate(
|
||||
provider,
|
||||
initial,
|
||||
target,
|
||||
signal,
|
||||
);
|
||||
if (!evaluation.ok) return evaluation;
|
||||
const evaluated = evaluation.value;
|
||||
const statuses = replaceCandidate(baseline, evaluated.status);
|
||||
if (
|
||||
evaluated.profile === undefined ||
|
||||
evaluated.compatibility === undefined
|
||||
)
|
||||
return err(selectionError("provider_unavailable", providerId, statuses));
|
||||
return ok(
|
||||
selectedResult(
|
||||
providerId,
|
||||
source,
|
||||
{
|
||||
...evaluated,
|
||||
profile: evaluated.profile,
|
||||
compatibility: evaluated.compatibility,
|
||||
},
|
||||
statuses,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async #selectAutomatically(
|
||||
context: TargetSelectionContext,
|
||||
): Promise<
|
||||
Result<
|
||||
AnalysisProviderSelection,
|
||||
ProviderSelectionError | AnalysisCancelledError
|
||||
>
|
||||
> {
|
||||
const { baseline, signal, target } = context;
|
||||
const results = await Promise.all(
|
||||
this.#providers.map(
|
||||
async (
|
||||
provider,
|
||||
): Promise<
|
||||
Result<AnalysisProviderCandidateEvaluation, AnalysisCancelledError>
|
||||
> => {
|
||||
const status = baseline.find(
|
||||
({ provider: identity }) => identity.id === provider.identity().id,
|
||||
);
|
||||
if (status === undefined)
|
||||
throw new TypeError(
|
||||
"Registered provider is absent from discovery output",
|
||||
);
|
||||
return status.availability.status === "available" &&
|
||||
status.targetSupport.status === "supported"
|
||||
? evaluateAnalysisProviderCandidate(
|
||||
provider,
|
||||
status,
|
||||
target,
|
||||
signal,
|
||||
)
|
||||
: ok({
|
||||
candidate: provider,
|
||||
status,
|
||||
} satisfies AnalysisProviderCandidateEvaluation);
|
||||
},
|
||||
),
|
||||
);
|
||||
const cancelled = results.find((result) => !result.ok);
|
||||
if (cancelled !== undefined && !cancelled.ok) return cancelled;
|
||||
const evaluations = results.map((result) => {
|
||||
if (!result.ok)
|
||||
throw new TypeError("Cancelled candidate evaluation disappeared");
|
||||
return result.value;
|
||||
});
|
||||
const statuses = evaluations.map(({ status }) => status);
|
||||
const usable = evaluations.filter(
|
||||
(
|
||||
evaluation,
|
||||
): evaluation is AnalysisProviderCandidateEvaluation & {
|
||||
readonly profile: AnalysisProfileCommitment;
|
||||
readonly compatibility: Readonly<Record<string, JsonValue>>;
|
||||
} =>
|
||||
evaluation.profile !== undefined &&
|
||||
evaluation.compatibility !== undefined,
|
||||
);
|
||||
if (usable.length === 0)
|
||||
return ok({
|
||||
requestedProviderId: "auto",
|
||||
binding: null,
|
||||
candidates: statuses,
|
||||
});
|
||||
if (usable.length > 1)
|
||||
return err(selectionError("ambiguous", "auto", statuses));
|
||||
const selected = usable[0];
|
||||
if (selected === undefined)
|
||||
throw new TypeError("Usable provider selection disappeared");
|
||||
return ok(
|
||||
selectedResult("auto", "auto-single-candidate", selected, statuses),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const candidateStatus = (
|
||||
provider: AnalysisProviderCandidate,
|
||||
target: BinaryTarget | undefined,
|
||||
): AnalysisProviderCandidateStatus => ({
|
||||
provider: structuredClone(provider.identity()),
|
||||
availability: cloneAvailability(provider.inspectAvailability()),
|
||||
targetSupport:
|
||||
target === undefined
|
||||
? {
|
||||
status: "unknown",
|
||||
code: null,
|
||||
reason: "No target is open.",
|
||||
diagnostics: {},
|
||||
}
|
||||
: cloneTargetSupport(provider.inspectTargetSupport(target)),
|
||||
selected: false,
|
||||
capabilities: provider
|
||||
.capabilities()
|
||||
.map((capability) => structuredClone(capability)),
|
||||
});
|
||||
|
||||
const signalIsAborted = (signal: AbortSignal | undefined): boolean =>
|
||||
signal?.aborted === true;
|
||||
|
||||
const selectedResult = (
|
||||
requestedProviderId: string,
|
||||
source: AnalysisProviderBinding["selectionSource"],
|
||||
evaluated: AnalysisProviderCandidateEvaluation & {
|
||||
readonly profile: AnalysisProfileCommitment;
|
||||
readonly compatibility: Readonly<Record<string, JsonValue>>;
|
||||
},
|
||||
statuses: readonly AnalysisProviderCandidateStatus[],
|
||||
): AnalysisProviderSelection => {
|
||||
const selectedStatus = {
|
||||
...evaluated.status,
|
||||
provider: structuredClone(evaluated.profile.provider),
|
||||
selected: true,
|
||||
} satisfies AnalysisProviderCandidateStatus;
|
||||
return {
|
||||
requestedProviderId,
|
||||
binding: {
|
||||
provider: evaluated.candidate,
|
||||
identity: structuredClone(evaluated.profile.provider),
|
||||
selectionSource: source,
|
||||
profile: structuredClone(evaluated.profile),
|
||||
compatibility: structuredClone(evaluated.compatibility),
|
||||
},
|
||||
candidates: replaceCandidate(statuses, selectedStatus),
|
||||
};
|
||||
};
|
||||
|
||||
const replaceCandidate = (
|
||||
statuses: readonly AnalysisProviderCandidateStatus[],
|
||||
replacement: AnalysisProviderCandidateStatus,
|
||||
): readonly AnalysisProviderCandidateStatus[] =>
|
||||
statuses.map((status) =>
|
||||
status.provider.id === replacement.provider.id ? replacement : status,
|
||||
);
|
||||
|
||||
const selectionError = (
|
||||
reason: ProviderSelectionFailureReason,
|
||||
requestedProviderId: string,
|
||||
candidates: readonly AnalysisProviderCandidateStatus[],
|
||||
operation = "open_binary",
|
||||
): ProviderSelectionError =>
|
||||
new ProviderSelectionError({
|
||||
operation,
|
||||
reason,
|
||||
requestedProviderId,
|
||||
candidateIds: candidates.map(({ provider }) => provider.id),
|
||||
rejections: candidateRejections(candidates),
|
||||
});
|
||||
|
||||
const candidateRejections = (
|
||||
candidates: readonly AnalysisProviderCandidateStatus[],
|
||||
): readonly ProviderSelectionRejection[] =>
|
||||
candidates.flatMap((candidate) =>
|
||||
[
|
||||
...(candidate.availability.status === "unavailable"
|
||||
? [candidate.availability]
|
||||
: []),
|
||||
...(candidate.targetSupport.status === "unsupported"
|
||||
? [candidate.targetSupport]
|
||||
: []),
|
||||
].map((rejection) => ({
|
||||
providerId: candidate.provider.id,
|
||||
code: rejection.code,
|
||||
reason: rejection.reason,
|
||||
diagnostics: structuredClone(rejection.diagnostics),
|
||||
})),
|
||||
);
|
||||
|
||||
const cloneAvailability = (
|
||||
availability: ProviderAvailability,
|
||||
): ProviderAvailability => ({
|
||||
...availability,
|
||||
diagnostics: jsonObjectSchema.parse(availability.diagnostics),
|
||||
});
|
||||
|
||||
const cloneTargetSupport = (
|
||||
support: ProviderTargetSupport,
|
||||
): ProviderTargetSupport => ({
|
||||
...support,
|
||||
diagnostics: jsonObjectSchema.parse(support.diagnostics),
|
||||
});
|
||||
+154
-123
@@ -1,10 +1,10 @@
|
||||
import type { BinaryTarget } from "../domain/binaryTarget.js";
|
||||
import { parseBinaryTarget } from "../domain/binaryTarget.js";
|
||||
import {
|
||||
analysisProfileSchema,
|
||||
analysisProfilesEqual,
|
||||
type AnalysisProfileCommitment,
|
||||
} from "../domain/analysisProfile.js";
|
||||
import type { AnalysisProviderSelector } from "../contracts/providerSelection.js";
|
||||
import {
|
||||
AnalysisCapabilityUnavailableError,
|
||||
EvidenceIntegrityError,
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
type AnalysisError,
|
||||
} from "../domain/errors.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { jsonObjectSchema, type JsonValue } from "../domain/jsonValue.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import type { Evidence } from "../domain/evidence.js";
|
||||
import { createEvidence } from "../domain/evidence.js";
|
||||
import type { EvidenceBundle } from "../domain/evidenceBundle.js";
|
||||
@@ -25,6 +25,7 @@ import type {
|
||||
AnalysisExecution,
|
||||
AnalysisClientFactory,
|
||||
AnalysisProfileResolution,
|
||||
AnalysisProfileResolutionOptions,
|
||||
AnalysisOperationPort,
|
||||
AnalysisProvider,
|
||||
CapabilityDescriptor,
|
||||
@@ -61,6 +62,10 @@ import {
|
||||
parseInvestigationWorkspace,
|
||||
type InvestigationWorkspace,
|
||||
} from "../domain/investigationWorkspace.js";
|
||||
import {
|
||||
SessionProviderRouter,
|
||||
type SessionProviderRoute,
|
||||
} from "./SessionProviderRouter.js";
|
||||
export type { BinarySessionPort } from "./BinarySessionPort.js";
|
||||
const OFFICIAL_OPERATIONS: ReadonlySet<string> = new Set(
|
||||
OFFICIAL_TOOL_CONTRACTS.map(({ name }) => name),
|
||||
@@ -80,16 +85,12 @@ export class BinarySession implements BinarySessionPort {
|
||||
readonly client: AnalysisClient;
|
||||
readonly profile: AnalysisProfileCommitment | null;
|
||||
readonly compatibility: Readonly<Record<string, JsonValue>>;
|
||||
readonly route: SessionProviderRoute;
|
||||
}
|
||||
| undefined;
|
||||
#transition: Promise<void> = Promise.resolve();
|
||||
readonly #calls = new Set<Promise<unknown>>();
|
||||
readonly #createClient: AnalysisClientFactory;
|
||||
readonly #resolveAnalysisProfile: (
|
||||
target: BinaryTarget,
|
||||
) => Promise<Result<AnalysisProfileResolution, AnalysisError>>;
|
||||
readonly #providerIdentity: ProviderIdentity;
|
||||
readonly #capabilities: ReadonlyMap<string, CapabilityDescriptor> | undefined;
|
||||
readonly #providerRouter: SessionProviderRouter;
|
||||
readonly #evidence = new EvidenceLedger({
|
||||
maxRecords: 10_000,
|
||||
maxBytes: 64 * 1024 * 1024,
|
||||
@@ -104,48 +105,33 @@ export class BinarySession implements BinarySessionPort {
|
||||
#snapshotInvalidated = false;
|
||||
|
||||
constructor(
|
||||
readonly provider: AnalysisProvider | AnalysisClientFactory,
|
||||
readonly provider:
|
||||
| AnalysisProvider
|
||||
| AnalysisClientFactory
|
||||
| SessionProviderRouter,
|
||||
options: {
|
||||
readonly resolveAnalysisProfile?: (
|
||||
target: BinaryTarget,
|
||||
options?: AnalysisProfileResolutionOptions,
|
||||
) => Promise<Result<AnalysisProfileResolution, AnalysisError>>;
|
||||
} = {},
|
||||
) {
|
||||
this.#createClient =
|
||||
typeof provider === "function"
|
||||
this.#providerRouter =
|
||||
provider instanceof SessionProviderRouter
|
||||
? provider
|
||||
: (target, profile) => provider.createClient(target, profile);
|
||||
this.#resolveAnalysisProfile =
|
||||
options.resolveAnalysisProfile ??
|
||||
(typeof provider === "function" ||
|
||||
provider.resolveAnalysisProfile === undefined
|
||||
? () => Promise.resolve(ok({ profile: null, compatibility: {} }))
|
||||
: (target) =>
|
||||
provider.resolveAnalysisProfile?.(target) ??
|
||||
Promise.resolve(ok({ profile: null, compatibility: {} })));
|
||||
this.#providerIdentity =
|
||||
typeof provider === "function"
|
||||
? { id: "unidentified", name: "Unidentified provider", version: null }
|
||||
: provider.identity();
|
||||
this.#capabilities =
|
||||
typeof provider === "function"
|
||||
? undefined
|
||||
: new Map(
|
||||
provider
|
||||
.capabilities()
|
||||
.map((descriptor) => [descriptor.operation, descriptor]),
|
||||
);
|
||||
: SessionProviderRouter.legacy(provider, options);
|
||||
}
|
||||
|
||||
/** Identify the provider producing evidence for this session. */
|
||||
providerIdentity(operation?: AnalysisOperation): ProviderIdentity {
|
||||
let selected = this.#providerIdentity;
|
||||
const route = this.#currentRoute();
|
||||
let selected = route.identity;
|
||||
if (operation !== undefined) {
|
||||
const exact = this.#capabilities?.get(operation)?.provider;
|
||||
const exact = route.capabilities?.get(operation)?.provider;
|
||||
if (exact !== undefined) selected = exact;
|
||||
else if (enhancedToolNameSchema.safeParse(operation).success) {
|
||||
const providers = new Map<string, ProviderIdentity>();
|
||||
for (const descriptor of this.#capabilities?.values() ?? [])
|
||||
for (const descriptor of route.capabilities?.values() ?? [])
|
||||
if (
|
||||
descriptor.available &&
|
||||
OFFICIAL_OPERATIONS.has(descriptor.operation)
|
||||
@@ -381,6 +367,7 @@ export class BinarySession implements BinarySessionPort {
|
||||
readonly signal?: AbortSignal;
|
||||
readonly targetKind?: BinaryTarget["kind"];
|
||||
readonly snapshot?: AnalysisSnapshot;
|
||||
readonly providerId?: AnalysisProviderSelector;
|
||||
} = {},
|
||||
): Promise<Result<BinaryTarget, AnalysisError>> {
|
||||
return this.#serialize(async () => {
|
||||
@@ -393,14 +380,25 @@ export class BinarySession implements BinarySessionPort {
|
||||
options.targetKind,
|
||||
);
|
||||
if (!parsed.ok) return parsed;
|
||||
const resolvedProfile = await this.#resolveAnalysisProfile(parsed.value);
|
||||
if (!resolvedProfile.ok) return resolvedProfile;
|
||||
const normalizedProfile = normalizeProfileResolution(
|
||||
resolvedProfile.value,
|
||||
this.#providerIdentity.id,
|
||||
);
|
||||
if (!normalizedProfile.ok) return normalizedProfile;
|
||||
const { profile, compatibility } = normalizedProfile.value;
|
||||
const sameTarget =
|
||||
this.#active?.target.path === parsed.value.path &&
|
||||
snapshotMatchesTarget(
|
||||
snapshotTarget(this.#active.target),
|
||||
parsed.value,
|
||||
);
|
||||
const resolvedRoute =
|
||||
sameTarget &&
|
||||
options.providerId === undefined &&
|
||||
this.#active !== undefined
|
||||
? ok(this.#active.route)
|
||||
: await this.#providerRouter.resolve(
|
||||
parsed.value,
|
||||
options.providerId,
|
||||
options.signal,
|
||||
);
|
||||
if (!resolvedRoute.ok) return resolvedRoute;
|
||||
const route = resolvedRoute.value;
|
||||
const { profile, compatibility } = route;
|
||||
if (
|
||||
options.snapshot !== undefined &&
|
||||
(profile === null ||
|
||||
@@ -427,14 +425,7 @@ export class BinarySession implements BinarySessionPort {
|
||||
activeProfile === null || activeProfile === undefined
|
||||
? profile === null
|
||||
: profile !== null && analysisProfilesEqual(activeProfile, profile);
|
||||
if (
|
||||
this.#active?.target.path === parsed.value.path &&
|
||||
snapshotMatchesTarget(
|
||||
snapshotTarget(this.#active.target),
|
||||
parsed.value,
|
||||
) &&
|
||||
sameProfile
|
||||
) {
|
||||
if (sameTarget && sameProfile) {
|
||||
if (options.snapshot !== undefined) {
|
||||
const imported = this.importAnalysisSnapshot(options.snapshot);
|
||||
if (!imported.ok) return imported;
|
||||
@@ -445,7 +436,7 @@ export class BinarySession implements BinarySessionPort {
|
||||
const previous = this.#active;
|
||||
this.#active = undefined;
|
||||
await previous?.client.close();
|
||||
const client = this.#createClient(parsed.value, profile ?? undefined);
|
||||
const client = route.createClient(parsed.value);
|
||||
const started = await client.execute("health", {}, options);
|
||||
if (!started.ok) {
|
||||
await client.close();
|
||||
@@ -462,6 +453,7 @@ export class BinarySession implements BinarySessionPort {
|
||||
client,
|
||||
profile,
|
||||
compatibility: structuredClone(compatibility),
|
||||
route,
|
||||
};
|
||||
this.#clearRuntimeAvailability();
|
||||
if (profile === null) this.#snapshot.clear();
|
||||
@@ -498,23 +490,20 @@ export class BinarySession implements BinarySessionPort {
|
||||
/** Describe the active binary session. */
|
||||
status(): JsonValue {
|
||||
const target = this.#active?.target;
|
||||
const route = this.#currentRoute();
|
||||
const configuredProvider = this.#providerRouter.configuredIdentity();
|
||||
const provider = {
|
||||
id: this.#providerIdentity.id,
|
||||
name: this.#providerIdentity.name,
|
||||
version: this.#providerIdentity.version,
|
||||
id: configuredProvider.id,
|
||||
name: configuredProvider.name,
|
||||
version: configuredProvider.version,
|
||||
};
|
||||
const providers = new Map<string, ProviderIdentity>();
|
||||
if (this.#capabilities === undefined) providers.set(provider.id, provider);
|
||||
else
|
||||
for (const descriptor of this.#capabilities.values())
|
||||
providers.set(descriptor.provider.id, descriptor.provider);
|
||||
const providerList = [...providers.values()]
|
||||
.sort((left, right) => left.id.localeCompare(right.id))
|
||||
const providerList = this.#providerRouter
|
||||
.providerIdentities(route)
|
||||
.map(({ id, name, version }) => ({ id, name, version }));
|
||||
const capabilities =
|
||||
this.#capabilities === undefined
|
||||
route.capabilities === undefined
|
||||
? []
|
||||
: [...this.#capabilities.values()]
|
||||
: [...route.capabilities.values()]
|
||||
.sort((left, right) =>
|
||||
left.operation.localeCompare(right.operation),
|
||||
)
|
||||
@@ -522,37 +511,57 @@ export class BinarySession implements BinarySessionPort {
|
||||
...descriptor,
|
||||
...this.#runtimeAvailability.get(descriptor.operation),
|
||||
}))
|
||||
.map((descriptor) => ({
|
||||
operation: descriptor.operation,
|
||||
available: descriptor.available,
|
||||
reason: descriptor.reason,
|
||||
input_contract_version: descriptor.inputContractVersion,
|
||||
output_contract_version: descriptor.outputContractVersion,
|
||||
pagination: descriptor.pagination,
|
||||
exhaustive: descriptor.exhaustive,
|
||||
effects: {
|
||||
mutates_artifact: descriptor.effects.mutatesArtifact,
|
||||
launches_process: descriptor.effects.launchesProcess,
|
||||
may_show_ui: descriptor.effects.mayShowUi,
|
||||
may_access_network: descriptor.effects.mayAccessNetwork,
|
||||
may_write_filesystem: descriptor.effects.mayWriteFilesystem,
|
||||
changes_permissions: descriptor.effects.changesPermissions,
|
||||
requires_root: descriptor.effects.requiresRoot,
|
||||
},
|
||||
limits: {
|
||||
max_results: descriptor.limits.maxResults,
|
||||
max_payload_bytes: descriptor.limits.maxPayloadBytes,
|
||||
timeout_ms: descriptor.limits.timeoutMs,
|
||||
},
|
||||
limitations: [...descriptor.limitations],
|
||||
}));
|
||||
.map((descriptor) => capabilityStatus(descriptor));
|
||||
const analysisProviderBinding =
|
||||
route.binding === null
|
||||
? null
|
||||
: {
|
||||
provider: {
|
||||
id: route.binding.identity.id,
|
||||
name: route.binding.identity.name,
|
||||
version: route.binding.identity.version,
|
||||
},
|
||||
selection_source: route.binding.selectionSource,
|
||||
analysis_profile: structuredClone(route.binding.profile),
|
||||
};
|
||||
const analysisProviderCandidates = this.#providerRouter
|
||||
.candidateStatuses(route)
|
||||
.map((candidate) => ({
|
||||
provider: {
|
||||
id: candidate.provider.id,
|
||||
name: candidate.provider.name,
|
||||
version: candidate.provider.version,
|
||||
},
|
||||
availability: {
|
||||
status: candidate.availability.status,
|
||||
code: candidate.availability.code,
|
||||
reason: candidate.availability.reason,
|
||||
diagnostics: structuredClone(candidate.availability.diagnostics),
|
||||
},
|
||||
target_support: {
|
||||
status: candidate.targetSupport.status,
|
||||
code: candidate.targetSupport.code,
|
||||
reason: candidate.targetSupport.reason,
|
||||
diagnostics: structuredClone(candidate.targetSupport.diagnostics),
|
||||
},
|
||||
selected: candidate.selected,
|
||||
capabilities: candidate.capabilities
|
||||
.slice()
|
||||
.sort((left, right) => left.operation.localeCompare(right.operation))
|
||||
.map((descriptor) => capabilityStatus(descriptor)),
|
||||
}));
|
||||
const common = {
|
||||
provider,
|
||||
providers: providerList,
|
||||
capabilities,
|
||||
analysis_provider_binding: analysisProviderBinding,
|
||||
analysis_provider_candidates: analysisProviderCandidates,
|
||||
};
|
||||
return target === undefined
|
||||
? { open: false, provider, providers: providerList, capabilities }
|
||||
? { open: false, ...common }
|
||||
: {
|
||||
open: true,
|
||||
provider,
|
||||
providers: providerList,
|
||||
capabilities,
|
||||
...common,
|
||||
path: target.path,
|
||||
sha256: target.sha256,
|
||||
format: target.format,
|
||||
@@ -580,17 +589,26 @@ export class BinarySession implements BinarySessionPort {
|
||||
): Promise<Result<AnalysisExecution, AnalysisError>> {
|
||||
const transitioned = await this.#waitForTransition(name, options?.signal);
|
||||
if (!transitioned.ok) return transitioned;
|
||||
const capability = this.#capabilities?.get(name);
|
||||
if (this.#capabilities !== undefined && capability?.available !== true)
|
||||
const active = this.#active;
|
||||
if (active === undefined) return err(new NoBinaryOpenError());
|
||||
const capability = active.route.capabilities?.get(name);
|
||||
if (
|
||||
active.route.capabilities !== undefined &&
|
||||
capability?.available !== true
|
||||
) {
|
||||
const selectionError = this.#providerRouter.unboundOperationError(
|
||||
name,
|
||||
active.route,
|
||||
);
|
||||
if (selectionError !== undefined) return err(selectionError);
|
||||
return err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
this.#providerIdentity.id,
|
||||
active.route.binding?.identity.id ?? active.route.identity.id,
|
||||
name,
|
||||
capability?.reason ?? "operation is not declared by this provider",
|
||||
),
|
||||
);
|
||||
const active = this.#active;
|
||||
if (active === undefined) return err(new NoBinaryOpenError());
|
||||
}
|
||||
const profile =
|
||||
active.profile !== null &&
|
||||
(capability === undefined ||
|
||||
@@ -650,9 +668,10 @@ export class BinarySession implements BinarySessionPort {
|
||||
"HopperRemoteError",
|
||||
].includes(result.error._tag)
|
||||
) {
|
||||
const providerId = this.#capabilities?.get(operation)?.provider.id;
|
||||
const capabilities = this.#active?.route.capabilities;
|
||||
const providerId = capabilities?.get(operation)?.provider.id;
|
||||
let changed = false;
|
||||
for (const descriptor of this.#capabilities?.values() ?? [])
|
||||
for (const descriptor of capabilities?.values() ?? [])
|
||||
if (providerId !== undefined && descriptor.provider.id === providerId)
|
||||
changed =
|
||||
this.#setRuntimeAvailability(
|
||||
@@ -697,6 +716,10 @@ export class BinarySession implements BinarySessionPort {
|
||||
}
|
||||
}
|
||||
|
||||
#currentRoute(): SessionProviderRoute {
|
||||
return this.#active?.route ?? this.#providerRouter.initialRoute();
|
||||
}
|
||||
|
||||
#serialize<T>(operation: () => Promise<T>): Promise<T> {
|
||||
const result = this.#transition.then(operation, operation);
|
||||
this.#transition = result.then(
|
||||
@@ -717,14 +740,12 @@ export class BinarySession implements BinarySessionPort {
|
||||
readonly client: AnalysisClient;
|
||||
readonly profile: AnalysisProfileCommitment | null;
|
||||
readonly compatibility: Readonly<Record<string, JsonValue>>;
|
||||
readonly route: SessionProviderRoute;
|
||||
}
|
||||
| undefined,
|
||||
): Promise<void> {
|
||||
if (previous === undefined) return;
|
||||
const client = this.#createClient(
|
||||
previous.target,
|
||||
previous.profile ?? undefined,
|
||||
);
|
||||
const client = previous.route.createClient(previous.target);
|
||||
const started = await client.execute("health", {});
|
||||
if (started.ok)
|
||||
this.#active = {
|
||||
@@ -732,6 +753,7 @@ export class BinarySession implements BinarySessionPort {
|
||||
client,
|
||||
profile: previous.profile,
|
||||
compatibility: previous.compatibility,
|
||||
route: previous.route,
|
||||
};
|
||||
else await client.close();
|
||||
}
|
||||
@@ -797,23 +819,32 @@ const commitExecutionProfile = (
|
||||
});
|
||||
};
|
||||
|
||||
const normalizeProfileResolution = (
|
||||
resolution: AnalysisProfileResolution,
|
||||
providerId: string,
|
||||
): Result<AnalysisProfileResolution, ProviderAdapterError> => {
|
||||
try {
|
||||
return ok({
|
||||
profile:
|
||||
resolution.profile === null
|
||||
? null
|
||||
: analysisProfileSchema.parse(resolution.profile),
|
||||
compatibility: jsonObjectSchema.parse(resolution.compatibility),
|
||||
});
|
||||
} catch (cause: unknown) {
|
||||
return err(
|
||||
new ProviderAdapterError(providerId, "resolve_analysis_profile", {
|
||||
cause,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
const capabilityStatus = (
|
||||
descriptor: Omit<CapabilityDescriptor, "available" | "reason"> & {
|
||||
readonly available: boolean;
|
||||
readonly reason: string | null;
|
||||
},
|
||||
) => ({
|
||||
operation: descriptor.operation,
|
||||
available: descriptor.available,
|
||||
reason: descriptor.reason,
|
||||
input_contract_version: descriptor.inputContractVersion,
|
||||
output_contract_version: descriptor.outputContractVersion,
|
||||
pagination: descriptor.pagination,
|
||||
exhaustive: descriptor.exhaustive,
|
||||
effects: {
|
||||
mutates_artifact: descriptor.effects.mutatesArtifact,
|
||||
launches_process: descriptor.effects.launchesProcess,
|
||||
may_show_ui: descriptor.effects.mayShowUi,
|
||||
may_access_network: descriptor.effects.mayAccessNetwork,
|
||||
may_write_filesystem: descriptor.effects.mayWriteFilesystem,
|
||||
changes_permissions: descriptor.effects.changesPermissions,
|
||||
requires_root: descriptor.effects.requiresRoot,
|
||||
},
|
||||
limits: {
|
||||
max_results: descriptor.limits.maxResults,
|
||||
max_payload_bytes: descriptor.limits.maxPayloadBytes,
|
||||
timeout_ms: descriptor.limits.timeoutMs,
|
||||
},
|
||||
limitations: [...descriptor.limitations],
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import type { Evidence } from "../domain/evidence.js";
|
||||
import type { EvidenceBundle } from "../domain/evidenceBundle.js";
|
||||
import type { AnalysisSnapshot } from "../domain/analysisSnapshot.js";
|
||||
import type { AnalysisProfileCommitment } from "../domain/analysisProfile.js";
|
||||
import type { AnalysisProviderSelector } from "../contracts/providerSelection.js";
|
||||
import type {
|
||||
AnalysisError,
|
||||
EvidenceIntegrityError,
|
||||
@@ -32,6 +33,7 @@ export interface BinarySessionPort extends AnalysisOperationPort {
|
||||
readonly signal?: AbortSignal;
|
||||
readonly targetKind?: BinaryTarget["kind"];
|
||||
readonly snapshot?: AnalysisSnapshot;
|
||||
readonly providerId?: AnalysisProviderSelector;
|
||||
},
|
||||
): Promise<Result<BinaryTarget, AnalysisError>>;
|
||||
close(): Promise<Result<null, AnalysisError>>;
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import { ProviderSelectionError } from "../domain/errors.js";
|
||||
import { AnalysisCapabilityUnavailableError } from "../domain/errors.js";
|
||||
import { err, ok } from "../domain/result.js";
|
||||
import type { BinaryTarget } from "../domain/binaryTarget.js";
|
||||
import type { AnalysisProfileCommitment } from "../domain/analysisProfile.js";
|
||||
import type {
|
||||
AnalysisClient,
|
||||
AnalysisProfileResolutionOptions,
|
||||
AnalysisProvider,
|
||||
CapabilityDescriptor,
|
||||
ProviderIdentity,
|
||||
} from "./AnalysisProvider.js";
|
||||
|
||||
const compositeIdentity = (
|
||||
providers: readonly AnalysisProvider[],
|
||||
/** Synthetic compatibility identity for a deterministic provider set. */
|
||||
export const compositeProviderIdentity = (
|
||||
identities: readonly ProviderIdentity[],
|
||||
): ProviderIdentity => ({
|
||||
id: `composite:${providers
|
||||
.map((provider) => provider.identity().id)
|
||||
id: `composite:${identities
|
||||
.map(({ id }) => id)
|
||||
.sort()
|
||||
.join("+")}`,
|
||||
name: "REA composite analysis provider",
|
||||
@@ -38,7 +40,11 @@ export class CompositeProvider implements AnalysisProvider {
|
||||
"CompositeProvider supports at most one target-bound analysis profile",
|
||||
);
|
||||
this.#profileProvider = profileProviders[0];
|
||||
this.#identity = Object.freeze(compositeIdentity(providers));
|
||||
this.#identity = Object.freeze(
|
||||
compositeProviderIdentity(
|
||||
providers.map((provider) => provider.identity()),
|
||||
),
|
||||
);
|
||||
const routes = new Map<string, AnalysisProvider>();
|
||||
const capabilities: CapabilityDescriptor[] = [];
|
||||
for (const provider of providers) {
|
||||
@@ -69,11 +75,14 @@ export class CompositeProvider implements AnalysisProvider {
|
||||
return this.#capabilities;
|
||||
}
|
||||
|
||||
resolveAnalysisProfile(target: BinaryTarget) {
|
||||
resolveAnalysisProfile(
|
||||
target: BinaryTarget,
|
||||
options?: AnalysisProfileResolutionOptions,
|
||||
) {
|
||||
const resolve = this.#profileProvider?.resolveAnalysisProfile;
|
||||
return resolve === undefined
|
||||
? Promise.resolve(ok({ profile: null, compatibility: {} }))
|
||||
: resolve.call(this.#profileProvider, target);
|
||||
: resolve.call(this.#profileProvider, target, options);
|
||||
}
|
||||
|
||||
createClient(
|
||||
@@ -106,7 +115,15 @@ export class CompositeProvider implements AnalysisProvider {
|
||||
);
|
||||
const provider = this.#providerByOperation.get(operation);
|
||||
return provider === undefined
|
||||
? Promise.resolve(err(new ProviderSelectionError(operation)))
|
||||
? Promise.resolve(
|
||||
err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
this.#identity.id,
|
||||
operation,
|
||||
"operation is not declared by this provider set",
|
||||
),
|
||||
),
|
||||
)
|
||||
: clientFor(provider).execute(operation, parameters, options);
|
||||
},
|
||||
close: async () => {
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
REA_WORKFLOW_PROVIDER,
|
||||
workflowAnalysisProfile,
|
||||
} from "./InvestigationProviders.js";
|
||||
import type { AnalysisProviderSelector } from "../contracts/providerSelection.js";
|
||||
|
||||
type DirectAnalysisTool =
|
||||
| "binary_overview"
|
||||
@@ -63,6 +64,7 @@ export const runDirectAnalysis = async (
|
||||
readonly snapshotPath?: string | undefined;
|
||||
readonly signal?: AbortSignal;
|
||||
readonly permissionAuthority?: PermissionAuthority;
|
||||
readonly providerId?: AnalysisProviderSelector;
|
||||
} = {},
|
||||
): Promise<JsonValue> =>
|
||||
withProcessCancellation(options.signal, (signal) =>
|
||||
@@ -70,6 +72,9 @@ export const runDirectAnalysis = async (
|
||||
logger: options.logger ?? silentLogger,
|
||||
snapshotPath: options.snapshotPath,
|
||||
signal,
|
||||
...(options.providerId === undefined
|
||||
? {}
|
||||
: { providerId: options.providerId }),
|
||||
...(options.permissionAuthority === undefined
|
||||
? {}
|
||||
: { permissionAuthority: options.permissionAuthority }),
|
||||
@@ -238,6 +243,7 @@ const runAnalysis = async (
|
||||
readonly snapshotPath: string | undefined;
|
||||
readonly signal: AbortSignal;
|
||||
readonly permissionAuthority?: PermissionAuthority;
|
||||
readonly providerId?: AnalysisProviderSelector;
|
||||
},
|
||||
): Promise<JsonValue> => {
|
||||
const { logger, signal, snapshotPath } = options;
|
||||
@@ -260,10 +266,13 @@ const runAnalysis = async (
|
||||
});
|
||||
if (!prepared.ok) return cliError(prepared.error);
|
||||
const { snapshot } = prepared.value;
|
||||
const opened = await session.open(
|
||||
path,
|
||||
snapshot === undefined ? { signal } : { signal, snapshot },
|
||||
);
|
||||
const opened = await session.open(path, {
|
||||
signal,
|
||||
...(snapshot === undefined ? {} : { snapshot }),
|
||||
...(options.providerId === undefined
|
||||
? {}
|
||||
: { providerId: options.providerId }),
|
||||
});
|
||||
if (!opened.ok) return cliError(opened.error);
|
||||
const evidenceProfile = analysisProfileForEvidence(session, tool);
|
||||
const bindingProfile = session.analysisProfile();
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
import type { AnalysisProviderSelector } from "../contracts/providerSelection.js";
|
||||
import {
|
||||
analysisProfileSchema,
|
||||
type AnalysisProfileCommitment,
|
||||
} from "../domain/analysisProfile.js";
|
||||
import type { BinaryTarget } from "../domain/binaryTarget.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisCapabilityUnavailableError,
|
||||
ProviderAdapterError,
|
||||
ProviderSelectionError,
|
||||
type AnalysisError,
|
||||
} from "../domain/errors.js";
|
||||
import { jsonObjectSchema, type JsonValue } from "../domain/jsonValue.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import type {
|
||||
AnalysisClient,
|
||||
AnalysisClientFactory,
|
||||
AnalysisProfileResolution,
|
||||
AnalysisProfileResolutionOptions,
|
||||
AnalysisProvider,
|
||||
CapabilityDescriptor,
|
||||
ProviderIdentity,
|
||||
} from "./AnalysisProvider.js";
|
||||
import {
|
||||
AnalysisProviderRegistry,
|
||||
type AnalysisProviderBinding,
|
||||
type AnalysisProviderCandidateStatus,
|
||||
type AnalysisProviderSelection,
|
||||
} from "./AnalysisProviderRegistry.js";
|
||||
import { ABORTED, waitForAbortable } from "./AbortablePromise.js";
|
||||
import {
|
||||
CompositeProvider,
|
||||
compositeProviderIdentity,
|
||||
} from "./CompositeProvider.js";
|
||||
|
||||
/** Immutable operation routes and binding metadata for one target transition. */
|
||||
export interface SessionProviderRoute {
|
||||
readonly identity: ProviderIdentity;
|
||||
readonly capabilities: ReadonlyMap<string, CapabilityDescriptor> | undefined;
|
||||
readonly profile: AnalysisProfileCommitment | null;
|
||||
readonly compatibility: Readonly<Record<string, JsonValue>>;
|
||||
readonly binding: AnalysisProviderBinding | null;
|
||||
readonly selection: AnalysisProviderSelection | undefined;
|
||||
createClient(target: BinaryTarget): AnalysisClient;
|
||||
}
|
||||
|
||||
interface LegacyProviderRuntime {
|
||||
readonly kind: "legacy";
|
||||
readonly provider: AnalysisProvider | AnalysisClientFactory;
|
||||
readonly identity: ProviderIdentity;
|
||||
readonly capabilities: ReadonlyMap<string, CapabilityDescriptor> | undefined;
|
||||
readonly resolveProfile: (
|
||||
target: BinaryTarget,
|
||||
options?: AnalysisProfileResolutionOptions,
|
||||
) => Promise<Result<AnalysisProfileResolution, AnalysisError>>;
|
||||
}
|
||||
|
||||
interface SelectableProviderRuntime {
|
||||
readonly kind: "selectable";
|
||||
readonly registry: AnalysisProviderRegistry;
|
||||
readonly auxiliaryProviders: readonly AnalysisProvider[];
|
||||
readonly identity: ProviderIdentity;
|
||||
readonly initialRoute: SessionProviderRoute;
|
||||
}
|
||||
|
||||
/** Resolve either the compatibility single-provider seam or a selectable set. */
|
||||
export class SessionProviderRouter {
|
||||
private constructor(
|
||||
private readonly runtime: LegacyProviderRuntime | SelectableProviderRuntime,
|
||||
) {}
|
||||
|
||||
/** Preserve the existing provider/factory seam used by focused embedders. */
|
||||
static legacy(
|
||||
provider: AnalysisProvider | AnalysisClientFactory,
|
||||
options: {
|
||||
readonly resolveAnalysisProfile?: (
|
||||
target: BinaryTarget,
|
||||
resolutionOptions?: AnalysisProfileResolutionOptions,
|
||||
) => Promise<Result<AnalysisProfileResolution, AnalysisError>>;
|
||||
} = {},
|
||||
): SessionProviderRouter {
|
||||
const identity =
|
||||
typeof provider === "function"
|
||||
? { id: "unidentified", name: "Unidentified provider", version: null }
|
||||
: provider.identity();
|
||||
const capabilities =
|
||||
typeof provider === "function"
|
||||
? undefined
|
||||
: capabilityMap(provider.capabilities());
|
||||
const resolveProfile =
|
||||
options.resolveAnalysisProfile ??
|
||||
(typeof provider === "function" ||
|
||||
provider.resolveAnalysisProfile === undefined
|
||||
? () => Promise.resolve(ok({ profile: null, compatibility: {} }))
|
||||
: (
|
||||
target: BinaryTarget,
|
||||
resolutionOptions?: AnalysisProfileResolutionOptions,
|
||||
) =>
|
||||
provider.resolveAnalysisProfile?.(target, resolutionOptions) ??
|
||||
Promise.resolve(ok({ profile: null, compatibility: {} })));
|
||||
return new SessionProviderRouter({
|
||||
kind: "legacy",
|
||||
provider,
|
||||
identity,
|
||||
capabilities,
|
||||
resolveProfile,
|
||||
});
|
||||
}
|
||||
|
||||
/** Create selection-aware routing while keeping auxiliary families disjoint. */
|
||||
static selectable(
|
||||
registry: AnalysisProviderRegistry,
|
||||
auxiliaryProviders: readonly AnalysisProvider[],
|
||||
): SessionProviderRouter {
|
||||
const identities = [
|
||||
...registry.identities(),
|
||||
...auxiliaryProviders.map((provider) => provider.identity()),
|
||||
];
|
||||
assertUniqueProviderIds(identities);
|
||||
const auxiliaryOperations = assertDisjoint(auxiliaryProviders);
|
||||
for (const operation of registry.declaredOperations())
|
||||
if (auxiliaryOperations.has(operation))
|
||||
throw new TypeError(
|
||||
`Deep and auxiliary providers both declare operation ${operation}`,
|
||||
);
|
||||
const initialRoute = selectableRoute(auxiliaryProviders, undefined);
|
||||
return new SessionProviderRouter({
|
||||
kind: "selectable",
|
||||
registry,
|
||||
auxiliaryProviders: [...auxiliaryProviders],
|
||||
identity:
|
||||
identities.length === 0
|
||||
? emptyProviderIdentity()
|
||||
: compositeProviderIdentity(identities),
|
||||
initialRoute,
|
||||
});
|
||||
}
|
||||
|
||||
/** Compatibility identity covering every configured provider. */
|
||||
configuredIdentity(): ProviderIdentity {
|
||||
return structuredClone(this.runtime.identity);
|
||||
}
|
||||
|
||||
/** Provider identities exposed through the flat 1.x compatibility field. */
|
||||
providerIdentities(route: SessionProviderRoute): readonly ProviderIdentity[] {
|
||||
if (this.runtime.kind === "legacy") {
|
||||
if (route.capabilities === undefined) return [route.identity];
|
||||
return uniqueProviderIdentities(
|
||||
[...route.capabilities.values()].map(({ provider }) => provider),
|
||||
);
|
||||
}
|
||||
const selected = route.binding?.identity;
|
||||
return uniqueProviderIdentities([
|
||||
...this.runtime.auxiliaryProviders.map((provider) => provider.identity()),
|
||||
...this.runtime.registry
|
||||
.identities()
|
||||
.map((identity) =>
|
||||
identity.id === selected?.id ? selected : identity,
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
/** Target-free routes and discovery metadata. */
|
||||
initialRoute(): SessionProviderRoute {
|
||||
if (this.runtime.kind === "selectable") return this.runtime.initialRoute;
|
||||
const runtime = this.runtime;
|
||||
return {
|
||||
identity: structuredClone(runtime.identity),
|
||||
capabilities: runtime.capabilities,
|
||||
profile: null,
|
||||
compatibility: {},
|
||||
binding: null,
|
||||
selection: undefined,
|
||||
createClient: (target) => createLegacyClient(runtime, target, undefined),
|
||||
};
|
||||
}
|
||||
|
||||
/** Resolve target routes before any provider client is created. */
|
||||
async resolve(
|
||||
target: BinaryTarget,
|
||||
providerId?: AnalysisProviderSelector,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<SessionProviderRoute, AnalysisError>> {
|
||||
const resolutionOptions = signal === undefined ? undefined : { signal };
|
||||
if (this.runtime.kind === "selectable") {
|
||||
const selected = await this.runtime.registry.select(
|
||||
target,
|
||||
providerId,
|
||||
resolutionOptions,
|
||||
);
|
||||
return selected.ok
|
||||
? ok(selectableRoute(this.runtime.auxiliaryProviders, selected.value))
|
||||
: selected;
|
||||
}
|
||||
const runtime = this.runtime;
|
||||
if (
|
||||
providerId !== undefined &&
|
||||
providerId !== "auto" &&
|
||||
providerId !== runtime.identity.id
|
||||
)
|
||||
return err(
|
||||
new ProviderSelectionError({
|
||||
reason: "unknown_provider",
|
||||
requestedProviderId: providerId,
|
||||
candidateIds: [runtime.identity.id],
|
||||
}),
|
||||
);
|
||||
const resolved = await waitForAbortable(
|
||||
runtime.resolveProfile(target, resolutionOptions),
|
||||
signal,
|
||||
);
|
||||
if (resolved === ABORTED)
|
||||
return err(new AnalysisCancelledError("open_binary"));
|
||||
if (!resolved.ok) return resolved;
|
||||
const normalized = normalizeProfileResolution(
|
||||
resolved.value,
|
||||
runtime.identity.id,
|
||||
);
|
||||
if (!normalized.ok) return normalized;
|
||||
return ok({
|
||||
identity: structuredClone(runtime.identity),
|
||||
capabilities: runtime.capabilities,
|
||||
profile: normalized.value.profile,
|
||||
compatibility: normalized.value.compatibility,
|
||||
binding: null,
|
||||
selection: undefined,
|
||||
createClient: (openedTarget) =>
|
||||
createLegacyClient(
|
||||
runtime,
|
||||
openedTarget,
|
||||
normalized.value.profile ?? undefined,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
/** Candidate status authoritative for the active or target-free route. */
|
||||
candidateStatuses(
|
||||
route: SessionProviderRoute,
|
||||
): readonly AnalysisProviderCandidateStatus[] {
|
||||
if (this.runtime.kind === "legacy") return [];
|
||||
return route.selection?.candidates ?? this.runtime.registry.candidates();
|
||||
}
|
||||
|
||||
/** Produce exact candidate rejections for an intentionally unbound route. */
|
||||
unboundOperationError(
|
||||
operation: string,
|
||||
route: SessionProviderRoute,
|
||||
): ProviderSelectionError | undefined {
|
||||
if (
|
||||
this.runtime.kind !== "selectable" ||
|
||||
route.binding !== null ||
|
||||
route.selection === undefined
|
||||
)
|
||||
return undefined;
|
||||
return this.runtime.registry.unboundOperationError(
|
||||
operation,
|
||||
route.selection,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const selectableRoute = (
|
||||
auxiliaryProviders: readonly AnalysisProvider[],
|
||||
selection: AnalysisProviderSelection | undefined,
|
||||
): SessionProviderRoute => {
|
||||
const binding = selection?.binding ?? null;
|
||||
const providers = [
|
||||
...auxiliaryProviders,
|
||||
...(binding === null ? [] : [binding.provider]),
|
||||
];
|
||||
const provider =
|
||||
providers.length === 0 ? undefined : new CompositeProvider(providers);
|
||||
const identity = provider?.identity() ?? emptyProviderIdentity();
|
||||
const capabilities = capabilityMap(provider?.capabilities() ?? []);
|
||||
return {
|
||||
identity,
|
||||
capabilities,
|
||||
profile: binding?.profile ?? null,
|
||||
compatibility: structuredClone(binding?.compatibility ?? {}),
|
||||
binding,
|
||||
selection,
|
||||
createClient: (target) =>
|
||||
provider?.createClient(target, binding?.profile) ?? emptyClient(identity),
|
||||
};
|
||||
};
|
||||
|
||||
const createLegacyClient = (
|
||||
runtime: LegacyProviderRuntime,
|
||||
target: BinaryTarget,
|
||||
profile: AnalysisProfileCommitment | undefined,
|
||||
): AnalysisClient =>
|
||||
typeof runtime.provider === "function"
|
||||
? runtime.provider(target, profile)
|
||||
: runtime.provider.createClient(target, profile);
|
||||
|
||||
const emptyClient = (identity: ProviderIdentity): AnalysisClient => ({
|
||||
execute: (operation) =>
|
||||
operation === "health"
|
||||
? Promise.resolve(
|
||||
ok({
|
||||
result: null,
|
||||
rawResult: null,
|
||||
provider: identity,
|
||||
limitations: [],
|
||||
locations: [],
|
||||
subject: null,
|
||||
}),
|
||||
)
|
||||
: Promise.resolve(
|
||||
err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
identity.id,
|
||||
operation,
|
||||
"operation is not declared by this provider set",
|
||||
),
|
||||
),
|
||||
),
|
||||
close: () => Promise.resolve(),
|
||||
});
|
||||
|
||||
const emptyProviderIdentity = (): ProviderIdentity => ({
|
||||
id: "composite:none",
|
||||
name: "REA composite analysis provider",
|
||||
version: null,
|
||||
});
|
||||
|
||||
const capabilityMap = (
|
||||
capabilities: readonly CapabilityDescriptor[],
|
||||
): ReadonlyMap<string, CapabilityDescriptor> =>
|
||||
new Map(capabilities.map((descriptor) => [descriptor.operation, descriptor]));
|
||||
|
||||
const uniqueProviderIdentities = (
|
||||
identities: readonly ProviderIdentity[],
|
||||
): readonly ProviderIdentity[] =>
|
||||
[...new Map(identities.map((identity) => [identity.id, identity])).values()]
|
||||
.sort((left, right) => left.id.localeCompare(right.id))
|
||||
.map((identity) => structuredClone(identity));
|
||||
|
||||
const assertDisjoint = (
|
||||
providers: readonly AnalysisProvider[],
|
||||
): ReadonlySet<string> => {
|
||||
const routes = new Set<string>();
|
||||
for (const provider of providers)
|
||||
for (const { operation } of provider.capabilities()) {
|
||||
if (routes.has(operation))
|
||||
throw new TypeError(
|
||||
`Multiple auxiliary providers declare operation ${operation}`,
|
||||
);
|
||||
routes.add(operation);
|
||||
}
|
||||
return routes;
|
||||
};
|
||||
|
||||
const assertUniqueProviderIds = (
|
||||
identities: readonly ProviderIdentity[],
|
||||
): void => {
|
||||
const ids = new Set<string>();
|
||||
for (const identity of identities) {
|
||||
if (ids.has(identity.id))
|
||||
throw new TypeError(`Duplicate configured provider ID: ${identity.id}`);
|
||||
ids.add(identity.id);
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeProfileResolution = (
|
||||
resolution: AnalysisProfileResolution,
|
||||
providerId: string,
|
||||
): Result<AnalysisProfileResolution, ProviderAdapterError> => {
|
||||
try {
|
||||
return ok({
|
||||
profile:
|
||||
resolution.profile === null
|
||||
? null
|
||||
: analysisProfileSchema.parse(resolution.profile),
|
||||
compatibility: jsonObjectSchema.parse(resolution.compatibility),
|
||||
});
|
||||
} catch (cause: unknown) {
|
||||
return err(
|
||||
new ProviderAdapterError(providerId, "resolve_analysis_profile", {
|
||||
cause,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -3,8 +3,9 @@ import { BinarySession } from "./BinarySession.js";
|
||||
import { HopperProvider } from "../hopper/HopperProvider.js";
|
||||
import { NativeMacOSProvider } from "../native/NativeMacOSProvider.js";
|
||||
import { ArtifactProvider } from "../artifacts/ArtifactProvider.js";
|
||||
import { CompositeProvider } from "./CompositeProvider.js";
|
||||
import { silentLogger, type Logger } from "../logger.js";
|
||||
import { AnalysisProviderRegistry } from "./AnalysisProviderRegistry.js";
|
||||
import { SessionProviderRouter } from "./SessionProviderRouter.js";
|
||||
|
||||
/**
|
||||
* Compose the target-switching runtime shared directly by CLI and MCP adapters.
|
||||
@@ -15,14 +16,17 @@ export const createBinarySession = (
|
||||
config: AppConfig,
|
||||
logger: Logger = silentLogger,
|
||||
): BinarySession => {
|
||||
const hopper = new HopperProvider(config, logger);
|
||||
return new BinarySession(
|
||||
new CompositeProvider([
|
||||
new ArtifactProvider(
|
||||
config.artifactNativeMountEnabled,
|
||||
config.artifactIntegrityContinueEnabled,
|
||||
),
|
||||
new NativeMacOSProvider(),
|
||||
new HopperProvider(config, logger),
|
||||
]),
|
||||
SessionProviderRouter.selectable(
|
||||
new AnalysisProviderRegistry([hopper], config.analysisProvider),
|
||||
[
|
||||
new ArtifactProvider(
|
||||
config.artifactNativeMountEnabled,
|
||||
config.artifactIntegrityContinueEnabled,
|
||||
),
|
||||
new NativeMacOSProvider(),
|
||||
],
|
||||
),
|
||||
);
|
||||
};
|
||||
+39
-9
@@ -31,6 +31,10 @@ import { registerBrowserCommands } from "./cliBrowserCommands.js";
|
||||
import { registerAdvancedBrowserCommands } from "./cliBrowserAdvancedCommands.js";
|
||||
import { registerElectronCommands } from "./cliElectronCommands.js";
|
||||
import { CLI_COMMANDS } from "./cliCommandNames.js";
|
||||
import {
|
||||
analysisProviderSelectorSchema,
|
||||
type AnalysisProviderSelector,
|
||||
} from "./contracts/providerSelection.js";
|
||||
|
||||
/**
|
||||
* Build the one-shot Incur CLI without starting Hopper at import time.
|
||||
@@ -95,6 +99,7 @@ const registerCoreCommands = (
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe("Load and update a local analysis snapshot"),
|
||||
provider: providerSelectionOption,
|
||||
});
|
||||
cli.command(CLI_COMMANDS.analyze, {
|
||||
description: "Get an overview of an app",
|
||||
@@ -108,7 +113,7 @@ const registerCoreCommands = (
|
||||
args.path,
|
||||
"binary_overview",
|
||||
{ detail: options.detail, limit: options.limit },
|
||||
{ logger, snapshotPath: options.snapshot },
|
||||
directAnalysisOptions(logger, options.snapshot, options.provider),
|
||||
),
|
||||
),
|
||||
});
|
||||
@@ -124,7 +129,7 @@ const registerCoreCommands = (
|
||||
args.path,
|
||||
"binary_overview",
|
||||
{ detail: options.detail, limit: options.limit },
|
||||
{ logger, snapshotPath: options.snapshot },
|
||||
directAnalysisOptions(logger, options.snapshot, options.provider),
|
||||
),
|
||||
),
|
||||
});
|
||||
@@ -134,14 +139,17 @@ const registerCoreCommands = (
|
||||
path: z.string().describe("App or program path"),
|
||||
address: z.string().describe("Procedure address"),
|
||||
}),
|
||||
options: z.object({ snapshot: z.string().min(1).optional() }),
|
||||
options: z.object({
|
||||
snapshot: z.string().min(1).optional(),
|
||||
provider: providerSelectionOption,
|
||||
}),
|
||||
run: ({ args, options }) =>
|
||||
logCliCommand(logger, "decompile", () =>
|
||||
runDirectAnalysis(
|
||||
args.path,
|
||||
"procedure_pseudo_code",
|
||||
{ procedure: args.address },
|
||||
{ logger, snapshotPath: options.snapshot },
|
||||
directAnalysisOptions(logger, options.snapshot, options.provider),
|
||||
),
|
||||
),
|
||||
});
|
||||
@@ -244,14 +252,17 @@ const registerXrefsCommand = (
|
||||
path: z.string().describe("App or program path"),
|
||||
address: z.string().describe("Hexadecimal address"),
|
||||
}),
|
||||
options: z.object({ snapshot: z.string().min(1).optional() }),
|
||||
options: z.object({
|
||||
snapshot: z.string().min(1).optional(),
|
||||
provider: providerSelectionOption,
|
||||
}),
|
||||
run: ({ args, options }) =>
|
||||
logCliCommand(logger, "xrefs", () =>
|
||||
runDirectAnalysis(
|
||||
args.path,
|
||||
"xrefs",
|
||||
{ address: args.address },
|
||||
{ logger, snapshotPath: options.snapshot },
|
||||
directAnalysisOptions(logger, options.snapshot, options.provider),
|
||||
),
|
||||
),
|
||||
});
|
||||
@@ -272,6 +283,7 @@ const registerTraceCommand = (
|
||||
limit: z.number().int().min(1).max(100).default(20),
|
||||
maxOperations: z.number().int().min(1).max(100).default(20),
|
||||
snapshot: z.string().min(1).optional(),
|
||||
provider: providerSelectionOption,
|
||||
}),
|
||||
alias: {
|
||||
caseSensitive: "case-sensitive",
|
||||
@@ -288,7 +300,7 @@ const registerTraceCommand = (
|
||||
limit: options.limit,
|
||||
max_operations: options.maxOperations,
|
||||
},
|
||||
{ logger, snapshotPath: options.snapshot },
|
||||
directAnalysisOptions(logger, options.snapshot, options.provider),
|
||||
),
|
||||
),
|
||||
});
|
||||
@@ -328,6 +340,7 @@ const registerFunctionCommand = (
|
||||
maxPseudocodeChars: z.number().int().min(1).max(100_000).default(20_000),
|
||||
maxInstructions: z.number().int().min(1).max(5_000).default(500),
|
||||
snapshot: z.string().min(1).optional(),
|
||||
provider: providerSelectionOption,
|
||||
}),
|
||||
alias: {
|
||||
includeAssembly: "include-assembly",
|
||||
@@ -346,7 +359,7 @@ const registerFunctionCommand = (
|
||||
max_pseudocode_chars: options.maxPseudocodeChars,
|
||||
max_instructions: options.maxInstructions,
|
||||
},
|
||||
{ logger, snapshotPath: options.snapshot },
|
||||
directAnalysisOptions(logger, options.snapshot, options.provider),
|
||||
),
|
||||
),
|
||||
});
|
||||
@@ -369,6 +382,7 @@ const registerSearchCommand = (
|
||||
offset: z.number().int().min(0).default(0),
|
||||
limit: z.number().int().min(1).max(100).default(100),
|
||||
snapshot: z.string().min(1).optional(),
|
||||
provider: providerSelectionOption,
|
||||
}),
|
||||
alias: { caseSensitive: "case-sensitive" },
|
||||
run: ({ args, options }) =>
|
||||
@@ -383,7 +397,7 @@ const registerSearchCommand = (
|
||||
offset: options.offset,
|
||||
limit: options.limit,
|
||||
},
|
||||
{ logger, snapshotPath: options.snapshot },
|
||||
directAnalysisOptions(logger, options.snapshot, options.provider),
|
||||
),
|
||||
),
|
||||
});
|
||||
@@ -578,3 +592,19 @@ const registerNativeCommands = (
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
const providerSelectionOption = analysisProviderSelectorSchema
|
||||
.optional()
|
||||
.describe(
|
||||
"Bind deep analysis to a provider ID or use deterministic auto selection",
|
||||
);
|
||||
|
||||
const directAnalysisOptions = (
|
||||
logger: Logger,
|
||||
snapshotPath: string | undefined,
|
||||
providerId: AnalysisProviderSelector | undefined,
|
||||
) => ({
|
||||
logger,
|
||||
snapshotPath,
|
||||
...(providerId === undefined ? {} : { providerId }),
|
||||
});
|
||||
+8
-1
@@ -16,6 +16,10 @@ import {
|
||||
isLiteralLoopbackHostname,
|
||||
} from "./domain/browserObservation.js";
|
||||
import { electronFileRootsSchema } from "./domain/electronObservation.js";
|
||||
import {
|
||||
analysisProviderSelectorSchema,
|
||||
type AnalysisProviderSelector,
|
||||
} from "./contracts/providerSelection.js";
|
||||
|
||||
const DEFAULT_HOPPER_LAUNCHER_PATH =
|
||||
"/Applications/Hopper Disassembler.app/Contents/MacOS/hopper";
|
||||
@@ -25,6 +29,7 @@ const defaultHopperLauncherPath = (): string =>
|
||||
: DEFAULT_HOPPER_LAUNCHER_PATH;
|
||||
|
||||
export interface AppConfig {
|
||||
readonly analysisProvider: AnalysisProviderSelector;
|
||||
readonly hopperLauncherPath: string;
|
||||
readonly hopperTargetPath: string | undefined;
|
||||
readonly hopperTargetKind: "executable" | "database";
|
||||
@@ -51,6 +56,7 @@ export interface AppConfig {
|
||||
|
||||
const environmentSchema = z
|
||||
.object({
|
||||
REA_ANALYSIS_PROVIDER: analysisProviderSelectorSchema.default("auto"),
|
||||
HOPPER_LAUNCHER_PATH: z.string().min(1).optional(),
|
||||
HOPPER_TARGET_PATH: z.string().min(1).optional(),
|
||||
HOPPER_TARGET_KIND: z
|
||||
@@ -239,7 +245,7 @@ export const parseConfig = (
|
||||
const parsedEnvironment = environmentSchema.safeParse(environment);
|
||||
if (!parsedEnvironment.success) {
|
||||
return err(
|
||||
new ConfigurationError("Invalid Hopper environment configuration", {
|
||||
new ConfigurationError("Invalid REA environment configuration", {
|
||||
cause: parsedEnvironment.error,
|
||||
}),
|
||||
);
|
||||
@@ -357,6 +363,7 @@ export const parseConfig = (
|
||||
: []),
|
||||
] satisfies readonly PermissionCeiling[];
|
||||
return ok({
|
||||
analysisProvider: parsedEnvironment.data.REA_ANALYSIS_PROVIDER,
|
||||
hopperLauncherPath:
|
||||
parsedEnvironment.data.HOPPER_LAUNCHER_PATH ??
|
||||
defaultHopperLauncherPath(),
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/** Stable provider identifier accepted at CLI, MCP, and environment boundaries. */
|
||||
export const analysisProviderIdSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(64)
|
||||
.regex(/^[a-z][a-z0-9-]*$/u)
|
||||
.refine((value) => value !== "auto", {
|
||||
message: "auto is reserved for provider selection",
|
||||
});
|
||||
|
||||
/** Explicit provider ID or the deterministic automatic-selection policy. */
|
||||
export const analysisProviderSelectorSchema = z.union([
|
||||
z.literal("auto"),
|
||||
analysisProviderIdSchema,
|
||||
]);
|
||||
|
||||
/** Stable reasons a deep provider cannot participate in target selection. */
|
||||
export const PROVIDER_REJECTION_CODES = [
|
||||
"not_configured",
|
||||
"executable_missing",
|
||||
"runtime_missing",
|
||||
"unsupported_host",
|
||||
"unsupported_version",
|
||||
"target_kind_unsupported",
|
||||
"target_format_unsupported",
|
||||
"architecture_unsupported",
|
||||
"open_options_invalid",
|
||||
"version_unresolved",
|
||||
] as const;
|
||||
|
||||
export type AnalysisProviderSelector = z.infer<
|
||||
typeof analysisProviderSelectorSchema
|
||||
>;
|
||||
|
||||
/** Stable reason a deep provider cannot participate in target selection. */
|
||||
export type ProviderRejectionCode = (typeof PROVIDER_REJECTION_CODES)[number];
|
||||
@@ -1,8 +1,10 @@
|
||||
import { z } from "zod";
|
||||
import { analysisProviderSelectorSchema } from "./providerSelection.js";
|
||||
|
||||
/** Input contract for opening a target with an optional staged snapshot. */
|
||||
export const openBinaryInputSchema = z.object({
|
||||
path: z.string().min(1),
|
||||
provider_id: analysisProviderSelectorSchema.optional(),
|
||||
snapshot_path: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
|
||||
@@ -405,7 +405,7 @@ export const binarySessionInputSchema = z.object({
|
||||
export const SESSION_TOOL_CONTRACTS = [
|
||||
session(
|
||||
"open_binary",
|
||||
"Open a local executable, application bundle, archive, JavaScript, source map, plist, or analysis database after validation. An optional snapshot v2 is imported atomically and must match the binary identity, concrete provider, and canonical analysis profile exactly; provider resources may still start before an MCP cache replay.",
|
||||
"Open a local executable, application bundle, archive, JavaScript, source map, plist, or analysis database after validation. provider_id selects one deep provider or deterministic auto selection; the binding remains stable until close or an explicit switch, with no failure fallback. An optional snapshot v2 is imported atomically and must match the binary identity, concrete provider, and canonical analysis profile exactly.",
|
||||
openBinaryInputSchema,
|
||||
),
|
||||
session(
|
||||
@@ -415,7 +415,7 @@ export const SESSION_TOOL_CONTRACTS = [
|
||||
),
|
||||
session(
|
||||
"binary_session",
|
||||
"Report provider identity, deterministic capability descriptors, and whether a target is open; open targets include canonical path, format, and kind. Use availability, effects, limits, and limitations before selecting analysis operations.",
|
||||
"Report deterministic deep-provider candidates, host availability, target support, the immutable active binding/profile, capability descriptors, and whether a target is open. Candidate discovery never starts an analysis process; use rejection codes and local diagnostics before choosing provider_id.",
|
||||
binarySessionInputSchema,
|
||||
),
|
||||
session(
|
||||
|
||||
@@ -26,6 +26,11 @@ import { callPathResultSchema } from "../domain/callPath.js";
|
||||
import { staticRuntimeCorrelationResultSchema } from "../domain/staticRuntimeCorrelation.js";
|
||||
import { reconstructionVerificationResultSchema } from "../domain/reconstructionVerification.js";
|
||||
import { analysisErrorProjectionSchema } from "./errorSchemas.js";
|
||||
import { analysisProfileSchema } from "../domain/analysisProfile.js";
|
||||
import {
|
||||
PROVIDER_REJECTION_CODES,
|
||||
type ProviderRejectionCode,
|
||||
} from "./providerSelection.js";
|
||||
|
||||
const resultOf = (schema: z.ZodType) =>
|
||||
evidenceEnvelopeSchema
|
||||
@@ -94,10 +99,72 @@ const providerIdentity = z.object({
|
||||
name: z.string(),
|
||||
version: z.string().nullable(),
|
||||
});
|
||||
const providerRejectionCode: z.ZodType<ProviderRejectionCode> = z.enum(
|
||||
PROVIDER_REJECTION_CODES,
|
||||
);
|
||||
const providerDiagnostics = z.record(z.string(), z.json());
|
||||
const providerAvailability = z.discriminatedUnion("status", [
|
||||
z.object({
|
||||
status: z.literal("available"),
|
||||
code: z.null(),
|
||||
reason: z.null(),
|
||||
diagnostics: providerDiagnostics,
|
||||
}),
|
||||
z.object({
|
||||
status: z.literal("unavailable"),
|
||||
code: providerRejectionCode,
|
||||
reason: z.string().min(1),
|
||||
diagnostics: providerDiagnostics,
|
||||
}),
|
||||
]);
|
||||
const providerTargetSupport = z.discriminatedUnion("status", [
|
||||
z.object({
|
||||
status: z.literal("unknown"),
|
||||
code: z.null(),
|
||||
reason: z.string().min(1),
|
||||
diagnostics: providerDiagnostics,
|
||||
}),
|
||||
z.object({
|
||||
status: z.literal("supported"),
|
||||
code: z.null(),
|
||||
reason: z.null(),
|
||||
diagnostics: providerDiagnostics,
|
||||
}),
|
||||
z.object({
|
||||
status: z.literal("unsupported"),
|
||||
code: z.enum([
|
||||
"target_kind_unsupported",
|
||||
"target_format_unsupported",
|
||||
"architecture_unsupported",
|
||||
]),
|
||||
reason: z.string().min(1),
|
||||
diagnostics: providerDiagnostics,
|
||||
}),
|
||||
]);
|
||||
const sessionProvider = z.object({
|
||||
provider: providerIdentity,
|
||||
providers: z.array(providerIdentity),
|
||||
capabilities: z.array(providerCapability),
|
||||
analysis_provider_binding: z
|
||||
.object({
|
||||
provider: providerIdentity,
|
||||
selection_source: z.enum([
|
||||
"request",
|
||||
"environment",
|
||||
"auto-single-candidate",
|
||||
]),
|
||||
analysis_profile: analysisProfileSchema,
|
||||
})
|
||||
.nullable(),
|
||||
analysis_provider_candidates: z.array(
|
||||
z.object({
|
||||
provider: providerIdentity,
|
||||
availability: providerAvailability,
|
||||
target_support: providerTargetSupport,
|
||||
selected: z.boolean(),
|
||||
capabilities: z.array(providerCapability),
|
||||
}),
|
||||
),
|
||||
tool_availability: z.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
|
||||
+107
-3
@@ -122,15 +122,96 @@ export class AnalysisTimeoutError extends AnalysisError {
|
||||
}
|
||||
}
|
||||
|
||||
/** No configured provider can satisfy a requested operation. */
|
||||
/** Stable failure classes for target-bound deep-provider selection. */
|
||||
export type ProviderSelectionFailureReason =
|
||||
| "unknown_provider"
|
||||
| "provider_unavailable"
|
||||
| "target_unsupported"
|
||||
| "ambiguous"
|
||||
| "invalid_options";
|
||||
|
||||
/** One actionable candidate rejection retained in caller diagnostics. */
|
||||
export interface ProviderSelectionRejection {
|
||||
readonly providerId: string;
|
||||
readonly code: string;
|
||||
readonly reason: string;
|
||||
readonly diagnostics: Readonly<Record<string, JsonValue>>;
|
||||
}
|
||||
|
||||
/** Complete typed context for one failed deep-provider selection. */
|
||||
export interface ProviderSelectionErrorOptions {
|
||||
readonly operation?: string;
|
||||
readonly reason: ProviderSelectionFailureReason;
|
||||
readonly requestedProviderId: string;
|
||||
readonly candidateIds: readonly string[];
|
||||
readonly rejections?: readonly ProviderSelectionRejection[];
|
||||
}
|
||||
|
||||
/** No configured provider can satisfy a requested operation or target binding. */
|
||||
export class ProviderSelectionError extends AnalysisError {
|
||||
readonly _tag = "ProviderSelectionError";
|
||||
readonly operation: string;
|
||||
readonly reason: ProviderSelectionFailureReason;
|
||||
readonly requestedProviderId: string;
|
||||
readonly candidateIds: readonly string[];
|
||||
readonly rejections: readonly ProviderSelectionRejection[];
|
||||
override readonly userMessage: string;
|
||||
|
||||
constructor(readonly operation: string) {
|
||||
super(`No configured provider can execute ${operation}`);
|
||||
constructor(input: string | ProviderSelectionErrorOptions) {
|
||||
const legacy = typeof input === "string";
|
||||
const options: ProviderSelectionErrorOptions = legacy
|
||||
? {
|
||||
operation: input,
|
||||
reason: "provider_unavailable",
|
||||
requestedProviderId: "auto",
|
||||
candidateIds: [],
|
||||
}
|
||||
: input;
|
||||
const operation = options.operation ?? "open_binary";
|
||||
super(
|
||||
legacy
|
||||
? `No configured provider can execute ${operation}`
|
||||
: providerSelectionDiagnostic(options),
|
||||
);
|
||||
this.operation = operation;
|
||||
this.reason = options.reason;
|
||||
this.requestedProviderId = options.requestedProviderId;
|
||||
this.candidateIds = [...options.candidateIds];
|
||||
this.rejections = [...(options.rejections ?? [])].map((rejection) => ({
|
||||
...rejection,
|
||||
diagnostics: structuredClone(rejection.diagnostics),
|
||||
}));
|
||||
this.userMessage = providerSelectionUserMessage(options);
|
||||
}
|
||||
}
|
||||
|
||||
const providerSelectionDiagnostic = (
|
||||
options: ProviderSelectionErrorOptions,
|
||||
): string => {
|
||||
const candidates =
|
||||
options.candidateIds.length === 0
|
||||
? "none"
|
||||
: options.candidateIds.join(", ");
|
||||
return `Analysis provider selection failed (${options.reason}) for ${options.requestedProviderId}; candidates: ${candidates}`;
|
||||
};
|
||||
|
||||
const providerSelectionUserMessage = (
|
||||
options: ProviderSelectionErrorOptions,
|
||||
): string => {
|
||||
switch (options.reason) {
|
||||
case "ambiguous":
|
||||
return "Multiple analysis providers support this target. Choose one with provider_id, --provider, or REA_ANALYSIS_PROVIDER.";
|
||||
case "unknown_provider":
|
||||
return `The requested analysis provider is unknown. Choose one of: ${options.candidateIds.join(", ") || "no configured providers"}.`;
|
||||
case "provider_unavailable":
|
||||
return "The selected analysis provider is unavailable. Review the reported local diagnostics or run `rea doctor`, then retry.";
|
||||
case "target_unsupported":
|
||||
return "The selected analysis provider does not support this target. Choose another provider or open a supported extracted binary.";
|
||||
case "invalid_options":
|
||||
return "The analysis provider selection or open options are invalid. Correct them and try again.";
|
||||
}
|
||||
};
|
||||
|
||||
/** A provider adapter failed outside its more precise typed variants. */
|
||||
export class ProviderAdapterError extends AnalysisError {
|
||||
readonly _tag = "ProviderAdapterError";
|
||||
@@ -459,6 +540,11 @@ export const projectAnalysisError = (
|
||||
|
||||
const errorCode = (error: AnalysisError): AnalysisErrorProjection["code"] => {
|
||||
if (error instanceof PermissionRequiredError) return "permission_required";
|
||||
if (
|
||||
error instanceof ProviderSelectionError &&
|
||||
error.reason === "provider_unavailable"
|
||||
)
|
||||
return "provider_unavailable";
|
||||
if (error instanceof BrowserObservationError) {
|
||||
if (error.reason === "payload_limit") return "truncated";
|
||||
if (
|
||||
@@ -602,6 +688,19 @@ const errorDetails = (
|
||||
return { operation: error.operation, reason: error.reason };
|
||||
if (error instanceof AnalysisCapabilityUnavailableError)
|
||||
return { provider_id: error.providerId, operation: error.operation };
|
||||
if (error instanceof ProviderSelectionError)
|
||||
return {
|
||||
operation: error.operation,
|
||||
selection_reason: error.reason,
|
||||
requested_provider_id: error.requestedProviderId,
|
||||
candidate_ids: [...error.candidateIds],
|
||||
rejections: error.rejections.map((rejection) => ({
|
||||
provider_id: rejection.providerId,
|
||||
code: rejection.code,
|
||||
reason: rejection.reason,
|
||||
diagnostics: rejection.diagnostics,
|
||||
})),
|
||||
};
|
||||
if (error instanceof ProviderAdapterError)
|
||||
return { provider_id: error.providerId, operation: error.operation };
|
||||
if (error instanceof BrowserObservationError)
|
||||
@@ -670,6 +769,11 @@ const errorCategory = (
|
||||
error: AnalysisError,
|
||||
): AnalysisErrorProjection["category"] => {
|
||||
if (error instanceof PermissionRequiredError) return "permission_required";
|
||||
if (
|
||||
error instanceof ProviderSelectionError &&
|
||||
error.reason === "provider_unavailable"
|
||||
)
|
||||
return "unavailable";
|
||||
if (error._tag === "ProcessCaptureError")
|
||||
return error.userCategory ?? "execution_failure";
|
||||
if (error instanceof BrowserObservationError) {
|
||||
|
||||
@@ -10,13 +10,18 @@ import type {
|
||||
BinaryArchitecture,
|
||||
BinaryTarget,
|
||||
} from "../domain/binaryTarget.js";
|
||||
import { ProviderAdapterError, type AnalysisError } from "../domain/errors.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
ProviderAdapterError,
|
||||
type AnalysisError,
|
||||
} from "../domain/errors.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
|
||||
interface HopperProfileOptions {
|
||||
readonly launcherPath: string;
|
||||
readonly loaderArgsOverride: readonly string[];
|
||||
readonly provider: ProviderIdentity;
|
||||
readonly signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/** Resolve Hopper open semantics without placing its CLI flags in BinaryTarget. */
|
||||
@@ -33,12 +38,14 @@ export const resolveHopperAnalysisProfile = async (
|
||||
? derived.value
|
||||
: [...options.loaderArgsOverride];
|
||||
const compatibility = { loaderArgs: [...loaderArgs] };
|
||||
const launcherDigest = await sha256File(options.launcherPath);
|
||||
if (launcherDigest === undefined) return ok({ profile: null, compatibility });
|
||||
const launcherDigest = await sha256File(options.launcherPath, options.signal);
|
||||
if (!launcherDigest.ok) return launcherDigest;
|
||||
if (launcherDigest.value === undefined)
|
||||
return ok({ profile: null, compatibility });
|
||||
const provider = {
|
||||
id: options.provider.id,
|
||||
name: options.provider.name,
|
||||
version: `launcher-sha256:${launcherDigest}`,
|
||||
version: `launcher-sha256:${launcherDigest.value}`,
|
||||
};
|
||||
return ok({
|
||||
profile: createAnalysisProfile(provider, 1, {
|
||||
@@ -103,12 +110,31 @@ const hopperArchitectureFlag = (architecture: BinaryArchitecture): string => {
|
||||
}
|
||||
};
|
||||
|
||||
const sha256File = async (path: string): Promise<string | undefined> => {
|
||||
const sha256File = async (
|
||||
path: string,
|
||||
signal: AbortSignal | undefined,
|
||||
): Promise<Result<string | undefined, AnalysisCancelledError>> => {
|
||||
if (signalIsAborted(signal))
|
||||
return err(new AnalysisCancelledError("open_binary"));
|
||||
const stream = createReadStream(path);
|
||||
const onAbort = (): void => {
|
||||
stream.destroy();
|
||||
};
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
try {
|
||||
const hash = createHash("sha256");
|
||||
for await (const chunk of createReadStream(path)) hash.update(chunk);
|
||||
return hash.digest("hex");
|
||||
for await (const chunk of stream) hash.update(chunk);
|
||||
return signalIsAborted(signal)
|
||||
? err(new AnalysisCancelledError("open_binary"))
|
||||
: ok(hash.digest("hex"));
|
||||
} catch {
|
||||
return undefined;
|
||||
return signalIsAborted(signal)
|
||||
? err(new AnalysisCancelledError("open_binary"))
|
||||
: ok(undefined);
|
||||
} finally {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
}
|
||||
};
|
||||
|
||||
const signalIsAborted = (signal: AbortSignal | undefined): boolean =>
|
||||
signal?.aborted === true;
|
||||
@@ -1,11 +1,15 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { accessSync, constants } from "node:fs";
|
||||
|
||||
import { createAnalysisExecution } from "../application/AnalysisProvider.js";
|
||||
import type {
|
||||
AnalysisClient,
|
||||
AnalysisProvider,
|
||||
AnalysisProfileResolutionOptions,
|
||||
AnalysisProviderCandidate,
|
||||
CapabilityDescriptor,
|
||||
ProviderAvailability,
|
||||
ProviderIdentity,
|
||||
ProviderTargetSupport,
|
||||
} from "../application/AnalysisProvider.js";
|
||||
import type { AnalysisProfileCommitment } from "../domain/analysisProfile.js";
|
||||
import type { AppConfig } from "../config.js";
|
||||
@@ -81,7 +85,7 @@ const CAPABILITIES: readonly CapabilityDescriptor[] = Object.freeze(
|
||||
);
|
||||
|
||||
/** Concrete analysis provider backed by REA's private Hopper bridge. */
|
||||
export class HopperProvider implements AnalysisProvider {
|
||||
export class HopperProvider implements AnalysisProviderCandidate {
|
||||
constructor(
|
||||
private readonly config: AppConfig,
|
||||
private readonly logger: Logger,
|
||||
@@ -95,11 +99,87 @@ export class HopperProvider implements AnalysisProvider {
|
||||
return CAPABILITIES;
|
||||
}
|
||||
|
||||
resolveAnalysisProfile(target: BinaryTarget) {
|
||||
inspectAvailability(): ProviderAvailability {
|
||||
const diagnostics = {
|
||||
launcher_path: this.config.hopperLauncherPath,
|
||||
platform: process.platform,
|
||||
};
|
||||
if (process.platform !== "darwin" && process.platform !== "linux")
|
||||
return {
|
||||
status: "unavailable",
|
||||
code: "unsupported_host",
|
||||
reason: `Hopper integration is not supported on ${process.platform}.`,
|
||||
diagnostics,
|
||||
};
|
||||
try {
|
||||
accessSync(this.config.hopperLauncherPath, constants.X_OK);
|
||||
return {
|
||||
status: "available",
|
||||
code: null,
|
||||
reason: null,
|
||||
diagnostics,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
status: "unavailable",
|
||||
code: "executable_missing",
|
||||
reason: `Hopper launcher is missing or not executable: ${this.config.hopperLauncherPath}`,
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
inspectTargetSupport(target: BinaryTarget): ProviderTargetSupport {
|
||||
const diagnostics = {
|
||||
target_kind: target.kind,
|
||||
target_format: target.format,
|
||||
architecture: target.architecture ?? null,
|
||||
};
|
||||
if (target.kind !== "executable" && target.kind !== "database")
|
||||
return {
|
||||
status: "unsupported",
|
||||
code: "target_kind_unsupported",
|
||||
reason: `Hopper cannot directly analyze ${target.kind} targets.`,
|
||||
diagnostics,
|
||||
};
|
||||
if (target.kind === "database")
|
||||
return {
|
||||
status: "supported",
|
||||
code: null,
|
||||
reason: null,
|
||||
diagnostics,
|
||||
};
|
||||
if (!["mach-o", "elf", "pe"].includes(target.format))
|
||||
return {
|
||||
status: "unsupported",
|
||||
code: "target_format_unsupported",
|
||||
reason: `Hopper cannot open ${target.format} through this adapter.`,
|
||||
diagnostics,
|
||||
};
|
||||
if (target.architecture === undefined)
|
||||
return {
|
||||
status: "unsupported",
|
||||
code: "architecture_unsupported",
|
||||
reason: "Hopper requires a supported concrete target architecture.",
|
||||
diagnostics,
|
||||
};
|
||||
return {
|
||||
status: "supported",
|
||||
code: null,
|
||||
reason: null,
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
resolveAnalysisProfile(
|
||||
target: BinaryTarget,
|
||||
options?: AnalysisProfileResolutionOptions,
|
||||
) {
|
||||
return resolveHopperAnalysisProfile(target, {
|
||||
launcherPath: this.config.hopperLauncherPath,
|
||||
loaderArgsOverride: this.config.hopperLoaderArgs,
|
||||
provider: IDENTITY,
|
||||
...(options?.signal === undefined ? {} : { signal: options.signal }),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,9 @@ const MAX_COMPLETION_VALUE_LENGTH = 4_096;
|
||||
const documentListSchema = z.array(z.string().min(1));
|
||||
const providerStatusSchema = z.object({
|
||||
providers: z.array(z.object({ id: z.string().min(1) })),
|
||||
analysis_provider_candidates: z
|
||||
.array(z.object({ provider: z.object({ id: z.string().min(1) }) }))
|
||||
.optional(),
|
||||
});
|
||||
|
||||
interface CompletionContext {
|
||||
@@ -133,9 +136,14 @@ const providerCandidates = (
|
||||
): readonly string[] => {
|
||||
if (session === undefined) return [];
|
||||
const parsed = providerStatusSchema.safeParse(session.status());
|
||||
return parsed.success
|
||||
? parsed.data.providers.map(({ id }) => id).slice(0, COMPLETION_SCAN_LIMIT)
|
||||
: [];
|
||||
if (!parsed.success) return [];
|
||||
const candidates =
|
||||
(parsed.data.analysis_provider_candidates?.length ?? 0) > 0
|
||||
? (parsed.data.analysis_provider_candidates?.map(
|
||||
({ provider }) => provider.id,
|
||||
) ?? [])
|
||||
: parsed.data.providers.map(({ id }) => id);
|
||||
return ["auto", ...candidates].slice(0, COMPLETION_SCAN_LIMIT);
|
||||
};
|
||||
|
||||
const evidenceCandidates = (
|
||||
|
||||
@@ -495,6 +495,9 @@ const registerLifecycleTools = ({
|
||||
const opened = await logToolExecution(logger, openContract.name, () =>
|
||||
session.open(parsed.path, {
|
||||
signal: context.mcpReq.signal,
|
||||
...(parsed.provider_id === undefined
|
||||
? {}
|
||||
: { providerId: parsed.provider_id }),
|
||||
...(snapshot === undefined ? {} : { snapshot }),
|
||||
}),
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user