mirror of
https://github.com/morluto/rea.git
synced 2026-11-07 07:16:13 +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(),
|
||||
],
|
||||
),
|
||||
);
|
||||
};
|
||||
Reference in new issue
Block a user