mirror of
https://github.com/morluto/rea.git
synced 2026-11-07 05:56:19 -10:00
Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9c4b0d029 | ||
|
|
c15668ce8b | ||
|
|
967c695e95 | ||
|
|
4c778bf24e | ||
|
|
0b88be0a2f | ||
|
|
65a2d037a9 | ||
|
|
5c19c54d7e | ||
|
|
64ddb9c379 | ||
|
|
1dbaa35936 | ||
|
|
c99ab7ee21 | ||
|
|
6fd318a99a | ||
|
|
675d3367d1 | ||
|
|
cc2bd100a5 | ||
|
|
e215b08906 | ||
|
|
011877468d | ||
|
|
a8f5811b6d | ||
|
|
bc962f5c19 | ||
|
|
588a4c81a0 | ||
|
|
68b1d91310 | ||
|
|
4295ae38df | ||
|
|
92adb42521 | ||
|
|
f7c1679374 | ||
|
|
1de53a34c8 | ||
|
|
3d371b9b26 | ||
|
|
ae0ff117b6 | ||
|
|
fc35f83b33 | ||
|
|
453dacf6a3 | ||
|
|
5cf06c4517 | ||
|
|
2f59bc0ff6 | ||
|
|
cb0cb206fb | ||
|
|
dbb99b15a0 | ||
|
|
47d514f1c0 | ||
|
|
40e8713e59 |
No files matched your search
+37
-3
@@ -1280,6 +1280,15 @@ Native call tracing and process capture retain available observations in
|
||||
`details.partial_observation` on failure, including when cleanup succeeds.
|
||||
The observation reports its partial coverage; cleanup details describe host
|
||||
state separately from the execution failure.
|
||||
When a completed observation cannot be published because recording a source
|
||||
failed, the original source failure remains the error. `partial_observation`
|
||||
can carry the failed source and `completed_observation` carries the completed
|
||||
result, with `source_closure: "not-established"`. A retained reference is
|
||||
reported only when its owning ledger confirms it; an inline result with
|
||||
`retention: "unknown"` makes no claim about ledger membership. If an
|
||||
observation cannot fit the MCP response, its corresponding `_omitted` detail
|
||||
reports the delivery limit. Cleanup failure and other completed source failures
|
||||
remain visible separately.
|
||||
For these tools, cancellation observed during result finalization or owned
|
||||
cleanup also returns typed cancellation with the available observations.
|
||||
A normal target exit does not establish request completion. Incomplete cleanup
|
||||
@@ -1296,6 +1305,22 @@ Those raw observations are not recorded in the Evidence bundle. If the SDK
|
||||
suppresses the error reply after client cancellation, Evidence tools cannot
|
||||
recover them. An empty bundle does not prove that no partial work or side
|
||||
effect occurred; output receipts and cleanup observations remain distinct.
|
||||
If an authenticated Evidence record exceeds the investigation's retention
|
||||
allowance, the refusal carries it inline as
|
||||
`details.partial_observation: {kind: "unretained-evidence", evidence: ...}`
|
||||
when it fits the response budget. Its ID identifies the supplied content; it
|
||||
is not a retained-session reference. If authentication itself failed or lacked
|
||||
headroom, no authenticated Evidence is claimed. When the completed record does
|
||||
not fit the MCP response, the original retention error carries
|
||||
`details.partial_observation_omitted` with the response constraint if the
|
||||
remaining error fits; the observation remains unretained. Otherwise the usual
|
||||
oversized-error recovery applies.
|
||||
When an atomic derived-Evidence and Unknown write exceeds the allowance after
|
||||
authentication, the completed Evidence is returned the same way if absent from
|
||||
the investigation. If that exact Evidence was already present, the error
|
||||
instead carries `{kind: "retained-evidence", evidence_id: ...}` for the current
|
||||
session. The refused write adds neither a record nor an Unknown revision; a
|
||||
previously retained record or Unknown remains available.
|
||||
The broader retention decision is tracked in
|
||||
[#4276](https://github.com/morluto/rea/issues/4276).
|
||||
|
||||
@@ -1577,12 +1602,21 @@ cursor outside a procedure is represented as
|
||||
procedure resolution returns typed unreadable output; provider errors never
|
||||
become absence from their message text. `inspect_address_context` requires an explicit address and
|
||||
returns name, procedure, comment, inline-comment, and bookmark facets;
|
||||
unsupported facets are local `unavailable` outcomes. A facet whose input is
|
||||
rejected includes its canonical `error` projection, including any structured
|
||||
issues, alongside the other facet outcomes. Use `current_document`,
|
||||
unsupported facets are local `unavailable` outcomes. A facet rejected for
|
||||
invalid input, source Evidence integrity or a resource constraint includes its canonical `error`
|
||||
projection, including structured issues or reported limits and recovery,
|
||||
alongside the other facet outcomes. Use `current_document`,
|
||||
`current_address`, and `current_procedure` for direct single-field lookups; use
|
||||
the aggregate tools when you need related context together.
|
||||
|
||||
If unretained facet Evidence makes the context response exceed an MCP delivery
|
||||
limit, REA omits those partial records in estimated size order until the reply
|
||||
fits. Each affected facet identifies its unretained omission and the delivery
|
||||
constraint; other facets and partials remain in the response when they fit. The
|
||||
size estimate is used only for ordering and can omit more records than a perfect
|
||||
wire-size ordering would. If the remaining context still cannot fit, call the
|
||||
needed address operations individually and follow their delivery diagnostics.
|
||||
|
||||
## Integrity attribution and reconstruction claims
|
||||
|
||||
Artifact comparison results include `integrity_mismatch_evidence_ids`, naming
|
||||
|
||||
@@ -9,18 +9,23 @@ import {
|
||||
AnalysisCapabilityUnavailableError,
|
||||
AnalysisInputError,
|
||||
AnalysisOutputError,
|
||||
AnalysisResourceConstraintError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import {
|
||||
omitUnretainedEvidenceFromError,
|
||||
projectAnalysisError,
|
||||
unretainedEvidenceCandidates,
|
||||
type AnalysisErrorProjection,
|
||||
} from "../domain/analysisErrorProjection.js";
|
||||
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
|
||||
import { analysisErrorWithCancellation } from "../domain/analysisErrorCancellation.js";
|
||||
import { EvidenceIntegrityError } from "../domain/evidenceErrors.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import { analysisBookmarkSchema } from "../domain/native/nativeInventory.js";
|
||||
import { parseContainingProcedureResolution } from "../domain/native/nativeObservation.js";
|
||||
import { nativeAddressCoordinateInputSchema } from "../domain/native/nativeAddress.js";
|
||||
import { resolveAnalysisDocument } from "./AnalysisDocument.js";
|
||||
import { estimateJsonStorage } from "./JsonAuthenticationHeadroom.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
|
||||
type Facet =
|
||||
@@ -32,6 +37,16 @@ type Facet =
|
||||
readonly error?: AnalysisErrorProjection;
|
||||
};
|
||||
|
||||
type AddressContext = {
|
||||
readonly address: string;
|
||||
readonly document: string;
|
||||
readonly name: Facet;
|
||||
readonly procedure: Facet;
|
||||
readonly comment: Facet;
|
||||
readonly inline_comment: Facet;
|
||||
readonly bookmarks: Facet;
|
||||
};
|
||||
|
||||
/** Facets inspected after an explicit or provider-resolved document is known. */
|
||||
export const ADDRESS_CONTEXT_FACET_OPERATIONS = [
|
||||
"address_name",
|
||||
@@ -99,12 +114,62 @@ export const getNavigationContext = async (
|
||||
});
|
||||
};
|
||||
|
||||
/** Preserve other facets and smaller partials if unretained Evidence cannot fit. */
|
||||
export function* omitContextEvidenceForDelivery(
|
||||
context: AddressContext,
|
||||
limit: {
|
||||
readonly constraint: "receive-buffer" | "string-length";
|
||||
readonly bytesAtLeast: number;
|
||||
},
|
||||
budgetBytes: number,
|
||||
): Generator<AddressContext> {
|
||||
const names = [
|
||||
"name",
|
||||
"procedure",
|
||||
"comment",
|
||||
"inline_comment",
|
||||
"bookmarks",
|
||||
] as const;
|
||||
const candidates = names.flatMap((name) => {
|
||||
const facet = context[name];
|
||||
if (facet.state !== "unavailable" || facet.error === undefined) return [];
|
||||
return [...unretainedEvidenceCandidates(facet.error)].map((candidate) => {
|
||||
const size = estimateJsonStorage(candidate.value, {
|
||||
maximumValueBytes: budgetBytes,
|
||||
includeSerialized: false,
|
||||
}).valueBytes;
|
||||
return { name, candidate, size };
|
||||
});
|
||||
});
|
||||
candidates.sort((left, right) => right.size - left.size);
|
||||
let current = context;
|
||||
for (const { name, candidate } of candidates) {
|
||||
const facet = current[name];
|
||||
if (facet.state !== "unavailable" || facet.error === undefined) continue;
|
||||
const compact = omitUnretainedEvidenceFromError(
|
||||
facet.error,
|
||||
limit,
|
||||
candidate,
|
||||
);
|
||||
if (compact === undefined) continue;
|
||||
current = {
|
||||
...current,
|
||||
[name]: {
|
||||
...facet,
|
||||
remediation: compact.remediation.action,
|
||||
error: compact,
|
||||
},
|
||||
};
|
||||
yield current;
|
||||
}
|
||||
}
|
||||
|
||||
/** Inspect bounded provider-neutral facets at one explicit reproducible address. */
|
||||
export const inspectAddressContext = async (
|
||||
analysis: AnalysisOperationPort,
|
||||
input: { readonly address: string; readonly document?: string | undefined },
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<JsonValue, AnalysisError>> => {
|
||||
): Promise<Result<AddressContext, AnalysisError>> => {
|
||||
const document = await resolveAnalysisDocument(
|
||||
analysis,
|
||||
input.document,
|
||||
@@ -225,9 +290,18 @@ const unavailableFacet = (
|
||||
: "Review provider availability in binary_session with detail complete and select another supported provider if appropriate. After switching, if a name is known and search_procedures is available, use it to find candidates and verify their identity and coordinates.",
|
||||
};
|
||||
}
|
||||
if (error instanceof AnalysisInputError) {
|
||||
if (
|
||||
error instanceof AnalysisInputError ||
|
||||
error instanceof AnalysisResourceConstraintError ||
|
||||
(error instanceof EvidenceIntegrityError &&
|
||||
error.sourcePublicationRecovery !== undefined)
|
||||
) {
|
||||
const projected = projectAnalysisError(error);
|
||||
if (projected.code === "invalid_request")
|
||||
if (
|
||||
projected.code === "invalid_request" ||
|
||||
projected.code === "resource_constraint" ||
|
||||
projected.code === "evidence_integrity_mismatch"
|
||||
)
|
||||
return {
|
||||
state: "unavailable",
|
||||
reason: projected.message,
|
||||
@@ -245,7 +319,11 @@ const unavailableFacet = (
|
||||
const isRequestLevelFailure = (error: AnalysisError): boolean =>
|
||||
error._tag === "AnalysisCancelledError" ||
|
||||
error._tag === "AnalysisTimeoutError" ||
|
||||
error._tag === "NoBinaryOpenError";
|
||||
error._tag === "NoBinaryOpenError" ||
|
||||
(error.sourcePublicationRecovery !== undefined &&
|
||||
!(error instanceof AnalysisInputError) &&
|
||||
!(error instanceof AnalysisResourceConstraintError) &&
|
||||
!(error instanceof EvidenceIntegrityError));
|
||||
|
||||
/** Wait for all facets and retain every unresolved cleanup owner. */
|
||||
const contextFailure = (
|
||||
@@ -292,7 +370,8 @@ const contextFailure = (
|
||||
).bindCleanupDependencies([previous, failure]);
|
||||
} else if (
|
||||
!(failure instanceof AnalysisCancelledError) &&
|
||||
isRequestLevelFailure(failure)
|
||||
(isRequestLevelFailure(failure) ||
|
||||
failure.sourcePublicationRecovery !== undefined)
|
||||
) {
|
||||
combined.retainSecondaryFailure(failure);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { admitCompletedAnalysis } from "./investigation/admitCompletedAnalysis.js";
|
||||
import type { EspFlashSelection } from "../domain/firmware/espFlashLocation.js";
|
||||
import type { ExecutableFormatHint } from "../domain/dosCom.js";
|
||||
import { dispatchQueuedInterrupts } from "../process/queuedInterrupts.js";
|
||||
@@ -25,8 +26,10 @@ import {
|
||||
retainAnalysisResultSources,
|
||||
type AnalysisResultWithSources,
|
||||
} from "./investigation/recordEvidenceSources.js";
|
||||
import { resolveInvestigationEvidenceClosure } from "./investigation/InvestigationEvidence.js";
|
||||
import { createInvestigationEvidenceClosureChecker } from "./investigation/InvestigationEvidence.js";
|
||||
import {
|
||||
createInvestigationEvidenceClosureChecker,
|
||||
resolveInvestigationEvidenceClosure,
|
||||
} from "./investigation/InvestigationEvidence.js";
|
||||
import type { Evidence } from "../domain/evidence.js";
|
||||
import type { NativeToolName } from "../contracts/native/nativeToolContracts.js";
|
||||
import type { EnhancedToolName } from "../contracts/enhancedInputs.js";
|
||||
@@ -43,6 +46,7 @@ import {
|
||||
AnalysisSnapshotMismatchError,
|
||||
type PublishedEvidenceFile,
|
||||
} from "../domain/evidenceErrors.js";
|
||||
import { analysisErrorWithCancellation } from "../domain/analysisErrorCancellation.js";
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import { access, stat } from "node:fs/promises";
|
||||
import { absoluteCallerPath } from "../filesystem/callerPath.js";
|
||||
@@ -278,7 +282,13 @@ export const runProviderAnalysis = async (
|
||||
operationSignal,
|
||||
);
|
||||
await dispatchQueuedInterrupts();
|
||||
if (!original.ok) return err(cliError(original.error, path));
|
||||
if (!original.ok)
|
||||
return err(
|
||||
cliError(
|
||||
retainFinalCancellation(original.error, tool, operationSignal),
|
||||
path,
|
||||
),
|
||||
);
|
||||
const evidence = withEvidenceCallerInputPath(original.value, path);
|
||||
if (operationSignal.aborted)
|
||||
return err(
|
||||
@@ -322,6 +332,7 @@ export const runManagedProviderExecution = async (
|
||||
tool: ManagedToolName,
|
||||
signal?: AbortSignal,
|
||||
parameters: Readonly<Record<string, JsonValue>> = {},
|
||||
recoveryParameters?: Readonly<Record<string, JsonValue>>,
|
||||
): Promise<Result<AnalysisExecution, AnalysisError>> =>
|
||||
withProcessCancellation(signal, async (operationSignal) => {
|
||||
const session = await dependencies.createManagedBinarySession();
|
||||
@@ -330,9 +341,14 @@ export const runManagedProviderExecution = async (
|
||||
async () => {
|
||||
const opened = await session.open(path, { signal: operationSignal });
|
||||
if (!opened.ok) return opened;
|
||||
return await session.execute(tool, parameters, {
|
||||
signal: operationSignal,
|
||||
});
|
||||
return recoveryParameters === undefined
|
||||
? await session.execute(tool, parameters, { signal: operationSignal })
|
||||
: await session.executeForDirectObservation(
|
||||
tool,
|
||||
parameters,
|
||||
recoveryParameters,
|
||||
{ signal: operationSignal },
|
||||
);
|
||||
},
|
||||
managedAnalysisCleanupFailure,
|
||||
);
|
||||
@@ -353,6 +369,7 @@ const runManagedProviderAnalysis = async (
|
||||
tool,
|
||||
signal,
|
||||
arguments_,
|
||||
evidenceParameters,
|
||||
);
|
||||
if (!execution.ok) return execution;
|
||||
const value = execution.value;
|
||||
@@ -626,13 +643,35 @@ const runAnalysis = async (
|
||||
readonly cleanupError?: AnalysisError;
|
||||
}>(
|
||||
session,
|
||||
async () => ({ result: await execute() }),
|
||||
async () => {
|
||||
const result = await execute();
|
||||
if (
|
||||
!result.ok &&
|
||||
result.error.sourcePublicationRecovery?.completed.kind ===
|
||||
"retained-evidence"
|
||||
) {
|
||||
const retained = session.evidenceForAnalysis(
|
||||
result.error.sourcePublicationRecovery.completed.evidence_id,
|
||||
);
|
||||
if (retained !== undefined)
|
||||
result.error.sourcePublicationRecovery = {
|
||||
...result.error.sourcePublicationRecovery,
|
||||
completed: {
|
||||
kind: "inline-evidence",
|
||||
retention: "one-shot-session",
|
||||
source_closure: "not-established",
|
||||
evidence: retained,
|
||||
},
|
||||
};
|
||||
}
|
||||
return { result };
|
||||
},
|
||||
(value, cleanupError) => ({ ...value, cleanupError }),
|
||||
);
|
||||
await dispatchQueuedInterrupts();
|
||||
const result = outcome.result.ok
|
||||
? completeProviderEvidence(outcome.result.value, signal)
|
||||
: outcome.result;
|
||||
: err(retainFinalCancellation(outcome.result.error, tool, signal));
|
||||
if (!result.ok && publishedSnapshot !== undefined)
|
||||
result.error.retainDiagnostics({
|
||||
analysis_snapshot: { ...publishedSnapshot },
|
||||
@@ -655,6 +694,19 @@ const runAnalysis = async (
|
||||
: err(cliError(result.error, path));
|
||||
};
|
||||
|
||||
/** Preserve an earlier failure and a cancellation observed during final cleanup. */
|
||||
const retainFinalCancellation = (
|
||||
failure: AnalysisError,
|
||||
tool: string,
|
||||
signal: AbortSignal,
|
||||
): AnalysisError =>
|
||||
signal.aborted
|
||||
? analysisErrorWithCancellation(
|
||||
failure,
|
||||
new AnalysisCancelledError(tool, { cause: signal.reason }),
|
||||
)
|
||||
: failure;
|
||||
|
||||
const executeAnalysisTool = async (input: {
|
||||
readonly session: BinarySession;
|
||||
readonly openedTarget: BinaryTarget;
|
||||
@@ -732,12 +784,17 @@ const executeAnalysisTool = async (input: {
|
||||
: { cachePolicy: result.value.cachePolicy }),
|
||||
});
|
||||
}
|
||||
let result = await session.execute(tool, input.arguments, {
|
||||
signal,
|
||||
...(input.saveSnapshot && tool === "procedure_pseudo_code"
|
||||
? { snapshotContext: "one-shot-target" as const }
|
||||
: {}),
|
||||
});
|
||||
let result = await session.executeForDirectObservation(
|
||||
tool,
|
||||
input.arguments,
|
||||
input.evidenceParameters,
|
||||
{
|
||||
signal,
|
||||
...(input.saveSnapshot && tool === "procedure_pseudo_code"
|
||||
? { snapshotContext: "one-shot-target" as const }
|
||||
: {}),
|
||||
},
|
||||
);
|
||||
if (
|
||||
tool === "xrefs" &&
|
||||
typeof input.arguments.address === "string" &&
|
||||
@@ -759,23 +816,32 @@ const executeAnalysisTool = async (input: {
|
||||
);
|
||||
if (!address.ok) return address;
|
||||
if (address.value !== input.arguments.address)
|
||||
result = await session.execute(
|
||||
result = await session.executeForDirectObservation(
|
||||
tool,
|
||||
{ ...input.arguments, address: address.value },
|
||||
input.evidenceParameters,
|
||||
{ signal },
|
||||
);
|
||||
}
|
||||
if (!result.ok) return result;
|
||||
const observation = {
|
||||
...result.value,
|
||||
subject:
|
||||
result.value.subject === undefined
|
||||
? input.openedTarget
|
||||
: result.value.subject,
|
||||
};
|
||||
const admitted = admitCompletedAnalysis(
|
||||
tool,
|
||||
input.evidenceParameters,
|
||||
observation,
|
||||
session.admitRecoveryObservation.bind(session),
|
||||
);
|
||||
if (!admitted.ok) return admitted;
|
||||
const evidence = createAnalysisExecutionEvidence(
|
||||
tool,
|
||||
input.evidenceParameters,
|
||||
{
|
||||
...result.value,
|
||||
subject:
|
||||
result.value.subject === undefined
|
||||
? input.openedTarget
|
||||
: result.value.subject,
|
||||
},
|
||||
observation,
|
||||
"observed",
|
||||
);
|
||||
return ok({ evidence });
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import {
|
||||
sourcePublicationRecoveryOf,
|
||||
type AnalysisError,
|
||||
} from "../domain/analysisErrorBase.js";
|
||||
import {
|
||||
childFailureDiagnostics,
|
||||
projectAnalysisError,
|
||||
@@ -33,19 +36,55 @@ export const projectDirectAnalysisFailure = (
|
||||
callerInputPath: string,
|
||||
): ReturnType<typeof projectAnalysisError> => {
|
||||
const projection = projectInvocationFailure(error);
|
||||
const observation = tryParseEvidence(projection.details?.partial_observation);
|
||||
return observation === undefined
|
||||
? projection
|
||||
: {
|
||||
...projection,
|
||||
details: {
|
||||
...projection.details,
|
||||
partial_observation: withEvidenceCallerInputPath(
|
||||
observation,
|
||||
const partial = projection.details?.partial_observation;
|
||||
const wrapped =
|
||||
typeof partial === "object" &&
|
||||
partial !== null &&
|
||||
!Array.isArray(partial) &&
|
||||
partial.kind === "unretained-evidence";
|
||||
const observation = tryParseEvidence(wrapped ? partial.evidence : partial);
|
||||
const rebound =
|
||||
observation === undefined
|
||||
? undefined
|
||||
: withEvidenceCallerInputPath(observation, callerInputPath);
|
||||
const completed = sourcePublicationRecoveryOf(error)?.completed;
|
||||
const completedWithPath =
|
||||
completed?.kind === "unretained-evidence" ||
|
||||
completed?.kind === "inline-evidence"
|
||||
? {
|
||||
...completed,
|
||||
evidence: withEvidenceCallerInputPath(
|
||||
completed.evidence,
|
||||
callerInputPath,
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
: completed?.kind === "unretained-observation"
|
||||
? {
|
||||
...completed,
|
||||
observation: {
|
||||
...completed.observation,
|
||||
caller_input_path: callerInputPath,
|
||||
},
|
||||
}
|
||||
: undefined;
|
||||
if (rebound === undefined && completedWithPath === undefined)
|
||||
return projection;
|
||||
return {
|
||||
...projection,
|
||||
details: {
|
||||
...projection.details,
|
||||
...(rebound === undefined
|
||||
? {}
|
||||
: {
|
||||
partial_observation: wrapped
|
||||
? { ...partial, evidence: rebound }
|
||||
: rebound,
|
||||
}),
|
||||
...(completedWithPath === undefined
|
||||
? {}
|
||||
: { completed_observation: completedWithPath }),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/** Await one-shot cleanup and retain either the outcome or both failures. */
|
||||
|
||||
+23
-12
@@ -367,6 +367,28 @@ const providerSettingSelections = (
|
||||
]),
|
||||
];
|
||||
|
||||
/** Render what the registration comparison established without inferring its effective environment. */
|
||||
export const registrationEnvironmentDetail = (
|
||||
client: string,
|
||||
{
|
||||
unrecorded,
|
||||
differing,
|
||||
unresolved,
|
||||
}: NonNullable<ClientRegistrationStatus["provider_settings"]>,
|
||||
): string =>
|
||||
[
|
||||
`Provider checks used this terminal's environment; what the server ${client} starts receives is unknown.`,
|
||||
...(unrecorded.length === 0
|
||||
? []
|
||||
: [`Its registration does not record ${unrecorded.join(", ")}.`]),
|
||||
...(differing.length === 0
|
||||
? []
|
||||
: [`It records other values for ${differing.join(", ")}.`]),
|
||||
...(unresolved.length === 0
|
||||
? []
|
||||
: [`REA could not resolve its ${unresolved.join(", ")}.`]),
|
||||
].join(" ");
|
||||
|
||||
/**
|
||||
* A healthy provider check describes this terminal, not the server a client
|
||||
* starts. Report what the registration's file leaves out without gating health:
|
||||
@@ -385,18 +407,7 @@ const registrationEnvironmentChecks = (
|
||||
name: `registration_environment:${client}`,
|
||||
ok: true,
|
||||
classification: "unknown" as const,
|
||||
detail: [
|
||||
`Provider checks used this terminal's environment; what the server ${client} starts receives is unknown.`,
|
||||
...(unrecorded.length === 0
|
||||
? []
|
||||
: [`Its registration does not record ${unrecorded.join(", ")}.`]),
|
||||
...(differing.length === 0
|
||||
? []
|
||||
: [`It records other values for ${differing.join(", ")}.`]),
|
||||
...(unresolved.length === 0
|
||||
? []
|
||||
: [`REA could not resolve its ${unresolved.join(", ")}.`]),
|
||||
].join(" "),
|
||||
detail: registrationEnvironmentDetail(client, settings),
|
||||
details: {
|
||||
effective_environment: "unknown",
|
||||
providers: selections
|
||||
|
||||
@@ -4,7 +4,12 @@ import { expect, it } from "vitest";
|
||||
|
||||
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
|
||||
import { createDoctorHostFixture as host } from "./Doctor.fixture.js";
|
||||
import { runDoctor, systemDoctorHost, type DoctorHost } from "./Doctor.js";
|
||||
import {
|
||||
registrationEnvironmentDetail,
|
||||
runDoctor,
|
||||
systemDoctorHost,
|
||||
type DoctorHost,
|
||||
} from "./Doctor.js";
|
||||
|
||||
const inspection = (
|
||||
id: string,
|
||||
@@ -176,9 +181,16 @@ it("states each comparison result without a claim it did not establish", async (
|
||||
]),
|
||||
}),
|
||||
);
|
||||
const detail = report.checks.find(({ name }) => name === CHECK)?.detail;
|
||||
expect(detail).toContain("what the server cursor starts receives is unknown");
|
||||
expect(detail).toContain("records other values for JAVA_HOME");
|
||||
expect(detail).toContain("could not resolve its GHIDRA_INSTALL_DIR");
|
||||
expect(detail).not.toContain("does not record");
|
||||
const check = report.checks.find(({ name }) => name === CHECK);
|
||||
expect(check).toMatchObject({
|
||||
classification: "unknown",
|
||||
details: { effective_environment: "unknown" },
|
||||
});
|
||||
expect(check?.detail).toBe(
|
||||
registrationEnvironmentDetail("cursor", {
|
||||
unrecorded: [],
|
||||
differing: ["JAVA_HOME"],
|
||||
unresolved: ["GHIDRA_INSTALL_DIR"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
estimateJsonStorage,
|
||||
estimateJsonArrayScalarEntry,
|
||||
requireAuthenticationHeadroom,
|
||||
} from "./JsonAuthenticationHeadroom.js";
|
||||
import { AnalysisResourceConstraintError } from "../domain/analysisErrorCore.js";
|
||||
@@ -19,6 +20,56 @@ it("stops a bounded representation scan before visiting an over-budget tail", ()
|
||||
expect(measured.valueBytes).toBeGreaterThan(512);
|
||||
});
|
||||
describe("JSON authentication headroom", () => {
|
||||
it("measures source IDs without first constructing their array", () => {
|
||||
const values = ["ev_123", null, 'escaped\"\n😀'];
|
||||
const empty = estimateJsonStorage({ evidence_links: [] });
|
||||
const incremental = values.reduce((total, value, index) => {
|
||||
const entry = estimateJsonArrayScalarEntry(value, index > 0);
|
||||
return {
|
||||
valueBytes: total.valueBytes + entry.valueBytes,
|
||||
serializedUtf16Bytes:
|
||||
total.serializedUtf16Bytes + entry.serializedUtf16Bytes,
|
||||
};
|
||||
}, empty);
|
||||
expect(incremental).toEqual(
|
||||
estimateJsonStorage({ evidence_links: values }),
|
||||
);
|
||||
});
|
||||
it("accounts for a measured representation that has not yet been allocated", () => {
|
||||
const value = { result: "small" };
|
||||
const base = estimateJsonStorage(value);
|
||||
const additional = { valueBytes: 8192, serializedUtf16Bytes: 2048 };
|
||||
const total =
|
||||
(base.valueBytes +
|
||||
base.serializedUtf16Bytes +
|
||||
additional.valueBytes +
|
||||
additional.serializedUtf16Bytes) *
|
||||
2;
|
||||
const request = {
|
||||
operation: "address_name",
|
||||
path: "recovery",
|
||||
description: "Cannot authenticate",
|
||||
additional,
|
||||
};
|
||||
expect(
|
||||
requireAuthenticationHeadroom(value, {
|
||||
...request,
|
||||
availableBytes: total - 1,
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
resource: "memory",
|
||||
reportedLimits: { authentication_bytes: total },
|
||||
},
|
||||
});
|
||||
expect(
|
||||
requireAuthenticationHeadroom(value, {
|
||||
...request,
|
||||
availableBytes: total,
|
||||
}),
|
||||
).toEqual({ ok: true, value: undefined });
|
||||
});
|
||||
it("does not queue every row while deciding whether a wide report can fit", () => {
|
||||
let pending = 0;
|
||||
let peak = 0;
|
||||
|
||||
@@ -6,6 +6,18 @@ import { err, ok, type Result } from "../domain/result.js";
|
||||
/** Authentication clones parsed JSON and hashes its canonical commitment. */
|
||||
const AUTHENTICATION_COPIES = 2;
|
||||
|
||||
/** Incremental storage of a string or null appended to an already measured array. */
|
||||
export const estimateJsonArrayScalarEntry = (
|
||||
value: string | null,
|
||||
hasPrevious: boolean,
|
||||
): { valueBytes: number; serializedUtf16Bytes: number } => ({
|
||||
valueBytes: 16 + (value === null ? 16 : 64 + value.length * 2),
|
||||
serializedUtf16Bytes:
|
||||
((value === null ? 4 : jsonStringCodeUnits(value)) +
|
||||
(hasPrevious ? 1 : 0)) *
|
||||
2,
|
||||
});
|
||||
|
||||
/**
|
||||
* Parsed storage and optional UTF-16 serialization work. A bounded value scan
|
||||
* returns only a lower bound after exceeding maximumValueBytes.
|
||||
@@ -77,11 +89,20 @@ export const requireAuthenticationHeadroom = (
|
||||
readonly path: string;
|
||||
readonly description: string;
|
||||
readonly availableBytes?: number;
|
||||
readonly additional?: {
|
||||
readonly valueBytes: number;
|
||||
readonly serializedUtf16Bytes: number;
|
||||
};
|
||||
},
|
||||
): Result<void, AnalysisResourceConstraintError> => {
|
||||
const availableBytes =
|
||||
options.availableBytes ?? getHeapStatistics().total_available_size;
|
||||
const { valueBytes, serializedUtf16Bytes } = estimateJsonStorage(value);
|
||||
const estimate = estimateJsonStorage(value);
|
||||
const valueBytes =
|
||||
estimate.valueBytes + (options.additional?.valueBytes ?? 0);
|
||||
const serializedUtf16Bytes =
|
||||
estimate.serializedUtf16Bytes +
|
||||
(options.additional?.serializedUtf16Bytes ?? 0);
|
||||
// Canonical hashing and manifest normalization can still encode scalars or
|
||||
// metadata; account for their escaped representations before allocating them.
|
||||
const requiredBytes =
|
||||
|
||||
@@ -10,21 +10,30 @@ export const workflowAnalysisProfile = (
|
||||
upstream: AnalysisProfileCommitment,
|
||||
operation?: AnalysisOperation,
|
||||
): AnalysisProfileCommitment =>
|
||||
createAnalysisProfile(REA_WORKFLOW_PROVIDER, {
|
||||
upstream_analysis_profile: upstream,
|
||||
source_limitations: "provider-operation-caveats-v1",
|
||||
...(operation === "trace_native_values"
|
||||
? { value_flow_absence: "recorded-versus-unavailable-v1" }
|
||||
: {}),
|
||||
...(operation === "binary_overview"
|
||||
? { workflow_revision: 5 }
|
||||
: operation === "batch_decompile"
|
||||
createAnalysisProfile(
|
||||
REA_WORKFLOW_PROVIDER,
|
||||
workflowAnalysisProfileParameters(upstream, operation),
|
||||
);
|
||||
|
||||
/** The owned profile shape, with null allowed when estimating its envelope. */
|
||||
export const workflowAnalysisProfileParameters = (
|
||||
upstream: AnalysisProfileCommitment | null,
|
||||
operation?: AnalysisOperation,
|
||||
) => ({
|
||||
upstream_analysis_profile: upstream,
|
||||
source_limitations: "provider-operation-caveats-v1",
|
||||
...(operation === "trace_native_values"
|
||||
? { value_flow_absence: "recorded-versus-unavailable-v1" }
|
||||
: {}),
|
||||
...(operation === "binary_overview"
|
||||
? { workflow_revision: 5 }
|
||||
: operation === "batch_decompile"
|
||||
? { workflow_revision: 4 }
|
||||
: operation === "trace_feature"
|
||||
? { workflow_revision: 4 }
|
||||
: operation === "trace_feature"
|
||||
? { workflow_revision: 4 }
|
||||
: operation === "get_call_graph"
|
||||
? { workflow_revision: 3 }
|
||||
: operation === "trace_native_values"
|
||||
? { workflow_revision: 2 }
|
||||
: { workflow_revision: 1 }),
|
||||
});
|
||||
: operation === "get_call_graph"
|
||||
? { workflow_revision: 3 }
|
||||
: operation === "trace_native_values"
|
||||
? { workflow_revision: 2 }
|
||||
: { workflow_revision: 1 }),
|
||||
});
|
||||
@@ -1,9 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
recordWorkflowUnknowns,
|
||||
createWorkflowEvidence,
|
||||
executeComposedWorkflow,
|
||||
retainComposedWorkflowSources,
|
||||
retainPartialWorkflowEvidence,
|
||||
workflowSnapshotRecord,
|
||||
} from "./WorkflowEvidence.js";
|
||||
import { InvestigationRecords } from "./investigation/InvestigationRecords.js";
|
||||
@@ -18,6 +21,7 @@ import {
|
||||
AnalysisResourceConstraintError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import { estimateJsonStorage } from "./JsonAuthenticationHeadroom.js";
|
||||
import { nativeApiBoundarySchema } from "../domain/native/nativeApiBoundary.js";
|
||||
import { projectNativeApiInspection } from "./native/NativeApiInspection.js";
|
||||
import { functionDossierSchema } from "../domain/native/functionDossier.js";
|
||||
@@ -25,6 +29,124 @@ import { ghidraFunctionDossier } from "../domain/ghidraValues.fixture.js";
|
||||
import { ANALYSIS_SNAPSHOT_PROFILE } from "../domain/analysisSnapshot.fixture.js";
|
||||
import { createEvidence } from "../domain/evidence.js";
|
||||
|
||||
it("preserves cleanup and reports an omitted partial when authentication headroom is unavailable", () => {
|
||||
const error = new AnalysisCancelledError("get_call_graph", {
|
||||
cleanup: { reason: "close failed", resources: ["session:fixture"] },
|
||||
});
|
||||
const outcome = {
|
||||
...err(error),
|
||||
partial: { result: { nodes: ["observed"] }, limitations: [] },
|
||||
sourceEvidence: [],
|
||||
};
|
||||
retainPartialWorkflowEvidence(
|
||||
outcome,
|
||||
{
|
||||
target: undefined,
|
||||
operation: "get_call_graph",
|
||||
parameters: {},
|
||||
upstreamProfile: undefined,
|
||||
},
|
||||
() =>
|
||||
err(
|
||||
new AnalysisResourceConstraintError(
|
||||
"get_call_graph",
|
||||
"capacity",
|
||||
"no headroom",
|
||||
null,
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(projectAnalysisError(error)).toMatchObject({
|
||||
code: "cleanup_incomplete",
|
||||
details: {
|
||||
cleanup: "incomplete",
|
||||
resources: ["session:fixture"],
|
||||
partial_observation: {
|
||||
kind: "omitted-observation",
|
||||
reason: "insufficient-authentication-headroom",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it.each([0, 1, 100])(
|
||||
"admits enough recovery representation for %i completed batch items",
|
||||
(count) => {
|
||||
const provider = { id: "fixture", name: "Fixture", version: "1" };
|
||||
const source = createEvidence(undefined, provider, {
|
||||
operation: "health",
|
||||
parameters: {},
|
||||
result: "source",
|
||||
});
|
||||
const items = Array.from({ length: count }, (_, index) => ({
|
||||
address: `input-${index}`,
|
||||
procedure: {
|
||||
status: "resolved",
|
||||
address: `0x${(index + 1).toString(16)}`,
|
||||
name: null,
|
||||
},
|
||||
status: "ok",
|
||||
pseudocode: 'escaped " and Unicode α'.repeat((index % 3) + 1),
|
||||
}));
|
||||
const completed = {
|
||||
...ok({
|
||||
result: { items, total: count, succeeded: count, failed: 0 },
|
||||
limitations: [],
|
||||
evidenceLinks: [source.evidence_id],
|
||||
}),
|
||||
sourceEvidence: [source],
|
||||
};
|
||||
let admitted: ReturnType<typeof estimateJsonStorage> | undefined;
|
||||
const outcome = retainComposedWorkflowSources(
|
||||
() =>
|
||||
err(
|
||||
new AnalysisResourceConstraintError(
|
||||
"record_evidence",
|
||||
"capacity",
|
||||
"full",
|
||||
null,
|
||||
),
|
||||
),
|
||||
completed,
|
||||
{
|
||||
target: {
|
||||
path: "/tmp/example",
|
||||
sha256: "a".repeat(64),
|
||||
kind: "executable",
|
||||
format: "mach-o",
|
||||
architecture: "arm64",
|
||||
availableArchitectures: ["arm64"],
|
||||
},
|
||||
operation: "batch_decompile",
|
||||
parameters: { addresses: items.map((item) => item.address) },
|
||||
upstreamProfile: ANALYSIS_SNAPSHOT_PROFILE,
|
||||
},
|
||||
() => undefined,
|
||||
(input, _operation, additional) => {
|
||||
const base = estimateJsonStorage(input);
|
||||
admitted = {
|
||||
valueBytes: base.valueBytes + additional.valueBytes,
|
||||
serializedUtf16Bytes:
|
||||
base.serializedUtf16Bytes + additional.serializedUtf16Bytes,
|
||||
};
|
||||
return ok(undefined);
|
||||
},
|
||||
);
|
||||
expect(outcome.ok).toBe(false);
|
||||
if (outcome.ok || admitted === undefined)
|
||||
throw new Error("Expected admitted recovery");
|
||||
const error = projectAnalysisError(outcome.error);
|
||||
const completedEvidence = z
|
||||
.object({ completed_observation: z.object({ evidence: z.unknown() }) })
|
||||
.parse(error.details).completed_observation.evidence;
|
||||
const actual = estimateJsonStorage(completedEvidence);
|
||||
expect(admitted.valueBytes).toBeGreaterThanOrEqual(actual.valueBytes);
|
||||
expect(admitted.serializedUtf16Bytes).toBeGreaterThanOrEqual(
|
||||
actual.serializedUtf16Bytes,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("links shared provider source Evidence once without copying its payload into the workflow", async () => {
|
||||
const source = createEvidence(
|
||||
undefined,
|
||||
|
||||
@@ -3,7 +3,10 @@ import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
import { jsonStringByteLength } from "../domain/jsonStringByteLength.js";
|
||||
import { createAnalysisExecutionEvidence } from "../domain/analysisObservation.js";
|
||||
import { REA_WORKFLOW_PROVIDER } from "../domain/investigationProviders.js";
|
||||
import { workflowAnalysisProfile } from "./WorkflowAnalysisProfile.js";
|
||||
import {
|
||||
workflowAnalysisProfile,
|
||||
workflowAnalysisProfileParameters,
|
||||
} from "./WorkflowAnalysisProfile.js";
|
||||
import type {
|
||||
AnalysisOperation,
|
||||
AnalysisOperationPort,
|
||||
@@ -20,6 +23,8 @@ import {
|
||||
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
|
||||
import {
|
||||
createEvidence,
|
||||
MISSING_EVIDENCE_SUBJECT_LIMITATION,
|
||||
evidenceEnvelopeForAdmission,
|
||||
type Evidence,
|
||||
type EvidenceLocation,
|
||||
} from "../domain/evidence.js";
|
||||
@@ -29,9 +34,16 @@ import { UnknownRegistryError } from "../domain/unknownRegistryError.js";
|
||||
import { EvidenceLedger } from "./investigation/EvidenceLedger.js";
|
||||
import {
|
||||
recordEvidenceSources,
|
||||
retainAnalysisResultSources,
|
||||
withEvidenceSources,
|
||||
type AnalysisResultWithSources,
|
||||
} from "./investigation/recordEvidenceSources.js";
|
||||
import { preserveCompletedSourceEvidence } from "./investigation/sourcePublicationRecovery.js";
|
||||
import {
|
||||
admitInvestigationAuthentication,
|
||||
type RecoveryAdmission,
|
||||
} from "./investigation/InvestigationRetention.js";
|
||||
import { estimateJsonStorage } from "./JsonAuthenticationHeadroom.js";
|
||||
import { workflowUnknownInput } from "./investigation/WorkflowUnknown.js";
|
||||
import {
|
||||
renderSourceLimitations,
|
||||
@@ -205,7 +217,7 @@ export const executeComposedWorkflow = async (
|
||||
};
|
||||
|
||||
/** Build the shared Evidence record for a successful composed binary workflow. */
|
||||
export const createWorkflowEvidence = (input: {
|
||||
export interface WorkflowEvidenceInput {
|
||||
readonly target: BinaryTarget | undefined;
|
||||
readonly operation: AnalysisOperation;
|
||||
readonly parameters: Readonly<Record<string, JsonValue>>;
|
||||
@@ -213,7 +225,13 @@ export const createWorkflowEvidence = (input: {
|
||||
readonly upstreamProfile: AnalysisProfileCommitment | undefined;
|
||||
readonly upstreamLimitations: readonly string[];
|
||||
readonly upstreamEvidenceLinks?: readonly string[];
|
||||
}): Evidence =>
|
||||
}
|
||||
|
||||
const WORKFLOW_DERIVATION_LIMITATION = "Derived by an REA composed workflow.";
|
||||
|
||||
export const createWorkflowEvidence = (
|
||||
input: WorkflowEvidenceInput,
|
||||
): Evidence =>
|
||||
createEvidence(input.target, REA_WORKFLOW_PROVIDER, {
|
||||
operation: input.operation,
|
||||
parameters: input.parameters,
|
||||
@@ -230,15 +248,134 @@ export const createWorkflowEvidence = (input: {
|
||||
...(input.upstreamEvidenceLinks === undefined
|
||||
? {}
|
||||
: { evidenceLinks: input.upstreamEvidenceLinks }),
|
||||
limitations: [
|
||||
"Derived by an REA composed workflow.",
|
||||
...input.upstreamLimitations,
|
||||
],
|
||||
limitations: [WORKFLOW_DERIVATION_LIMITATION, ...input.upstreamLimitations],
|
||||
...(input.operation === "batch_decompile"
|
||||
? { locations: batchProcedureLocations(input.result) }
|
||||
: {}),
|
||||
});
|
||||
|
||||
const admitWorkflowRecovery = (
|
||||
observation: ComposedWorkflowObservation,
|
||||
evidenceInput: Pick<
|
||||
WorkflowEvidenceInput,
|
||||
"target" | "operation" | "parameters" | "upstreamProfile"
|
||||
>,
|
||||
admitRecovery: RecoveryAdmission,
|
||||
) => {
|
||||
const admissionInput = {
|
||||
operation: evidenceInput.operation,
|
||||
parameters: evidenceInput.parameters,
|
||||
target:
|
||||
evidenceInput.target === undefined
|
||||
? null
|
||||
: {
|
||||
path: evidenceInput.target.path,
|
||||
sha256: evidenceInput.target.sha256,
|
||||
format: evidenceInput.target.format,
|
||||
architecture: evidenceInput.target.architecture ?? null,
|
||||
},
|
||||
profile: evidenceInput.upstreamProfile ?? null,
|
||||
result: observation.result,
|
||||
limitations: observation.limitations,
|
||||
links: observation.evidenceLinks ?? [],
|
||||
};
|
||||
// The completed result can also generate a location per batch item and
|
||||
// the workflow profile embeds the upstream profile. Admit these copies
|
||||
// before constructing or hashing the recovery Evidence.
|
||||
const shell = evidenceEnvelopeForAdmission(
|
||||
evidenceInput.target,
|
||||
REA_WORKFLOW_PROVIDER,
|
||||
{
|
||||
operation: evidenceInput.operation,
|
||||
parameters: {},
|
||||
result: null,
|
||||
...(evidenceInput.upstreamProfile === undefined
|
||||
? {}
|
||||
: {
|
||||
analysisProfile: {
|
||||
provider: REA_WORKFLOW_PROVIDER,
|
||||
parameters: workflowAnalysisProfileParameters(
|
||||
null,
|
||||
evidenceInput.operation,
|
||||
),
|
||||
digest: "0".repeat(64),
|
||||
},
|
||||
}),
|
||||
confidence: "derived",
|
||||
limitations: [WORKFLOW_DERIVATION_LIMITATION],
|
||||
locations: [],
|
||||
evidenceLinks: [],
|
||||
},
|
||||
);
|
||||
const additional = estimateJsonStorage([
|
||||
observation.result,
|
||||
evidenceInput.upstreamProfile ?? null,
|
||||
shell,
|
||||
...(evidenceInput.target === undefined
|
||||
? [MISSING_EVIDENCE_SUBJECT_LIMITATION]
|
||||
: []),
|
||||
]);
|
||||
return admitRecovery(admissionInput, evidenceInput.operation, additional);
|
||||
};
|
||||
|
||||
/** Experimental shared recovery of a workflow that completed before source admission. */
|
||||
export const retainComposedWorkflowSources = (
|
||||
recordEvidence: Parameters<typeof retainAnalysisResultSources>[1],
|
||||
result: Awaited<ReturnType<typeof executeComposedWorkflow>>,
|
||||
evidenceInput: Omit<
|
||||
WorkflowEvidenceInput,
|
||||
"result" | "upstreamLimitations" | "upstreamEvidenceLinks"
|
||||
>,
|
||||
evidenceForAnalysis?: (id: string) => Evidence | undefined,
|
||||
admitRecovery: RecoveryAdmission = (input, operation, additional) =>
|
||||
admitInvestigationAuthentication(input, operation, {}, additional),
|
||||
): ReturnType<
|
||||
typeof retainAnalysisResultSources<{
|
||||
readonly result: JsonValue;
|
||||
readonly limitations: readonly string[];
|
||||
readonly cachePolicy?: "live";
|
||||
readonly evidenceLinks?: readonly string[];
|
||||
}>
|
||||
> => {
|
||||
const retained = retainAnalysisResultSources(
|
||||
evidenceInput.operation,
|
||||
recordEvidence,
|
||||
result,
|
||||
);
|
||||
if (result.ok && !retained.ok) {
|
||||
const admitted = admitWorkflowRecovery(
|
||||
result.value,
|
||||
evidenceInput,
|
||||
admitRecovery,
|
||||
);
|
||||
if (!admitted.ok) {
|
||||
retained.error.retainSourcePublication({
|
||||
kind: "omitted",
|
||||
reason:
|
||||
admitted.error._tag === "AnalysisResourceConstraintError"
|
||||
? "insufficient-authentication-headroom"
|
||||
: "recovery-input-invalid",
|
||||
source_closure: "not-established",
|
||||
});
|
||||
return retained;
|
||||
}
|
||||
const evidence = createWorkflowEvidence({
|
||||
...evidenceInput,
|
||||
result: result.value.result,
|
||||
upstreamLimitations: result.value.limitations,
|
||||
...(result.value.evidenceLinks === undefined
|
||||
? {}
|
||||
: { upstreamEvidenceLinks: result.value.evidenceLinks }),
|
||||
});
|
||||
preserveCompletedSourceEvidence(
|
||||
retained.error,
|
||||
evidence,
|
||||
evidenceForAnalysis,
|
||||
);
|
||||
}
|
||||
return retained;
|
||||
};
|
||||
|
||||
/** Retain a stopped workflow's admitted observation as partial workflow Evidence on its failure. */
|
||||
export const retainPartialWorkflowEvidence = (
|
||||
outcome: ComposedWorkflowResult,
|
||||
@@ -246,8 +383,30 @@ export const retainPartialWorkflowEvidence = (
|
||||
Parameters<typeof createWorkflowEvidence>[0],
|
||||
"target" | "operation" | "parameters" | "upstreamProfile"
|
||||
>,
|
||||
admitRecovery: RecoveryAdmission = (input, operation, additional) =>
|
||||
admitInvestigationAuthentication(input, operation, {}, additional),
|
||||
): void => {
|
||||
if (outcome.ok || outcome.partial === undefined) return;
|
||||
if (
|
||||
outcome.ok ||
|
||||
outcome.partial === undefined ||
|
||||
outcome.error.partialObservation !== undefined
|
||||
)
|
||||
return;
|
||||
const admitted = admitWorkflowRecovery(
|
||||
outcome.partial,
|
||||
context,
|
||||
admitRecovery,
|
||||
);
|
||||
if (!admitted.ok) {
|
||||
outcome.error.retainPartialObservation({
|
||||
kind: "omitted-observation",
|
||||
reason:
|
||||
admitted.error._tag === "AnalysisResourceConstraintError"
|
||||
? "insufficient-authentication-headroom"
|
||||
: "recovery-input-invalid",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const evidence = createWorkflowEvidence({
|
||||
...context,
|
||||
result: outcome.partial.result,
|
||||
|
||||
@@ -4,7 +4,10 @@ import {
|
||||
analysisViewResultSchema,
|
||||
inspectAnalysisViewInputSchema,
|
||||
} from "../../domain/analysisView/analysisView.js";
|
||||
import { projectNativeLoadImageView } from "../../domain/analysisView/nativeLoadImageView.js";
|
||||
import {
|
||||
RELOCATION_SYMBOL_UNRECORDED,
|
||||
projectNativeLoadImageView,
|
||||
} from "../../domain/analysisView/nativeLoadImageView.js";
|
||||
import type { NativeLoadImage } from "../../domain/native/nativeLoadImage.js";
|
||||
import {
|
||||
nativeDosLoadImageFixture,
|
||||
@@ -80,7 +83,7 @@ it("distinguishes recorded, absent, and historical unrecorded relocation symbols
|
||||
{ record: { symbol: "external_symbol" } },
|
||||
{ record: { symbol: null } },
|
||||
]);
|
||||
expect(current.unknowns.join(" ")).not.toContain("symbol was not recorded");
|
||||
expect(current.unknowns).not.toContain(RELOCATION_SYMBOL_UNRECORDED);
|
||||
|
||||
report.observations.relocations[1]!.symbol = "";
|
||||
const emptyName = await view(report, request);
|
||||
@@ -88,15 +91,15 @@ it("distinguishes recorded, absent, and historical unrecorded relocation symbols
|
||||
if (emptyName.items.collection !== "relocations")
|
||||
throw new Error("Expected relocations");
|
||||
expect(emptyName.items.rows?.[1]?.record).toMatchObject({ symbol: "" });
|
||||
expect(emptyName.unknowns.join(" ")).not.toContain("symbol was not recorded");
|
||||
expect(emptyName.unknowns).not.toContain(RELOCATION_SYMBOL_UNRECORDED);
|
||||
|
||||
delete report.observations.relocations[0]!.symbol;
|
||||
const historical = await view(report, request);
|
||||
if (historical.kind !== "native-load-image") throw new Error("Expected page");
|
||||
expect(historical.items.rows?.[0]?.record).not.toHaveProperty("symbol");
|
||||
expect(historical.unknowns.join(" ")).toContain("symbol was not recorded");
|
||||
expect(historical.unknowns).toContain(RELOCATION_SYMBOL_UNRECORDED);
|
||||
const omitted = await view(report, { ...request, fields: ["address"] });
|
||||
expect(omitted.unknowns.join(" ")).not.toContain("symbol was not recorded");
|
||||
expect(omitted.unknowns).not.toContain(RELOCATION_SYMBOL_UNRECORDED);
|
||||
});
|
||||
|
||||
it("summarizes measured ELF facts without upgrading unsupported verification", async () => {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { bindSessionEvidenceExecution } from "./SessionEvidenceReuse.js";
|
||||
import { admitCompletedAnalysis } from "../investigation/admitCompletedAnalysis.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
writeEvidenceBundle,
|
||||
@@ -50,6 +52,7 @@ import {
|
||||
} from "./SessionProviderPolicy.js";
|
||||
import { InvestigationRecords } from "../investigation/InvestigationRecords.js";
|
||||
import { recordEvidenceSources } from "../investigation/recordEvidenceSources.js";
|
||||
import { recoverProviderSourceFailure } from "../investigation/sourcePublicationRecovery.js";
|
||||
import { BinarySessionRecords } from "./BinarySessionRecords.js";
|
||||
import { binarySessionStatus } from "./BinarySessionStatus.js";
|
||||
import { SessionRuntimeAvailability } from "./SessionRuntimeAvailability.js";
|
||||
@@ -1375,9 +1378,34 @@ export class BinarySession
|
||||
name: Parameters<AnalysisOperationPort["execute"]>[0],
|
||||
arguments_: Readonly<Record<string, JsonValue>>,
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<AnalysisExecution, AnalysisError>> {
|
||||
return this.#trackedExecute(name, arguments_, options);
|
||||
}
|
||||
|
||||
/** Preserve the one-shot caller's Evidence identity if source retention fails. */
|
||||
executeForDirectObservation(
|
||||
name: Parameters<AnalysisOperationPort["execute"]>[0],
|
||||
arguments_: Readonly<Record<string, JsonValue>>,
|
||||
evidenceParameters: Readonly<Record<string, JsonValue>>,
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<AnalysisExecution, AnalysisError>> {
|
||||
return this.#trackedExecute(name, arguments_, options, evidenceParameters);
|
||||
}
|
||||
|
||||
#trackedExecute(
|
||||
name: Parameters<AnalysisOperationPort["execute"]>[0],
|
||||
arguments_: Readonly<Record<string, JsonValue>>,
|
||||
options?: ExecutionOptions,
|
||||
recoveryParameters?: Readonly<Record<string, JsonValue>>,
|
||||
): Promise<Result<AnalysisExecution, AnalysisError>> {
|
||||
const generation = this.#transitionGeneration;
|
||||
const call = this.#execute(name, arguments_, options, this.#transition);
|
||||
const call = this.#execute(
|
||||
name,
|
||||
arguments_,
|
||||
options,
|
||||
this.#transition,
|
||||
recoveryParameters,
|
||||
);
|
||||
this.#calls.set(call, generation);
|
||||
return call.finally(() => this.#calls.delete(call));
|
||||
}
|
||||
@@ -1449,6 +1477,7 @@ export class BinarySession
|
||||
arguments_: Readonly<Record<string, JsonValue>>,
|
||||
options: ExecutionOptions | undefined,
|
||||
transition: Promise<void>,
|
||||
recoveryParameters?: Readonly<Record<string, JsonValue>>,
|
||||
): Promise<Result<AnalysisExecution, AnalysisError>> {
|
||||
const transitioned = await this.#waitForTransition(
|
||||
name,
|
||||
@@ -1535,7 +1564,24 @@ export class BinarySession
|
||||
(source) => this.recordEvidence(source),
|
||||
execution.value.sourceEvidence,
|
||||
);
|
||||
if (!sourcesRecorded.ok) return sourcesRecorded;
|
||||
if (!sourcesRecorded.ok) {
|
||||
recoverProviderSourceFailure({
|
||||
error: sourcesRecorded.error,
|
||||
operation: name,
|
||||
parameters: recoveryParameters ?? arguments_,
|
||||
execution: {
|
||||
...execution.value,
|
||||
sourceEvidence: execution.value.sourceEvidence,
|
||||
subject:
|
||||
execution.value.subject === undefined
|
||||
? active.target
|
||||
: execution.value.subject,
|
||||
},
|
||||
admit: this.admitRecoveryObservation.bind(this),
|
||||
evidenceForAnalysis: this.evidenceForAnalysis.bind(this),
|
||||
});
|
||||
return sourcesRecorded;
|
||||
}
|
||||
}
|
||||
if (
|
||||
execution.ok &&
|
||||
@@ -1547,22 +1593,37 @@ export class BinarySession
|
||||
) &&
|
||||
profile !== undefined
|
||||
) {
|
||||
const evidenceExecution = {
|
||||
...execution.value,
|
||||
analysisProfile: profile,
|
||||
subject:
|
||||
execution.value.subject === undefined
|
||||
? active.target
|
||||
: execution.value.subject,
|
||||
};
|
||||
const admitted = admitCompletedAnalysis(
|
||||
name,
|
||||
arguments_,
|
||||
evidenceExecution,
|
||||
this.admitRecoveryObservation.bind(this),
|
||||
);
|
||||
if (!admitted.ok) return admitted;
|
||||
const evidence = createAnalysisExecutionEvidence(
|
||||
name,
|
||||
arguments_,
|
||||
{
|
||||
...execution.value,
|
||||
analysisProfile: profile,
|
||||
subject:
|
||||
execution.value.subject === undefined
|
||||
? active.target
|
||||
: execution.value.subject,
|
||||
},
|
||||
evidenceExecution,
|
||||
"observed",
|
||||
);
|
||||
const recorded = this.recordEvidence(evidence);
|
||||
if (!recorded.ok) return recorded;
|
||||
const retained = { ...execution.value, evidenceId: evidence.evidence_id };
|
||||
const authenticated = this.evidenceForAnalysis(evidence.evidence_id);
|
||||
const retained =
|
||||
authenticated === undefined
|
||||
? { ...execution.value, evidenceId: evidence.evidence_id }
|
||||
: bindSessionEvidenceExecution(
|
||||
{ ...execution.value, evidenceId: evidence.evidence_id },
|
||||
authenticated,
|
||||
);
|
||||
if (cacheable && execution.value.cachePolicy !== "live")
|
||||
this.recordSnapshot({
|
||||
target: active.target,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { EspFlashSelection } from "../../domain/firmware/espFlashLocation.js";
|
||||
import type { InvestigationRecordPort } from "../investigation/InvestigationRecordPort.js";
|
||||
import type { RecoveryAdmission } from "../investigation/InvestigationRetention.js";
|
||||
import type { ResetInvestigationReceipt } from "../../domain/investigationLifecycle.js";
|
||||
import type { ExecutableFormatHint } from "../../domain/dosCom.js";
|
||||
import type {
|
||||
@@ -57,6 +58,7 @@ export type BinaryOpenResult =
|
||||
/** Target lifecycle used by CLI and MCP without exposing a concrete provider. */
|
||||
export interface BinarySessionPort
|
||||
extends AnalysisOperationPort, InvestigationRecordPort {
|
||||
readonly admitRecoveryObservation: RecoveryAdmission;
|
||||
/** Execute only against a positively ready, already-created provider client. */
|
||||
executeIfProviderReady(
|
||||
operation: AnalysisOperation,
|
||||
|
||||
@@ -126,6 +126,17 @@ export abstract class BinarySessionRecords {
|
||||
return recorded;
|
||||
}
|
||||
|
||||
admitRecoveryObservation(
|
||||
input: unknown,
|
||||
operation: string,
|
||||
additional: {
|
||||
readonly valueBytes: number;
|
||||
readonly serializedUtf16Bytes: number;
|
||||
},
|
||||
): Result<void, AnalysisError> {
|
||||
return this.#records.admitRecoveryObservation(input, operation, additional);
|
||||
}
|
||||
|
||||
hasEvidence(evidenceId: string): boolean {
|
||||
return this.#records.hasEvidence(evidenceId);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { basename } from "node:path";
|
||||
import type { AnalysisExecution } from "../AnalysisProvider.js";
|
||||
import type { Evidence } from "../../domain/evidence.js";
|
||||
import type { JsonValue } from "../../domain/jsonValue.js";
|
||||
import { equalJsonText } from "../../domain/jsonSerialization.js";
|
||||
|
||||
// Proof belongs to the exact object issued by the session. A wrapper that
|
||||
// copies or transforms the observation cannot carry it forward.
|
||||
const issued = new WeakMap<AnalysisExecution, Evidence>();
|
||||
|
||||
export const bindSessionEvidenceExecution = (
|
||||
execution: AnalysisExecution,
|
||||
evidence: Evidence,
|
||||
): AnalysisExecution => {
|
||||
if (
|
||||
(execution.sourceEvidence !== undefined &&
|
||||
execution.sourceEvidence.length > 0) ||
|
||||
evidence.caller_input_path !== undefined
|
||||
)
|
||||
return execution;
|
||||
// Semantic IDs intentionally omit display paths and names. A duplicate in
|
||||
// the ledger can therefore refer to the same bytes at a different path.
|
||||
const selected = execution.subject;
|
||||
if (
|
||||
selected == null
|
||||
? evidence.subject !== null
|
||||
: evidence.subject === null ||
|
||||
evidence.subject.local_path !== selected.path ||
|
||||
evidence.subject.name !== (basename(selected.path) || "artifact")
|
||||
)
|
||||
return execution;
|
||||
const subject =
|
||||
execution.subject === undefined || execution.subject === null
|
||||
? execution.subject
|
||||
: Object.freeze({ ...execution.subject });
|
||||
const result = Object.freeze({
|
||||
...execution,
|
||||
result: evidence.normalized_result,
|
||||
rawResult: evidence.raw_result,
|
||||
provider: evidence.provider,
|
||||
...(evidence.analysis_profile === null
|
||||
? {}
|
||||
: { analysisProfile: evidence.analysis_profile }),
|
||||
limitations: evidence.limitations,
|
||||
locations: evidence.locations,
|
||||
...(evidence.relationship_coverage === null
|
||||
? {}
|
||||
: { relationshipCoverage: evidence.relationship_coverage }),
|
||||
...(subject === undefined ? {} : { subject }),
|
||||
});
|
||||
issued.set(result, evidence);
|
||||
return result;
|
||||
};
|
||||
|
||||
export const sessionEvidenceForExecution = (
|
||||
execution: AnalysisExecution,
|
||||
operation: string,
|
||||
parameters: Readonly<Record<string, JsonValue>>,
|
||||
lookup: (id: string) => Evidence | undefined,
|
||||
): Evidence | undefined => {
|
||||
const evidence = issued.get(execution);
|
||||
if (
|
||||
evidence === undefined ||
|
||||
evidence.operation !== operation ||
|
||||
lookup(evidence.evidence_id) !== evidence ||
|
||||
!equalJsonText(evidence.parameters, parameters)
|
||||
)
|
||||
return undefined;
|
||||
return evidence;
|
||||
};
|
||||
@@ -9,7 +9,10 @@ import {
|
||||
validateResidualUnknownAddition,
|
||||
type EvidenceBundle,
|
||||
} from "../../domain/evidenceBundle.js";
|
||||
import { AnalysisInputError } from "../../domain/analysisErrorCore.js";
|
||||
import {
|
||||
AnalysisInputError,
|
||||
AnalysisResourceConstraintError,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
import { EvidenceIntegrityError } from "../../domain/evidenceErrors.js";
|
||||
import { UnknownRegistryError } from "../../domain/unknownRegistryError.js";
|
||||
import {
|
||||
@@ -17,6 +20,7 @@ import {
|
||||
evidenceDisplayMetadataAgree,
|
||||
type Evidence,
|
||||
} from "../../domain/evidence.js";
|
||||
import { freezeJsonSnapshot } from "../../domain/immutableJson.js";
|
||||
import { safeParseImmutableEvidence } from "../../domain/evidenceValidation.js";
|
||||
import {
|
||||
createResidualUnknown,
|
||||
@@ -111,7 +115,8 @@ export class EvidenceLedger {
|
||||
"record_evidence",
|
||||
new Map([[evidence.evidence_id, evidence]]),
|
||||
);
|
||||
if (!admitted.ok) return admitted;
|
||||
if (!admitted.ok)
|
||||
return err(this.#exposeRefusedEvidence(admitted.error, evidence));
|
||||
this.#records.set(evidence.evidence_id, evidence);
|
||||
this.#commitAccounting(admitted.value);
|
||||
return ok("added");
|
||||
@@ -366,14 +371,38 @@ export class EvidenceLedger {
|
||||
if (creationUnknown.revision_digest !== unknown.revision_digest)
|
||||
return err(new UnknownRegistryError("already-exists"));
|
||||
const checked = this.#appendIncremental([evidence]);
|
||||
if (!checked.ok) return checked;
|
||||
if (!checked.ok)
|
||||
return err(this.#exposeRefusedEvidence(checked.error, evidence));
|
||||
return ok(null);
|
||||
}
|
||||
const checked = this.#appendIncremental([evidence, mutation], unknown);
|
||||
if (!checked.ok) return checked;
|
||||
if (!checked.ok)
|
||||
return err(this.#exposeRefusedEvidence(checked.error, evidence));
|
||||
return ok(structuredClone(unknown));
|
||||
}
|
||||
|
||||
/** Report authenticated completed Evidence without inferring membership from this attempt. */
|
||||
#exposeRefusedEvidence(
|
||||
error: AnalysisError,
|
||||
evidence: Evidence,
|
||||
): AnalysisError {
|
||||
if (
|
||||
!(error instanceof AnalysisResourceConstraintError) ||
|
||||
error.resource !== "capacity"
|
||||
)
|
||||
return error;
|
||||
return error.retainPartialObservation(
|
||||
freezeJsonSnapshot(
|
||||
this.#records.has(evidence.evidence_id)
|
||||
? {
|
||||
kind: "retained-evidence" as const,
|
||||
evidence_id: evidence.evidence_id,
|
||||
}
|
||||
: { kind: "unretained-evidence" as const, evidence },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** Apply one approved full-state update using optimistic revision matching. */
|
||||
updateUnknown(
|
||||
input: UpdateUnknownInput,
|
||||
|
||||
@@ -96,7 +96,9 @@ describe("investigation record ownership", () => {
|
||||
...unknownInput("Is the missing Evidence present?"),
|
||||
supporting_evidence_ids: [`ev_${"f".repeat(64)}`],
|
||||
};
|
||||
expect(records.recordEvidenceWithUnknown(evidence, invalid).ok).toBe(false);
|
||||
const refused = records.recordEvidenceWithUnknown(evidence, invalid);
|
||||
if (refused.ok) throw new Error("Expected invalid Unknown references");
|
||||
expect(refused.error.partialObservation).toBeUndefined();
|
||||
expect(records.hasEvidence(evidence.evidence_id)).toBe(false);
|
||||
expect(records.exportEvidenceBundle()).toEqual(createEvidenceBundle([]));
|
||||
});
|
||||
|
||||
@@ -48,6 +48,23 @@ export class InvestigationRecords
|
||||
return this.#evidence.statistics();
|
||||
}
|
||||
|
||||
/** Check live headroom before constructing a recovery representation. */
|
||||
admitRecoveryObservation(
|
||||
input: unknown,
|
||||
operation: string,
|
||||
additional: {
|
||||
readonly valueBytes: number;
|
||||
readonly serializedUtf16Bytes: number;
|
||||
},
|
||||
): Result<void, AnalysisError> {
|
||||
return admitInvestigationAuthentication(
|
||||
input,
|
||||
operation,
|
||||
this.retentionPolicy,
|
||||
additional,
|
||||
);
|
||||
}
|
||||
|
||||
/** Record immutable Evidence idempotently; reject conflicting content. */
|
||||
recordEvidence(
|
||||
evidence: Evidence,
|
||||
|
||||
@@ -11,7 +11,13 @@ import {
|
||||
updateUnknownInputSchema,
|
||||
type ResidualUnknown,
|
||||
} from "../../domain/residualUnknown.js";
|
||||
import { createAnalysisExecutionEvidence } from "../../domain/analysisObservation.js";
|
||||
import { estimateJsonStorage } from "../JsonAuthenticationHeadroom.js";
|
||||
import { ok } from "../../domain/result.js";
|
||||
import { admitCompletedAnalysis } from "./admitCompletedAnalysis.js";
|
||||
import { recordEvidenceSources } from "./recordEvidenceSources.js";
|
||||
import { InvestigationRecords } from "./InvestigationRecords.js";
|
||||
import { isImmutableJsonSnapshot } from "../../domain/immutableJson.js";
|
||||
|
||||
const provider = { id: "fixture", name: "Retention fixture", version: "1" };
|
||||
const observe = (index: number, path = "/fixture") =>
|
||||
@@ -57,6 +63,39 @@ const recordCost = (evidence: Evidence): number => {
|
||||
return records.statistics().retainedRepresentationBytes;
|
||||
};
|
||||
|
||||
it("returns only authenticated Evidence when a completed record exceeds retention capacity", () => {
|
||||
const evidence = observe(7);
|
||||
const records = new InvestigationRecords({ maximumRepresentationBytes: 0 });
|
||||
const refused = records.recordEvidence(evidence);
|
||||
if (refused.ok) throw new Error("Expected retention refusal");
|
||||
expect(refused.error._tag).toBe("AnalysisResourceConstraintError");
|
||||
expect(refused.error.partialObservation).toEqual({
|
||||
kind: "unretained-evidence",
|
||||
evidence,
|
||||
});
|
||||
expect(isImmutableJsonSnapshot(refused.error.partialObservation)).toBe(true);
|
||||
expect(records.hasEvidence(evidence.evidence_id)).toBe(false);
|
||||
const invalid = records.recordEvidence({
|
||||
...evidence,
|
||||
normalized_result: "tampered",
|
||||
});
|
||||
if (invalid.ok) throw new Error("Expected integrity refusal");
|
||||
expect(invalid.error._tag).toBe("EvidenceIntegrityError");
|
||||
expect(invalid.error.partialObservation).toBeUndefined();
|
||||
const mutable = createEvidence(undefined, provider, {
|
||||
operation: "health",
|
||||
parameters: {},
|
||||
result: { value: 1 },
|
||||
});
|
||||
const noHeadroom = new InvestigationRecords({
|
||||
availableAuthenticationBytes: () => 0,
|
||||
}).recordEvidence(mutable);
|
||||
if (noHeadroom.ok)
|
||||
throw new Error("Expected authentication headroom refusal");
|
||||
expect(noHeadroom.error._tag).toBe("AnalysisResourceConstraintError");
|
||||
expect(noHeadroom.error.partialObservation).toBeUndefined();
|
||||
});
|
||||
|
||||
it("admits exact capacity, rejects cumulative growth atomically, and releases accounting on clear", () => {
|
||||
const first = observe(1);
|
||||
const second = observe(2);
|
||||
@@ -296,3 +335,64 @@ it("reads statistics without asking either bundle-export path to copy retained f
|
||||
expect(detached).not.toHaveBeenCalled();
|
||||
expect(borrowed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves the completed observation's path when a retained semantic ID has different display metadata", () => {
|
||||
const earlier = observe(7, "/original/file");
|
||||
const current = observe(7, "/selected/file");
|
||||
expect(current.evidence_id).toBe(earlier.evidence_id);
|
||||
const records = new InvestigationRecords({
|
||||
maximumRepresentationBytes: recordCost(earlier),
|
||||
});
|
||||
expect(records.recordEvidence(earlier).ok).toBe(true);
|
||||
const result = recordEvidenceSources(
|
||||
records.recordEvidence.bind(records),
|
||||
[observe(8)],
|
||||
{
|
||||
evidence: current,
|
||||
evidenceForAnalysis: records.evidenceForAnalysis.bind(records),
|
||||
},
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("Expected source publication failure");
|
||||
expect(result.error.sourcePublicationRecovery?.completed).toMatchObject({
|
||||
kind: "unretained-evidence",
|
||||
evidence: {
|
||||
evidence_id: current.evidence_id,
|
||||
subject: { local_path: "/selected/file" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("accounts for the generated subject limitation before admitting a subjectless observation", () => {
|
||||
const observation = {
|
||||
provider,
|
||||
result: { value: 1 },
|
||||
rawResult: null,
|
||||
limitations: ["source limit"],
|
||||
locations: [],
|
||||
subject: null,
|
||||
} as const;
|
||||
let admitted: ReturnType<typeof estimateJsonStorage> | undefined;
|
||||
const result = admitCompletedAnalysis(
|
||||
"health",
|
||||
{},
|
||||
observation,
|
||||
(input, _operation, additional) => {
|
||||
const estimate = estimateJsonStorage(input);
|
||||
admitted = {
|
||||
valueBytes: estimate.valueBytes + additional.valueBytes,
|
||||
serializedUtf16Bytes:
|
||||
estimate.serializedUtf16Bytes + additional.serializedUtf16Bytes,
|
||||
};
|
||||
return ok(undefined);
|
||||
},
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
const actual = estimateJsonStorage(
|
||||
createAnalysisExecutionEvidence("health", {}, observation, "observed"),
|
||||
);
|
||||
expect(admitted?.valueBytes).toBeGreaterThanOrEqual(actual.valueBytes);
|
||||
expect(admitted?.serializedUtf16Bytes).toBeGreaterThanOrEqual(
|
||||
actual.serializedUtf16Bytes,
|
||||
);
|
||||
});
|
||||
@@ -16,6 +16,16 @@ export interface InvestigationRetentionPolicy {
|
||||
readonly availableAuthenticationBytes?: () => number;
|
||||
}
|
||||
|
||||
/** Owner admission used before materializing a completed recovery record. */
|
||||
export type RecoveryAdmission = (
|
||||
input: unknown,
|
||||
operation: string,
|
||||
additional: {
|
||||
readonly valueBytes: number;
|
||||
readonly serializedUtf16Bytes: number;
|
||||
},
|
||||
) => Result<void, AnalysisError>;
|
||||
|
||||
/**
|
||||
* Reserve one retained representation, two authentication/canonical working
|
||||
* copies and one detached export/control representation from current V8 room.
|
||||
@@ -39,6 +49,10 @@ export const admitInvestigationAuthentication = (
|
||||
input: unknown,
|
||||
operation: string,
|
||||
policy: InvestigationRetentionPolicy,
|
||||
additional?: {
|
||||
readonly valueBytes: number;
|
||||
readonly serializedUtf16Bytes: number;
|
||||
},
|
||||
): Result<void, AnalysisError> => {
|
||||
const issue = jsonValueValidationIssue(input);
|
||||
if (issue !== undefined)
|
||||
@@ -52,6 +66,7 @@ export const admitInvestigationAuthentication = (
|
||||
path: "investigation",
|
||||
description:
|
||||
"Insufficient live heap headroom to authenticate investigation records",
|
||||
...(additional === undefined ? {} : { additional }),
|
||||
...(policy.availableAuthenticationBytes === undefined
|
||||
? {}
|
||||
: { availableBytes: policy.availableAuthenticationBytes() }),
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { MISSING_EVIDENCE_SUBJECT_LIMITATION } from "../../domain/evidence.js";
|
||||
import {
|
||||
analysisExecutionEnvelopeForAdmission,
|
||||
type AnalysisObservation,
|
||||
} from "../../domain/analysisObservation.js";
|
||||
import { estimateJsonArrayScalarEntry } from "../JsonAuthenticationHeadroom.js";
|
||||
import {
|
||||
admitInvestigationAuthentication,
|
||||
type RecoveryAdmission,
|
||||
} from "./InvestigationRetention.js";
|
||||
|
||||
/** Admit borrowed source facts before constructing their completed Evidence. */
|
||||
export const admitCompletedAnalysis = (
|
||||
operation: string,
|
||||
parameters: unknown,
|
||||
observation: AnalysisObservation,
|
||||
admit: RecoveryAdmission = (input, name, additional) =>
|
||||
admitInvestigationAuthentication(input, name, {}, additional),
|
||||
) => {
|
||||
const envelope = analysisExecutionEnvelopeForAdmission(
|
||||
operation,
|
||||
parameters,
|
||||
observation,
|
||||
"observed",
|
||||
);
|
||||
let valueBytes = 0;
|
||||
let serializedUtf16Bytes = 0;
|
||||
if (observation.subject == null) {
|
||||
const entry = estimateJsonArrayScalarEntry(
|
||||
MISSING_EVIDENCE_SUBJECT_LIMITATION,
|
||||
observation.limitations.length > 0,
|
||||
);
|
||||
valueBytes += entry.valueBytes;
|
||||
serializedUtf16Bytes += entry.serializedUtf16Bytes;
|
||||
}
|
||||
for (const [index, source] of (observation.sourceEvidence ?? []).entries()) {
|
||||
const entry = estimateJsonArrayScalarEntry(source.evidence_id, index > 0);
|
||||
valueBytes += entry.valueBytes;
|
||||
serializedUtf16Bytes += entry.serializedUtf16Bytes;
|
||||
}
|
||||
const admitted = admit(envelope, operation, {
|
||||
valueBytes,
|
||||
serializedUtf16Bytes,
|
||||
});
|
||||
if (!admitted.ok)
|
||||
admitted.error.retainSourcePublication({
|
||||
kind: "omitted",
|
||||
reason:
|
||||
admitted.error._tag === "AnalysisResourceConstraintError"
|
||||
? "insufficient-authentication-headroom"
|
||||
: "recovery-input-invalid",
|
||||
source_closure: "not-established",
|
||||
});
|
||||
return admitted;
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { EvidenceWriter } from "./InvestigationRecordPort.js";
|
||||
import type { Evidence } from "../../domain/evidence.js";
|
||||
import { preserveCompletedSourceEvidence } from "./sourcePublicationRecovery.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import { AnalysisCapabilityUnavailableError } from "../../domain/analysisErrorCore.js";
|
||||
@@ -54,10 +55,22 @@ export const retainAnalysisResultSources = <Value>(
|
||||
export const recordEvidenceSources = (
|
||||
recordEvidence: EvidenceWriter["recordEvidence"] | undefined,
|
||||
sources: readonly Evidence[],
|
||||
completed?: {
|
||||
readonly evidence: Evidence;
|
||||
readonly evidenceForAnalysis?: (id: string) => Evidence | undefined;
|
||||
},
|
||||
): Result<null, AnalysisError> => {
|
||||
for (const source of sources) {
|
||||
const recorded = recordEvidence?.(source);
|
||||
if (recorded !== undefined && !recorded.ok) return recorded;
|
||||
if (recorded !== undefined && !recorded.ok) {
|
||||
if (completed !== undefined)
|
||||
preserveCompletedSourceEvidence(
|
||||
recorded.error,
|
||||
completed.evidence,
|
||||
completed.evidenceForAnalysis,
|
||||
);
|
||||
return recorded;
|
||||
}
|
||||
}
|
||||
return ok(null);
|
||||
};
|
||||
@@ -0,0 +1,207 @@
|
||||
import { ZodError } from "zod";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import { createAnalysisExecutionEvidence } from "../../domain/analysisObservation.js";
|
||||
import { isDigest } from "../../domain/digests.js";
|
||||
import {
|
||||
evidenceDisplayMetadataAgree,
|
||||
evidenceSchema,
|
||||
isImmutableEvidence,
|
||||
type Evidence,
|
||||
} from "../../domain/evidence.js";
|
||||
import { safeParseImmutableEvidence } from "../../domain/evidenceValidation.js";
|
||||
import { freezeJsonSnapshot } from "../../domain/immutableJson.js";
|
||||
import { jsonObjectSchema, type JsonValue } from "../../domain/jsonValue.js";
|
||||
import type { Result } from "../../domain/result.js";
|
||||
import type { AnalysisExecution } from "../AnalysisProvider.js";
|
||||
import {
|
||||
estimateJsonArrayScalarEntry,
|
||||
estimateJsonStorage,
|
||||
} from "../JsonAuthenticationHeadroom.js";
|
||||
import { admitInvestigationAuthentication } from "./InvestigationRetention.js";
|
||||
|
||||
/** Preserve a completed result only after authenticating it and checking its actual owner. */
|
||||
export const preserveCompletedSourceEvidence = (
|
||||
error: AnalysisError,
|
||||
evidence: Evidence,
|
||||
evidenceForAnalysis: ((id: string) => Evidence | undefined) | undefined,
|
||||
): void => {
|
||||
if (!isImmutableEvidence(evidence)) {
|
||||
const headroom = admitInvestigationAuthentication(
|
||||
evidence,
|
||||
evidence.operation,
|
||||
{},
|
||||
);
|
||||
if (!headroom.ok) {
|
||||
error.retainSourcePublication({
|
||||
kind: "omitted",
|
||||
reason:
|
||||
headroom.error._tag === "AnalysisResourceConstraintError"
|
||||
? "insufficient-authentication-headroom"
|
||||
: "primary-authentication-failed",
|
||||
source_closure: "not-established",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
const authenticated = safeParseImmutableEvidence(evidence);
|
||||
if (!authenticated.ok) {
|
||||
error.retainSourcePublication({
|
||||
kind: "omitted",
|
||||
reason: "primary-authentication-failed",
|
||||
source_closure: "not-established",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const valid = authenticated.value;
|
||||
const retained = evidenceForAnalysis?.(valid.evidence_id);
|
||||
error.retainSourcePublication(
|
||||
freezeJsonSnapshot(
|
||||
evidenceForAnalysis === undefined
|
||||
? {
|
||||
kind: "inline-evidence" as const,
|
||||
retention: "unknown" as const,
|
||||
source_closure: "not-established" as const,
|
||||
evidence: valid,
|
||||
}
|
||||
: retained !== undefined &&
|
||||
evidenceDisplayMetadataAgree(retained, valid)
|
||||
? {
|
||||
kind: "retained-evidence" as const,
|
||||
source_closure: "not-established" as const,
|
||||
evidence_id: valid.evidence_id,
|
||||
}
|
||||
: {
|
||||
kind: "unretained-evidence" as const,
|
||||
source_closure: "not-established" as const,
|
||||
evidence: valid,
|
||||
},
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
/** Materialize a provider result only after source recording failed and admission succeeds. */
|
||||
export const recoverProviderSourceFailure = (input: {
|
||||
readonly error: AnalysisError;
|
||||
readonly operation: string;
|
||||
readonly parameters: Readonly<Record<string, JsonValue>>;
|
||||
readonly execution: AnalysisExecution & {
|
||||
readonly sourceEvidence: readonly Evidence[];
|
||||
};
|
||||
readonly admit: (
|
||||
value: unknown,
|
||||
operation: string,
|
||||
additional: {
|
||||
readonly valueBytes: number;
|
||||
readonly serializedUtf16Bytes: number;
|
||||
},
|
||||
) => Result<void, AnalysisError>;
|
||||
readonly evidenceForAnalysis: (id: string) => Evidence | undefined;
|
||||
}): void => {
|
||||
const {
|
||||
error,
|
||||
operation,
|
||||
parameters,
|
||||
execution,
|
||||
admit,
|
||||
evidenceForAnalysis,
|
||||
} = input;
|
||||
const { sourceEvidence, ...primaryExecution } = execution;
|
||||
// The schema owns the envelope shape. Measure its generated fields and then
|
||||
// account for source IDs incrementally, before allocating the ID array.
|
||||
const shell: Record<string, JsonValue> = Object.fromEntries(
|
||||
Object.keys(evidenceSchema.shape).map((key) => [key, null]),
|
||||
);
|
||||
shell.evidence_id = `ev_${"0".repeat(64)}`;
|
||||
shell.predicate_type = "rea.analysis";
|
||||
shell.confidence = "observed";
|
||||
shell.authority = "shipped-artifact";
|
||||
shell.evidence_links = [];
|
||||
const base = estimateJsonStorage(shell);
|
||||
let sourceValueBytes = base.valueBytes;
|
||||
let sourceSerializedUtf16Bytes = base.serializedUtf16Bytes;
|
||||
let validSourceIds = true;
|
||||
for (const [index, source] of sourceEvidence.entries()) {
|
||||
const id = recoverySourceId(source);
|
||||
const entry = estimateJsonArrayScalarEntry(id, index > 0);
|
||||
sourceValueBytes += entry.valueBytes;
|
||||
sourceSerializedUtf16Bytes += entry.serializedUtf16Bytes;
|
||||
validSourceIds &&=
|
||||
id !== null &&
|
||||
id.length === 67 &&
|
||||
id.startsWith("ev_") &&
|
||||
isDigest(id.slice(3));
|
||||
}
|
||||
const headroom = admit(
|
||||
{
|
||||
operation,
|
||||
parameters,
|
||||
execution: primaryExecution,
|
||||
subject: execution.subject,
|
||||
},
|
||||
operation,
|
||||
{
|
||||
valueBytes: sourceValueBytes,
|
||||
serializedUtf16Bytes: sourceSerializedUtf16Bytes,
|
||||
},
|
||||
);
|
||||
if (!headroom.ok) {
|
||||
error.retainSourcePublication({
|
||||
kind: "omitted",
|
||||
reason:
|
||||
headroom.error._tag === "AnalysisResourceConstraintError"
|
||||
? "insufficient-authentication-headroom"
|
||||
: "recovery-input-invalid",
|
||||
source_closure: "not-established",
|
||||
});
|
||||
return;
|
||||
}
|
||||
let evidence: Evidence | undefined;
|
||||
if (validSourceIds) {
|
||||
try {
|
||||
evidence = createAnalysisExecutionEvidence(
|
||||
operation,
|
||||
parameters,
|
||||
execution,
|
||||
"observed",
|
||||
{ immutable: true },
|
||||
);
|
||||
} catch (cause) {
|
||||
if (!(cause instanceof ZodError)) throw cause;
|
||||
}
|
||||
}
|
||||
if (evidence !== undefined) {
|
||||
preserveCompletedSourceEvidence(error, evidence, evidenceForAnalysis);
|
||||
return;
|
||||
}
|
||||
const observation = jsonObjectSchema.parse({
|
||||
operation,
|
||||
parameters,
|
||||
result: execution.result,
|
||||
raw_result: execution.rawResult,
|
||||
provider: execution.provider,
|
||||
analysis_profile: execution.analysisProfile ?? null,
|
||||
subject: execution.subject ?? null,
|
||||
limitations: execution.limitations,
|
||||
locations: execution.locations,
|
||||
relationship_coverage: execution.relationshipCoverage ?? null,
|
||||
source_evidence_ids: sourceEvidence.map(recoverySourceId),
|
||||
});
|
||||
error.retainSourcePublication(
|
||||
freezeJsonSnapshot({
|
||||
kind: "unretained-observation" as const,
|
||||
source_closure: "not-established" as const,
|
||||
observation,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
/** Read only an ordinary data property; source payloads may have failed validation. */
|
||||
const recoverySourceId = (source: unknown): string | null => {
|
||||
if (typeof source !== "object" || source === null) return null;
|
||||
const property = Object.getOwnPropertyDescriptor(source, "evidence_id");
|
||||
return property !== undefined &&
|
||||
"value" in property &&
|
||||
typeof property.value === "string"
|
||||
? property.value
|
||||
: null;
|
||||
};
|
||||
@@ -36,6 +36,14 @@ const generic = <Code extends string>(code: Code) =>
|
||||
|
||||
/** The canonical invalid-input projection, also used for facet-local refusals. */
|
||||
export const analysisInputErrorProjectionSchema = generic("invalid_request");
|
||||
/** The canonical resource refusal, including its reported limits and recovery. */
|
||||
export const analysisResourceErrorProjectionSchema = generic(
|
||||
"resource_constraint",
|
||||
);
|
||||
/** Authentication or integrity refusal for a supplied Evidence record. */
|
||||
export const evidenceIntegrityErrorProjectionSchema = generic(
|
||||
"evidence_integrity_mismatch",
|
||||
);
|
||||
|
||||
/** Stable discriminated schema shared by every CLI and MCP error surface. */
|
||||
export const analysisErrorProjectionSchema = z.discriminatedUnion("code", [
|
||||
@@ -47,7 +55,7 @@ export const analysisErrorProjectionSchema = z.discriminatedUnion("code", [
|
||||
generic("unsupported_target"),
|
||||
generic("provider_unavailable"),
|
||||
generic("provider_timeout"),
|
||||
generic("resource_constraint"),
|
||||
analysisResourceErrorProjectionSchema,
|
||||
generic("cancelled"),
|
||||
z
|
||||
.object({
|
||||
@@ -90,7 +98,7 @@ export const analysisErrorProjectionSchema = z.discriminatedUnion("code", [
|
||||
})
|
||||
.strict(),
|
||||
generic("artifact_operation_failed"),
|
||||
generic("evidence_integrity_mismatch"),
|
||||
evidenceIntegrityErrorProjectionSchema,
|
||||
generic("truncated"),
|
||||
generic("process_capture_failed"),
|
||||
generic("cleanup_incomplete"),
|
||||
|
||||
@@ -187,7 +187,7 @@ export const SESSION_TOOL_CONTRACTS = [
|
||||
{
|
||||
...session(
|
||||
"inspect_address_context",
|
||||
"Inspect one explicit reproducible address for its analyzed name, containing procedure, regular and inline comments, and matching bookmarks. Each unsupported facet returns a typed unavailable outcome. A facet rejected for invalid input retains its canonical error and issues alongside any successful facets. Use xrefs, assembly, or pseudocode for those deeper views.",
|
||||
"Inspect one explicit reproducible address for its analyzed name, containing procedure, regular and inline comments, and matching bookmarks. Each unsupported facet returns a typed unavailable outcome. A facet rejected for invalid input, source Evidence integrity, or a resource constraint retains its canonical error and recovery alongside successful facets. Use xrefs, assembly, or pseudocode for those deeper views.",
|
||||
addressContextInputSchema,
|
||||
),
|
||||
requiresActiveTarget: true,
|
||||
|
||||
@@ -101,6 +101,8 @@ import { decompileDirectoryManifestSchema } from "./decompileDirectory.js";
|
||||
import {
|
||||
analysisErrorProjectionSchema,
|
||||
analysisInputErrorProjectionSchema,
|
||||
analysisResourceErrorProjectionSchema,
|
||||
evidenceIntegrityErrorProjectionSchema,
|
||||
} from "./errorSchemas.js";
|
||||
import { relationshipCoverageSchema } from "../domain/relationshipCoverage.js";
|
||||
import { nativeDispatchMetadataResultSchema } from "../domain/native/objcSwiftMetadata.js";
|
||||
@@ -122,13 +124,18 @@ import {
|
||||
import { processCaptureSchema } from "../domain/process/processCapture.js";
|
||||
import { processScenarioPlanSchema } from "../domain/process/processScenario.js";
|
||||
import { electronFuseReportSchema } from "../domain/electron/electronFuses.js";
|
||||
const contextFacetErrorSchema = z.discriminatedUnion("code", [
|
||||
analysisInputErrorProjectionSchema,
|
||||
analysisResourceErrorProjectionSchema,
|
||||
evidenceIntegrityErrorProjectionSchema,
|
||||
]);
|
||||
const contextFacetSchema = z.discriminatedUnion("state", [
|
||||
z.object({ state: z.literal("available"), value: jsonValueSchema }),
|
||||
z.object({
|
||||
state: z.literal("unavailable"),
|
||||
reason: z.string(),
|
||||
remediation: z.string(),
|
||||
error: analysisInputErrorProjectionSchema.optional(),
|
||||
error: contextFacetErrorSchema.optional(),
|
||||
}),
|
||||
]);
|
||||
const bookmarkFacetSchema = z.discriminatedUnion("state", [
|
||||
@@ -140,7 +147,7 @@ const bookmarkFacetSchema = z.discriminatedUnion("state", [
|
||||
state: z.literal("unavailable"),
|
||||
reason: z.string(),
|
||||
remediation: z.string(),
|
||||
error: analysisInputErrorProjectionSchema.optional(),
|
||||
error: contextFacetErrorSchema.optional(),
|
||||
}),
|
||||
]);
|
||||
|
||||
|
||||
@@ -115,6 +115,43 @@ export interface AnalysisCleanupResourceOwner {
|
||||
readonly cleanupResources: readonly string[] | undefined;
|
||||
}
|
||||
|
||||
/** Result completed before its source Evidence could be published. */
|
||||
export type SourcePublicationCompletion =
|
||||
| {
|
||||
readonly kind: "unretained-evidence";
|
||||
readonly source_closure: "not-established";
|
||||
readonly evidence: Evidence;
|
||||
}
|
||||
| {
|
||||
readonly kind: "retained-evidence";
|
||||
readonly source_closure: "not-established";
|
||||
readonly evidence_id: string;
|
||||
}
|
||||
| {
|
||||
readonly kind: "unretained-observation";
|
||||
readonly source_closure: "not-established";
|
||||
readonly observation: Readonly<Record<string, JsonValue>>;
|
||||
}
|
||||
| {
|
||||
readonly kind: "inline-evidence";
|
||||
readonly retention: "one-shot-session" | "unknown";
|
||||
readonly source_closure: "not-established";
|
||||
readonly evidence: Evidence;
|
||||
}
|
||||
| {
|
||||
readonly kind: "omitted";
|
||||
readonly source_closure: "not-established";
|
||||
readonly reason:
|
||||
| "primary-authentication-failed"
|
||||
| "insufficient-authentication-headroom"
|
||||
| "recovery-input-invalid";
|
||||
};
|
||||
|
||||
export interface SourcePublicationRecovery {
|
||||
readonly source: AnalysisPartialObservation | undefined;
|
||||
readonly completed: SourcePublicationCompletion;
|
||||
}
|
||||
|
||||
/** Base class for expected analysis, provider, and session failures. */
|
||||
export abstract class AnalysisError extends Error {
|
||||
limit: Readonly<AnalysisSettingLimit> | undefined;
|
||||
@@ -175,8 +212,19 @@ export abstract class AnalysisError extends Error {
|
||||
readonly userCategory: "cancelled" | undefined = undefined;
|
||||
readonly executionFailure: string | undefined = undefined;
|
||||
partialObservation: AnalysisPartialObservation | undefined;
|
||||
sourcePublicationRecovery: SourcePublicationRecovery | undefined;
|
||||
workflowUnknownPublication: WorkflowUnknownPublication | undefined;
|
||||
|
||||
/** Keep the failed source and completed result with their error owner. */
|
||||
retainSourcePublication(completed: SourcePublicationCompletion): this {
|
||||
this.sourcePublicationRecovery = {
|
||||
source: this.partialObservation,
|
||||
completed,
|
||||
};
|
||||
this.partialObservation = undefined;
|
||||
return this;
|
||||
}
|
||||
|
||||
get cleanupPrimary(): AnalysisError | undefined {
|
||||
return this.#cleanupPrimary;
|
||||
}
|
||||
@@ -342,6 +390,20 @@ export const primaryAnalysisError = (error: AnalysisError): AnalysisError => {
|
||||
while (error.cleanupPrimary !== undefined) error = error.cleanupPrimary;
|
||||
return error;
|
||||
};
|
||||
|
||||
/** Find the completed publication outcome owned anywhere in a cleanup chain. */
|
||||
export const sourcePublicationRecoveryOf = (
|
||||
error: AnalysisError,
|
||||
): SourcePublicationRecovery | undefined => {
|
||||
for (
|
||||
let owner: AnalysisError | undefined = error;
|
||||
owner !== undefined;
|
||||
owner = owner.cleanupPrimary
|
||||
)
|
||||
if (owner.sourcePublicationRecovery !== undefined)
|
||||
return owner.sourcePublicationRecovery;
|
||||
return undefined;
|
||||
};
|
||||
import type {
|
||||
PartialProcessCaptureObservation,
|
||||
ProcessCaptureCleanupReport,
|
||||
@@ -402,6 +464,14 @@ import type { WebArtifactAcquisition } from "./webArtifactAcquisition.js";
|
||||
|
||||
/** Provider-neutral evidence collected before a typed analysis failure. */
|
||||
export type AnalysisPartialObservation =
|
||||
| {
|
||||
readonly kind: "omitted-observation";
|
||||
readonly reason:
|
||||
| "insufficient-authentication-headroom"
|
||||
| "recovery-input-invalid";
|
||||
}
|
||||
| { readonly kind: "unretained-evidence"; readonly evidence: Evidence }
|
||||
| { readonly kind: "retained-evidence"; readonly evidence_id: string }
|
||||
| GraphqlContract
|
||||
| PlaywrightTrace
|
||||
| SavedAnalysisSnapshotPartialObservation
|
||||
|
||||
@@ -195,12 +195,15 @@ it.each([
|
||||
const projected = projectAnalysisError(composed);
|
||||
expect(projected).toMatchObject(original);
|
||||
expect(projected.details?.cleanup_history).toMatchObject({
|
||||
primary_error:
|
||||
primary.capturedOutput === undefined
|
||||
? original
|
||||
: projectAnalysisErrorWithoutCapturedOutput(primary),
|
||||
primary_error: { code: original.code, message: original.message },
|
||||
cleanup_error: { code: "cleanup_incomplete" },
|
||||
});
|
||||
expect(projected.details?.cleanup_history).not.toHaveProperty(
|
||||
"primary_error.details.partial_observation",
|
||||
);
|
||||
expect(projected.details?.cleanup_history).not.toHaveProperty(
|
||||
"primary_error.details.captured_output",
|
||||
);
|
||||
expect(projected.details).not.toHaveProperty("cleanup", "incomplete");
|
||||
expect(projected.details).not.toHaveProperty("resources");
|
||||
expect(analysisErrorCategory(composed)).toBe(original.category);
|
||||
@@ -251,6 +254,33 @@ it.each(["inner", "outer"] as const)(
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves recovery and secondary failures owned by an intermediate cleanup", () => {
|
||||
const primary = new AnalysisInputError("inspect");
|
||||
const inner = analysisErrorWithCleanupFailure(
|
||||
primary,
|
||||
new ProviderCleanupError("fixture", ["input"], {}),
|
||||
);
|
||||
inner.retainSourcePublication({
|
||||
kind: "omitted",
|
||||
reason: "primary-authentication-failed",
|
||||
source_closure: "not-established",
|
||||
});
|
||||
inner.retainSecondaryFailure(new AnalysisInputError("source"));
|
||||
const outer = analysisErrorWithCleanupFailure(
|
||||
inner,
|
||||
new ProviderCleanupError("fixture", ["workspace"], {}),
|
||||
);
|
||||
expect(projectAnalysisError(outer)).toMatchObject({
|
||||
details: {
|
||||
completed_observation_omitted: {
|
||||
reason: "primary-authentication-failed",
|
||||
source_closure: "not-established",
|
||||
},
|
||||
secondary_failures: [{ code: "invalid_request" }],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("does not infer cleanup success when a composition has no live owner binding", () => {
|
||||
const primary = new AnalysisInputError("inspect");
|
||||
const cleanup = new ProviderCleanupError("fixture", ["input"], {});
|
||||
|
||||
@@ -44,6 +44,7 @@ export const analysisErrorWithCleanupFailure = (
|
||||
...childFailureDiagnostics({
|
||||
primary_error: {
|
||||
error: primary,
|
||||
omitRecovery: true,
|
||||
...(primary.capturedOutput === undefined
|
||||
? {}
|
||||
: { capturedOutput: "omit" as const }),
|
||||
|
||||
@@ -47,6 +47,10 @@ import {
|
||||
} from "./analysisErrorBase.js";
|
||||
import type { AnalysisErrorProjection } from "./analysisErrorProjection.js";
|
||||
|
||||
/** Explain how to preserve an observation the session could not retain. */
|
||||
export const UNRETAINED_EVIDENCE_EXPORT_GUIDANCE =
|
||||
"Unretained Evidence is not included in the exported investigation; if present in this response, save it separately.";
|
||||
|
||||
/** File export when one MCP frame cannot carry the retained session bundle. */
|
||||
export const MCP_TRANSPORT_EVIDENCE_EXPORT =
|
||||
"Export complete retained session evidence through export_evidence_bundle to a caller-selected path, or use complete CLI JSON output. The connection remains usable.";
|
||||
|
||||
@@ -1582,6 +1582,24 @@ describe("analysis error projection: composed failures for a caller lifetime", (
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps caller lifetime for a secondary failure embedded in child diagnostics", () => {
|
||||
const primary = new AnalysisInputError("read_bytes", undefined, []);
|
||||
const secondary = new AnalysisTimeoutError("search_strings", 250, {
|
||||
limit: analysisSettingLimit("regex.matching_deadline_ms", 250, "setting"),
|
||||
continuingCallerGuidance: continuingOnly,
|
||||
});
|
||||
primary.retainSecondaryFailure(secondary);
|
||||
const history = childFailureDiagnostics({
|
||||
primary_error: { error: primary },
|
||||
});
|
||||
expect(history.diagnostics?.primary_error).toEqual(
|
||||
projectAnalysisError(primary, "continuing"),
|
||||
);
|
||||
expect(history.invocationDiagnostics?.primary_error).toEqual(
|
||||
projectAnalysisError(primary, "invocation"),
|
||||
);
|
||||
});
|
||||
|
||||
it("records one lifetime form when no child depends on the caller", () => {
|
||||
const composed = analysisErrorWithCleanupFailure(
|
||||
new AnalysisInputError("read_bytes", undefined, [
|
||||
|
||||
@@ -9,6 +9,7 @@ import { isImmutableJsonSnapshot } from "./immutableJson.js";
|
||||
|
||||
import {
|
||||
analysisErrorCategory,
|
||||
UNRETAINED_EVIDENCE_EXPORT_GUIDANCE,
|
||||
analysisErrorRemediationAction,
|
||||
analysisErrorUserMessage,
|
||||
assertKnownAnalysisErrorTag,
|
||||
@@ -50,6 +51,7 @@ import { providerRetryAction } from "./providerOperationHealth.js";
|
||||
import {
|
||||
primaryAnalysisError,
|
||||
resolvedAnalysisError,
|
||||
sourcePublicationRecoveryOf,
|
||||
type AnalysisCallerLifetime,
|
||||
type AnalysisError,
|
||||
type AnalysisErrorOptions,
|
||||
@@ -67,19 +69,195 @@ import { invocationSettingLimit } from "./analysisSettingDefinitions.js";
|
||||
export const projectAnalysisError = (
|
||||
error: AnalysisError,
|
||||
callerLifetime: AnalysisCallerLifetime = "continuing",
|
||||
): AnalysisErrorProjection => projectError(error, true, callerLifetime);
|
||||
): AnalysisErrorProjection =>
|
||||
projectError(
|
||||
error,
|
||||
{
|
||||
capturedOutput: true,
|
||||
partialObservation: true,
|
||||
secondary: true,
|
||||
},
|
||||
callerLifetime,
|
||||
);
|
||||
|
||||
/** Cleanup history references output retained by its enclosing error owner. */
|
||||
export const projectAnalysisErrorWithoutCapturedOutput = (
|
||||
error: AnalysisError,
|
||||
callerLifetime: AnalysisCallerLifetime = "continuing",
|
||||
): AnalysisErrorProjection => projectError(error, false, callerLifetime);
|
||||
): AnalysisErrorProjection =>
|
||||
projectError(
|
||||
error,
|
||||
{
|
||||
capturedOutput: false,
|
||||
partialObservation: true,
|
||||
secondary: true,
|
||||
},
|
||||
callerLifetime,
|
||||
);
|
||||
|
||||
/** Bounded fallback when the completed observation itself cannot be delivered. */
|
||||
export const projectAnalysisErrorWithoutPartialObservation = (
|
||||
error: AnalysisError,
|
||||
callerLifetime: AnalysisCallerLifetime = "continuing",
|
||||
): AnalysisErrorProjection =>
|
||||
projectError(
|
||||
error,
|
||||
{
|
||||
capturedOutput: true,
|
||||
partialObservation: false,
|
||||
secondary: true,
|
||||
},
|
||||
callerLifetime,
|
||||
);
|
||||
|
||||
export interface UnretainedEvidenceCandidate {
|
||||
readonly field: "partial_observation" | "completed_observation";
|
||||
readonly secondaryPath: readonly number[];
|
||||
readonly value: JsonValue;
|
||||
}
|
||||
|
||||
/** Enumerate recoveries owned by this projection, including secondary failures. */
|
||||
export function* unretainedEvidenceCandidates(
|
||||
projected: AnalysisErrorProjection,
|
||||
secondaryPath: readonly number[] = [],
|
||||
): Generator<UnretainedEvidenceCandidate> {
|
||||
for (const field of [
|
||||
"partial_observation",
|
||||
"completed_observation",
|
||||
] as const) {
|
||||
const value = projected.details?.[field];
|
||||
if (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
!Array.isArray(value) &&
|
||||
(value.kind === "unretained-evidence" ||
|
||||
value.kind === "unretained-observation" ||
|
||||
value.kind === "inline-evidence")
|
||||
)
|
||||
yield { field, secondaryPath, value };
|
||||
}
|
||||
const secondary = projected.details?.secondary_failures;
|
||||
if (!Array.isArray(secondary)) return;
|
||||
for (const [index, failure] of secondary.entries()) {
|
||||
if (
|
||||
typeof failure !== "object" ||
|
||||
failure === null ||
|
||||
Array.isArray(failure)
|
||||
)
|
||||
continue;
|
||||
const remediation = failure.remediation;
|
||||
if (
|
||||
typeof remediation !== "object" ||
|
||||
remediation === null ||
|
||||
Array.isArray(remediation) ||
|
||||
typeof remediation.action !== "string"
|
||||
)
|
||||
continue;
|
||||
yield* unretainedEvidenceCandidates(
|
||||
failure as unknown as AnalysisErrorProjection,
|
||||
[...secondaryPath, index],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Omit one explicitly unretained recovery when a complete reply cannot fit. */
|
||||
export const omitUnretainedEvidenceFromError = (
|
||||
projected: AnalysisErrorProjection,
|
||||
limit: {
|
||||
readonly constraint: "receive-buffer" | "string-length";
|
||||
readonly bytesAtLeast: number;
|
||||
},
|
||||
candidate: UnretainedEvidenceCandidate,
|
||||
): AnalysisErrorProjection | undefined => {
|
||||
const { field, secondaryPath } = candidate;
|
||||
if (secondaryPath.length > 0) {
|
||||
const [index, ...rest] = secondaryPath;
|
||||
const secondary = projected.details?.secondary_failures;
|
||||
if (!Array.isArray(secondary) || index === undefined) return undefined;
|
||||
const failure = secondary[index];
|
||||
if (
|
||||
typeof failure !== "object" ||
|
||||
failure === null ||
|
||||
Array.isArray(failure)
|
||||
)
|
||||
return undefined;
|
||||
const omitted = omitUnretainedEvidenceFromError(
|
||||
failure as unknown as AnalysisErrorProjection,
|
||||
limit,
|
||||
{ ...candidate, secondaryPath: rest },
|
||||
);
|
||||
if (omitted === undefined) return undefined;
|
||||
return {
|
||||
...projected,
|
||||
details: {
|
||||
...projected.details,
|
||||
secondary_failures: secondary.map((item, i) =>
|
||||
i === index ? omitted : item,
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
const partial = projected.details?.[field];
|
||||
if (
|
||||
typeof partial !== "object" ||
|
||||
partial === null ||
|
||||
Array.isArray(partial) ||
|
||||
(partial.kind !== "unretained-evidence" &&
|
||||
partial.kind !== "unretained-observation" &&
|
||||
partial.kind !== "inline-evidence")
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const { [field]: _partial, ...details } = projected.details!;
|
||||
const evidence = partial.evidence;
|
||||
const evidenceId =
|
||||
typeof evidence === "object" &&
|
||||
evidence !== null &&
|
||||
!Array.isArray(evidence)
|
||||
? evidence.evidence_id
|
||||
: undefined;
|
||||
const retention =
|
||||
partial.kind === "inline-evidence"
|
||||
? typeof partial.retention === "string"
|
||||
? partial.retention
|
||||
: "unknown"
|
||||
: "unretained";
|
||||
return {
|
||||
...projected,
|
||||
remediation: {
|
||||
action: `${projected.remediation.action} ${recoveryOmissionRemediation(retention)}`,
|
||||
},
|
||||
details: {
|
||||
...details,
|
||||
[`${field}_omitted`]: {
|
||||
reason: "mcp-response-limit",
|
||||
retention,
|
||||
constraint: limit.constraint,
|
||||
original_response_bytes_at_least: limit.bytesAtLeast,
|
||||
...(typeof evidenceId === "string" ? { evidence_id: evidenceId } : {}),
|
||||
...(field === "completed_observation"
|
||||
? { source_closure: "not-established" }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/** Explain the delivered omission without inferring ledger membership. */
|
||||
export const recoveryOmissionRemediation = (retention: string): string =>
|
||||
retention === "unretained"
|
||||
? "The omitted observation was not retained and could not fit within MCP delivery limits."
|
||||
: retention === "unknown"
|
||||
? "The omitted observation's retention is unknown, and it could not fit within MCP delivery limits."
|
||||
: "The observation could not fit within MCP delivery limits.";
|
||||
|
||||
/** A child failure embedded in a composed error's diagnostics. */
|
||||
export interface AnalysisChildFailure {
|
||||
readonly error: AnalysisError;
|
||||
/** Omit output that the composing error already retains. */
|
||||
readonly capturedOutput?: "omit";
|
||||
/** Omit recovery facts retained by the composing error. */
|
||||
readonly omitRecovery?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -93,17 +271,26 @@ export const childFailureDiagnostics = (
|
||||
): Pick<AnalysisErrorOptions, "diagnostics" | "invocationDiagnostics"> => {
|
||||
const entries = Object.entries(children);
|
||||
const project = (
|
||||
{ error, capturedOutput }: AnalysisChildFailure,
|
||||
{ error, capturedOutput, omitRecovery }: AnalysisChildFailure,
|
||||
callerLifetime: AnalysisCallerLifetime,
|
||||
) => projectError(error, capturedOutput !== "omit", callerLifetime);
|
||||
) =>
|
||||
projectError(
|
||||
error,
|
||||
{
|
||||
capturedOutput: capturedOutput !== "omit",
|
||||
partialObservation: omitRecovery !== true,
|
||||
secondary: omitRecovery !== true,
|
||||
},
|
||||
callerLifetime,
|
||||
);
|
||||
const continuing = {
|
||||
...diagnostics,
|
||||
...Object.fromEntries(
|
||||
entries.map(([key, child]) => [key, project(child, "continuing")]),
|
||||
),
|
||||
};
|
||||
const varying = entries.filter(([, { error }]) =>
|
||||
projectionDependsOnCallerLifetime(error),
|
||||
const varying = entries.filter(([, { error, omitRecovery }]) =>
|
||||
projectionDependsOnCallerLifetime(error, omitRecovery !== true),
|
||||
);
|
||||
return varying.length === 0
|
||||
? { diagnostics: continuing }
|
||||
@@ -118,26 +305,44 @@ export const childFailureDiagnostics = (
|
||||
};
|
||||
};
|
||||
|
||||
/** Every lifetime-dependent input the projection reads lies on this chain. */
|
||||
const projectionDependsOnCallerLifetime = (error: AnalysisError): boolean => {
|
||||
/** Check the primary chain and the secondary failures included in its projection. */
|
||||
const projectionDependsOnCallerLifetime = (
|
||||
error: AnalysisError,
|
||||
includeSecondary: boolean,
|
||||
): boolean => {
|
||||
const chainDepends = (first: AnalysisError): boolean => {
|
||||
for (
|
||||
let node: AnalysisError | undefined = first;
|
||||
node !== undefined;
|
||||
node = node.cleanupPrimary
|
||||
)
|
||||
if (
|
||||
node.limit !== undefined ||
|
||||
node.continuingCallerGuidance !== undefined ||
|
||||
node.invocationDiagnostics !== undefined ||
|
||||
node.workflowUnknownPublication !== undefined
|
||||
)
|
||||
return true;
|
||||
return false;
|
||||
};
|
||||
if (chainDepends(error)) return true;
|
||||
if (!includeSecondary) return false;
|
||||
for (
|
||||
let node: AnalysisError | undefined = error;
|
||||
node !== undefined;
|
||||
node = node.cleanupPrimary
|
||||
)
|
||||
if (
|
||||
node.limit !== undefined ||
|
||||
node.continuingCallerGuidance !== undefined ||
|
||||
node.invocationDiagnostics !== undefined ||
|
||||
node.workflowUnknownPublication !== undefined
|
||||
)
|
||||
return true;
|
||||
if (node.secondaryFailures.some(chainDepends)) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
const projectError = (
|
||||
error: AnalysisError,
|
||||
includeCapturedOutput: boolean,
|
||||
options: {
|
||||
readonly capturedOutput: boolean;
|
||||
readonly partialObservation: boolean;
|
||||
readonly secondary: boolean;
|
||||
},
|
||||
callerLifetime: AnalysisCallerLifetime,
|
||||
): AnalysisErrorProjection => {
|
||||
const composed = error;
|
||||
@@ -152,8 +357,30 @@ const projectError = (
|
||||
const cleanupHistory =
|
||||
composed === error ? undefined : composed.diagnosticsFor(callerLifetime);
|
||||
const capturedOutput = composed.capturedOutput ?? error.capturedOutput;
|
||||
const publication = sourcePublicationRecoveryOf(composed);
|
||||
const secondary = new Set<AnalysisError>();
|
||||
for (
|
||||
let owner: AnalysisError | undefined = composed;
|
||||
owner !== undefined;
|
||||
owner = owner.cleanupPrimary
|
||||
) {
|
||||
for (const failure of owner.secondaryFailures) secondary.add(failure);
|
||||
}
|
||||
const partialObservation =
|
||||
composed.partialObservation ?? error.partialObservation;
|
||||
composed.partialObservation ??
|
||||
error.partialObservation ??
|
||||
publication?.source;
|
||||
const completedSourceObservation =
|
||||
publication?.completed.kind === "omitted"
|
||||
? undefined
|
||||
: publication?.completed;
|
||||
const completedSourceOmission =
|
||||
publication?.completed.kind === "omitted"
|
||||
? {
|
||||
reason: publication.completed.reason,
|
||||
source_closure: publication.completed.source_closure,
|
||||
}
|
||||
: undefined;
|
||||
// A finalizer can add a report above the semantic primary, including while
|
||||
// its cleanup is pending or beneath another cleanup composition.
|
||||
let cleanupReport = composed.cleanupReport;
|
||||
@@ -169,20 +396,20 @@ const projectError = (
|
||||
const underlyingCode = underlyingErrorCode(primary);
|
||||
const code = error.cleanupIncomplete ? "cleanup_incomplete" : underlyingCode;
|
||||
const primaryDetails = errorDetails(primary);
|
||||
const publication =
|
||||
const unknownPublication =
|
||||
composed.workflowUnknownPublication ?? primary.workflowUnknownPublication;
|
||||
const details = {
|
||||
...primaryDetails,
|
||||
...(publication === undefined
|
||||
...(unknownPublication === undefined
|
||||
? {}
|
||||
: {
|
||||
workflow_unknown_publication: {
|
||||
...publication,
|
||||
...unknownPublication,
|
||||
evidence_retention:
|
||||
callerLifetime === "invocation" &&
|
||||
publication.evidence_retention === "retained"
|
||||
unknownPublication.evidence_retention === "retained"
|
||||
? "invocation-only"
|
||||
: publication.evidence_retention,
|
||||
: unknownPublication.evidence_retention,
|
||||
},
|
||||
}),
|
||||
...(primary.limit === undefined
|
||||
@@ -193,6 +420,17 @@ const projectError = (
|
||||
? { ...invocationSettingLimit(primary.limit) }
|
||||
: { ...primary.limit },
|
||||
}),
|
||||
...(!options.secondary || secondary.size === 0
|
||||
? {}
|
||||
: {
|
||||
secondary_failures: [...secondary].map((failure) =>
|
||||
projectError(
|
||||
failure,
|
||||
{ ...options, secondary: false },
|
||||
callerLifetime,
|
||||
),
|
||||
),
|
||||
}),
|
||||
...(cleanupReport === undefined
|
||||
? {}
|
||||
: { cleanup_report: jsonValueSchema.parse(cleanupReport) }),
|
||||
@@ -218,20 +456,32 @@ const projectError = (
|
||||
primaryDetails?.execution_failure ?? underlyingCode,
|
||||
}
|
||||
: {}),
|
||||
...(!includeCapturedOutput || capturedOutput === undefined
|
||||
...(!options.capturedOutput || capturedOutput === undefined
|
||||
? {}
|
||||
: {
|
||||
captured_output: {
|
||||
...capturedOutput,
|
||||
},
|
||||
}),
|
||||
...(partialObservation === undefined
|
||||
...(!options.partialObservation || partialObservation === undefined
|
||||
? {}
|
||||
: {
|
||||
partial_observation: isImmutableJsonSnapshot(partialObservation)
|
||||
? partialObservation
|
||||
: jsonValueSchema.parse(partialObservation),
|
||||
}),
|
||||
...(!options.partialObservation || completedSourceObservation === undefined
|
||||
? {}
|
||||
: {
|
||||
completed_observation: isImmutableJsonSnapshot(
|
||||
completedSourceObservation,
|
||||
)
|
||||
? completedSourceObservation
|
||||
: jsonValueSchema.parse(completedSourceObservation),
|
||||
}),
|
||||
...(!options.partialObservation || completedSourceOmission === undefined
|
||||
? {}
|
||||
: { completed_observation_omitted: completedSourceOmission }),
|
||||
};
|
||||
// The retained cleanup owner's retry precedes the failed operation's hint.
|
||||
const cleanupRetry = error.cleanupRetry;
|
||||
@@ -240,9 +490,24 @@ const projectError = (
|
||||
? errorRecovery(error)
|
||||
: cleanupRetryRecovery(cleanupRetry);
|
||||
const remediation = analysisErrorRemediationAction(error, callerLifetime);
|
||||
const includesUnretainedEvidence =
|
||||
options.partialObservation &&
|
||||
[partialObservation, completedSourceObservation].some(
|
||||
(observation) =>
|
||||
typeof observation === "object" &&
|
||||
observation !== null &&
|
||||
"kind" in observation &&
|
||||
observation.kind === "unretained-evidence",
|
||||
);
|
||||
const retentionGuidance =
|
||||
error instanceof AnalysisResourceConstraintError &&
|
||||
error.resource === "capacity" &&
|
||||
includesUnretainedEvidence
|
||||
? ` ${UNRETAINED_EVIDENCE_EXPORT_GUIDANCE}`
|
||||
: "";
|
||||
const publicationRemediation =
|
||||
callerLifetime === "continuing" &&
|
||||
publication?.evidence_retention === "retained"
|
||||
unknownPublication?.evidence_retention === "retained"
|
||||
? " Use get_evidence_bundle with the Evidence ID in details.workflow_unknown_publication to retrieve the completed result; use list_unknowns to inspect recorded questions. Publication stopped at the reported index; later questions were not checked in this attempt."
|
||||
: "";
|
||||
return {
|
||||
@@ -259,8 +524,8 @@ const projectError = (
|
||||
remediation: {
|
||||
action:
|
||||
cleanupRetry === undefined
|
||||
? `${remediation}${publicationRemediation}`
|
||||
: `${remediation} ${cleanupRetryRemediation(cleanupRetry)}${publicationRemediation}`,
|
||||
? `${remediation}${retentionGuidance}${publicationRemediation}`
|
||||
: `${remediation}${retentionGuidance} ${cleanupRetryRemediation(cleanupRetry)}${publicationRemediation}`,
|
||||
},
|
||||
...(recovery === undefined ? {} : { recovery }),
|
||||
...(Object.keys(details).length === 0 ? {} : { details }),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
createEvidence,
|
||||
evidenceEnvelopeForAdmission,
|
||||
type Evidence,
|
||||
type EvidenceCreationOptions,
|
||||
type EvidenceLocation,
|
||||
@@ -29,6 +30,41 @@ export interface AnalysisObservation {
|
||||
readonly subject?: EvidenceSubjectTarget | null;
|
||||
}
|
||||
|
||||
/** Build the single borrowed observation used for admission and commitment. */
|
||||
const analysisExecutionEvidenceInput = (
|
||||
operation: string,
|
||||
input: unknown,
|
||||
execution: AnalysisObservation,
|
||||
confidence: "observed" | "derived",
|
||||
) => ({
|
||||
operation,
|
||||
parameters: input,
|
||||
result: execution.result,
|
||||
rawResult: execution.rawResult,
|
||||
limitations: execution.limitations,
|
||||
...(execution.analysisProfile === undefined
|
||||
? {}
|
||||
: { analysisProfile: execution.analysisProfile }),
|
||||
...(execution.relationshipCoverage === undefined
|
||||
? {}
|
||||
: { relationshipCoverage: execution.relationshipCoverage }),
|
||||
locations: execution.locations,
|
||||
confidence,
|
||||
});
|
||||
|
||||
/** Borrow the Evidence envelope with links left empty for incremental admission accounting. */
|
||||
export const analysisExecutionEnvelopeForAdmission = (
|
||||
operation: string,
|
||||
input: unknown,
|
||||
execution: AnalysisObservation,
|
||||
confidence: "observed" | "derived",
|
||||
) =>
|
||||
evidenceEnvelopeForAdmission(
|
||||
execution.subject ?? undefined,
|
||||
execution.provider,
|
||||
analysisExecutionEvidenceInput(operation, input, execution, confidence),
|
||||
);
|
||||
|
||||
/** Commit identical facts on success and when retaining a completed observation after cancellation. */
|
||||
export const createAnalysisExecutionEvidence = (
|
||||
operation: string,
|
||||
@@ -38,26 +74,17 @@ export const createAnalysisExecutionEvidence = (
|
||||
options?: EvidenceCreationOptions,
|
||||
): Evidence =>
|
||||
createEvidence(
|
||||
execution.subject === undefined
|
||||
? undefined
|
||||
: (execution.subject ?? undefined),
|
||||
execution.subject ?? undefined,
|
||||
execution.provider,
|
||||
{
|
||||
operation,
|
||||
parameters: input,
|
||||
result: execution.result,
|
||||
rawResult: execution.rawResult,
|
||||
limitations: execution.limitations,
|
||||
...(execution.analysisProfile === undefined
|
||||
? {}
|
||||
: { analysisProfile: execution.analysisProfile }),
|
||||
...(execution.relationshipCoverage === undefined
|
||||
? {}
|
||||
: { relationshipCoverage: execution.relationshipCoverage }),
|
||||
locations: execution.locations,
|
||||
...analysisExecutionEvidenceInput(
|
||||
operation,
|
||||
input,
|
||||
execution,
|
||||
confidence,
|
||||
),
|
||||
evidenceLinks:
|
||||
execution.sourceEvidence?.map(({ evidence_id }) => evidence_id) ?? [],
|
||||
confidence,
|
||||
},
|
||||
options,
|
||||
);
|
||||
@@ -243,7 +243,9 @@ const summarizeBatch = (items: readonly BatchDecompileItem[]) => {
|
||||
};
|
||||
};
|
||||
|
||||
const batchUnknowns = (items: readonly BatchDecompileItem[]): string[] => {
|
||||
export const batchUnknowns = (
|
||||
items: readonly BatchDecompileItem[],
|
||||
): string[] => {
|
||||
const unknownIdentity = items.filter(
|
||||
({ procedure }) => procedure.status === "unknown",
|
||||
).length;
|
||||
|
||||
@@ -199,6 +199,10 @@ const selectRows = (source: readonly (Row | string)[], view: PageRequest) => {
|
||||
};
|
||||
|
||||
/** Query the owner's parsed historical observations without reopening the target. */
|
||||
/** A selected historical relocation row omitted its symbol field. */
|
||||
export const RELOCATION_SYMBOL_UNRECORDED =
|
||||
"The relocation symbol was not recorded for at least one selected row.";
|
||||
|
||||
export const projectNativeLoadImageView = (
|
||||
parent: AnalysisViewParent,
|
||||
admitted: AdmittedView,
|
||||
@@ -300,10 +304,7 @@ export const projectNativeLoadImageView = (
|
||||
return ok({
|
||||
...shared,
|
||||
unknowns: symbolUnrecorded
|
||||
? [
|
||||
...shared.unknowns,
|
||||
"The relocation symbol was not recorded for at least one selected row.",
|
||||
]
|
||||
? [...shared.unknowns, RELOCATION_SYMBOL_UNRECORDED]
|
||||
: shared.unknowns,
|
||||
kind: "native-load-image" as const,
|
||||
verification_status: report.status,
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from "./binaryTargetTypes.js";
|
||||
import {
|
||||
createEvidence,
|
||||
evidenceEnvelopeForAdmission,
|
||||
evidenceSchema,
|
||||
parseEvidence,
|
||||
immutableEvidence,
|
||||
@@ -895,3 +896,20 @@ it("diagnoses a previous explicit-null coverage commitment without admitting or
|
||||
});
|
||||
expect(parseEvidence(current)).toEqual(current);
|
||||
});
|
||||
|
||||
it("borrows variable-size collections for admission", () => {
|
||||
const limitations = ["one", "two"];
|
||||
const locations: never[] = [];
|
||||
const evidenceLinks = ["ev_" + "a".repeat(64)];
|
||||
const envelope = evidenceEnvelopeForAdmission(TARGET, PROVIDER, {
|
||||
operation: "borrow",
|
||||
parameters: {},
|
||||
result: {},
|
||||
limitations,
|
||||
locations,
|
||||
evidenceLinks,
|
||||
});
|
||||
expect(envelope.limitations).toBe(limitations);
|
||||
expect(envelope.locations).toBe(locations);
|
||||
expect(envelope.evidence_links).toBe(evidenceLinks);
|
||||
});
|
||||
+30
-26
@@ -35,6 +35,8 @@ import {
|
||||
|
||||
/** Predicate shared by ordinary provider analysis observations. */
|
||||
export const ANALYSIS_EVIDENCE_PREDICATE = "rea.analysis";
|
||||
export const MISSING_EVIDENCE_SUBJECT_LIMITATION =
|
||||
"Artifact identity is unavailable for this observation.";
|
||||
|
||||
/** Provider identity schema shared by evidence-bearing persistence formats. */
|
||||
export const providerSchema = z.strictObject({
|
||||
@@ -454,7 +456,8 @@ export function* createImmutableEvidenceSteps(
|
||||
});
|
||||
}
|
||||
|
||||
const normalizeEvidenceObservation = (
|
||||
/** Borrow input collections for admission; callers account for a missing-subject limitation separately. */
|
||||
export const evidenceEnvelopeForAdmission = (
|
||||
target: EvidenceSubjectTarget | BinaryTarget | undefined,
|
||||
provider: EvidenceProvider,
|
||||
observation: EvidenceObservationInput,
|
||||
@@ -469,15 +472,9 @@ const normalizeEvidenceObservation = (
|
||||
architecture: target.architecture ?? null,
|
||||
local_path: target.path,
|
||||
};
|
||||
const semantic = {
|
||||
subject:
|
||||
subject === null
|
||||
? null
|
||||
: {
|
||||
digest: subject.digest,
|
||||
format: subject.format,
|
||||
architecture: subject.architecture,
|
||||
},
|
||||
return {
|
||||
evidence_id: `ev_${"0".repeat(64)}`,
|
||||
subject,
|
||||
provider: {
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
@@ -492,21 +489,32 @@ const normalizeEvidenceObservation = (
|
||||
confidence: observation.confidence ?? "observed",
|
||||
authority: observation.authority ?? "shipped-artifact",
|
||||
environment: observation.environment ?? null,
|
||||
limitations: [
|
||||
...(target === undefined
|
||||
? [
|
||||
observation.subjectUnavailableReason ??
|
||||
"Artifact identity is unavailable for this observation.",
|
||||
]
|
||||
: []),
|
||||
...(observation.limitations ?? []),
|
||||
],
|
||||
limitations: observation.limitations ?? [],
|
||||
...(observation.relationshipCoverage === undefined
|
||||
? { relationship_coverage: null }
|
||||
: { relationship_coverage: observation.relationshipCoverage }),
|
||||
locations: [...(observation.locations ?? [])],
|
||||
evidence_links: [...(observation.evidenceLinks ?? [])],
|
||||
locations: observation.locations ?? [],
|
||||
evidence_links: observation.evidenceLinks ?? [],
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeEvidenceObservation = (
|
||||
target: EvidenceSubjectTarget | BinaryTarget | undefined,
|
||||
provider: EvidenceProvider,
|
||||
observation: EvidenceObservationInput,
|
||||
) => {
|
||||
const borrowed = evidenceEnvelopeForAdmission(target, provider, observation);
|
||||
const envelope =
|
||||
target === undefined
|
||||
? {
|
||||
...borrowed,
|
||||
limitations: [
|
||||
observation.subjectUnavailableReason ??
|
||||
MISSING_EVIDENCE_SUBJECT_LIMITATION,
|
||||
...borrowed.limitations,
|
||||
],
|
||||
}
|
||||
: borrowed;
|
||||
const sharedResult =
|
||||
typeof observation.result === "object" &&
|
||||
observation.result !== null &&
|
||||
@@ -514,11 +522,7 @@ const normalizeEvidenceObservation = (
|
||||
const selectedSchema = sharedResult
|
||||
? evidenceSchema.safeExtend({ normalized_result: immutableResultSchema })
|
||||
: evidenceSchema;
|
||||
const normalized = selectedSchema.parse({
|
||||
...semantic,
|
||||
evidence_id: `ev_${"0".repeat(64)}`,
|
||||
subject,
|
||||
});
|
||||
const normalized = selectedSchema.parse(envelope);
|
||||
// The envelope has already been parsed into an independent snapshot. Only
|
||||
// its derived identifier changes here; parsing again clones the full payload
|
||||
// and recomputes the same digest while the previous snapshot is still live.
|
||||
|
||||
@@ -44,7 +44,10 @@ import {
|
||||
AnalysisResourceConstraintError,
|
||||
type AnalysisInputIssue,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import {
|
||||
projectAnalysisError,
|
||||
projectAnalysisErrorWithoutPartialObservation,
|
||||
} from "../domain/analysisErrorProjection.js";
|
||||
import { projectInputIssueGroupsWithinBudget } from "../domain/inputIssueProjection.js";
|
||||
import type { z } from "zod";
|
||||
import { createEvidence } from "../domain/evidence.js";
|
||||
@@ -936,7 +939,11 @@ class ErrorEvidenceTransport implements Transport {
|
||||
}
|
||||
: { retention: recorded === undefined ? "unavailable" : "failed" }),
|
||||
...(recorded !== undefined && !recorded.ok
|
||||
? { retention_error: projectAnalysisError(recorded.error) }
|
||||
? {
|
||||
retention_error: projectAnalysisErrorWithoutPartialObservation(
|
||||
recorded.error,
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
targetContent,
|
||||
|
||||
@@ -7,13 +7,16 @@ import type { BinaryOpenResult } from "../application/binary/BinarySessionPort.j
|
||||
import type { Evidence } from "../domain/evidence.js";
|
||||
import { safeParseEvidence } from "../domain/evidenceValidation.js";
|
||||
import {
|
||||
omitUnretainedEvidenceFromError,
|
||||
projectAnalysisError,
|
||||
unretainedEvidenceCandidates,
|
||||
type AnalysisErrorProjection,
|
||||
} from "../domain/analysisErrorProjection.js";
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import { NoBinaryOpenError } from "../domain/configurationErrors.js";
|
||||
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
|
||||
import type { Result } from "../domain/result.js";
|
||||
import { estimateJsonStorage } from "../application/JsonAuthenticationHeadroom.js";
|
||||
import { AnalysisResourceConstraintError } from "../domain/analysisErrorCore.js";
|
||||
import {
|
||||
MCP_TRANSPORT_EVIDENCE_DISCOVERY,
|
||||
@@ -31,6 +34,7 @@ import {
|
||||
MCP_RESULT_STRING_LIMIT,
|
||||
NODE_MAX_STRING_LENGTH,
|
||||
TARGET_CONTENT_RESULT_META,
|
||||
type ToolResultEncoding,
|
||||
} from "./toolResultEncoding.js";
|
||||
|
||||
/** Project a response-budget refusal without labeling REA's reserved string limit as Node's ceiling. */
|
||||
@@ -128,12 +132,17 @@ export class ToolResultDelivery {
|
||||
result: Result<JsonValue, AnalysisError>,
|
||||
contract: ToolContract,
|
||||
recovery: Readonly<Record<string, JsonValue>> = {},
|
||||
alternatives?: (
|
||||
failure: Extract<ToolResultEncoding, { readonly ok: false }>,
|
||||
budgetBytes: number,
|
||||
) => Iterable<JsonValue>,
|
||||
): CallToolResult {
|
||||
return result.ok
|
||||
? this.successResult(
|
||||
contract.kind === "session" ? { result: result.value } : result.value,
|
||||
contract,
|
||||
recovery,
|
||||
alternatives,
|
||||
)
|
||||
: this.toErrorToolResult(result.error, contract.returnsTargetContent);
|
||||
}
|
||||
@@ -298,8 +307,22 @@ export class ToolResultDelivery {
|
||||
const partial = safeParseEvidence(error.partialObservation);
|
||||
if (!partial.ok) return this.toErrorToolResult(error, targetContent);
|
||||
const recorded = recordEvidence(partial.value);
|
||||
if (!recorded.ok) return this.toErrorToolResult(error, targetContent);
|
||||
const projected = projectAnalysisError(error);
|
||||
if (!recorded.ok)
|
||||
return this.projectedErrorResult(
|
||||
{
|
||||
...projected,
|
||||
details: {
|
||||
...projected.details,
|
||||
partial_observation: {
|
||||
kind: "inline-evidence",
|
||||
retention: "unknown",
|
||||
evidence: partial.value,
|
||||
},
|
||||
},
|
||||
},
|
||||
targetContent,
|
||||
);
|
||||
return this.projectedErrorResult(
|
||||
{
|
||||
...projected,
|
||||
@@ -319,15 +342,53 @@ export class ToolResultDelivery {
|
||||
projected: AnalysisErrorProjection,
|
||||
targetContent: boolean,
|
||||
): CallToolResult {
|
||||
const structuredContent = { error: projected };
|
||||
let structuredContent = { error: projected };
|
||||
// Errors reach the wire as text only; the structured copy is a private
|
||||
// carrier the transport removes after its own budget check.
|
||||
const encoded = encodeToolResult(
|
||||
let encoded = encodeToolResult(
|
||||
structuredContent,
|
||||
this.resultBudgetBytes,
|
||||
"error-text",
|
||||
targetContent,
|
||||
);
|
||||
let candidateProjection = projected;
|
||||
while (!encoded.ok) {
|
||||
const limit = encoded;
|
||||
const candidates = [...unretainedEvidenceCandidates(candidateProjection)]
|
||||
.map((candidate) => ({
|
||||
candidate,
|
||||
size: estimateJsonStorage(candidate.value, {
|
||||
maximumValueBytes: this.resultBudgetBytes,
|
||||
includeSerialized: false,
|
||||
}).valueBytes,
|
||||
}))
|
||||
.sort((left, right) => right.size - left.size);
|
||||
let next: AnalysisErrorProjection | undefined;
|
||||
for (const { candidate } of candidates) {
|
||||
const compact = omitUnretainedEvidenceFromError(
|
||||
candidateProjection,
|
||||
limit,
|
||||
candidate,
|
||||
);
|
||||
if (compact === undefined) continue;
|
||||
next ??= compact;
|
||||
const content = { error: compact };
|
||||
const compactEncoded = encodeToolResult(
|
||||
content,
|
||||
this.resultBudgetBytes,
|
||||
"error-text",
|
||||
targetContent,
|
||||
);
|
||||
if (compactEncoded.ok) {
|
||||
structuredContent = content;
|
||||
encoded = compactEncoded;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (encoded.ok) break;
|
||||
if (next === undefined) break;
|
||||
candidateProjection = next;
|
||||
}
|
||||
// The transport retains an oversized projected error before replacing it
|
||||
// with a recoverable delivery constraint, using this same budget.
|
||||
return {
|
||||
@@ -342,13 +403,31 @@ export class ToolResultDelivery {
|
||||
value: JsonValue,
|
||||
contract: ToolContract,
|
||||
recovery: Readonly<Record<string, JsonValue>> = {},
|
||||
alternatives?: (
|
||||
failure: Extract<ToolResultEncoding, { readonly ok: false }>,
|
||||
budgetBytes: number,
|
||||
) => Iterable<JsonValue>,
|
||||
): CallToolResult {
|
||||
const encoded = encodeToolResult(
|
||||
let encoded = encodeToolResult(
|
||||
value,
|
||||
this.resultBudgetBytes,
|
||||
"structured-and-text",
|
||||
contract.returnsTargetContent,
|
||||
);
|
||||
if (!encoded.ok && alternatives !== undefined)
|
||||
for (const candidate of alternatives(encoded, this.resultBudgetBytes)) {
|
||||
const candidateEncoding = encodeToolResult(
|
||||
candidate,
|
||||
this.resultBudgetBytes,
|
||||
"structured-and-text",
|
||||
contract.returnsTargetContent,
|
||||
);
|
||||
if (candidateEncoding.ok) {
|
||||
value = candidate;
|
||||
encoded = candidateEncoding;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (encoded.ok)
|
||||
return {
|
||||
content: [{ type: "text", text: encoded.text }],
|
||||
@@ -369,7 +448,9 @@ export class ToolResultDelivery {
|
||||
? "Call list_unknowns on this same connection with the same filters and offset, using a smaller limit, then follow next_offset. If a single unknown cannot fit, export the session with export_evidence_bundle to recover it."
|
||||
: contract.name === "get_evidence_bundle"
|
||||
? evidenceBundleOverflowStep(value)
|
||||
: "Use complete CLI JSON output to consume the analysis.";
|
||||
: contract.name === "inspect_address_context"
|
||||
? "Call get_evidence_bundle with detail summary to discover retained facet observations, then export_evidence_bundle to inspect complete records. Some facets may not have been retained; a new analysis can differ."
|
||||
: "Use complete CLI JSON output to consume the analysis.";
|
||||
const projected = mcpResponseOverflowProjection({
|
||||
completed: true,
|
||||
constraint: encoded.constraint,
|
||||
|
||||
@@ -352,6 +352,9 @@ export const createServer = (
|
||||
recordUnknown,
|
||||
evidenceById,
|
||||
withAdmittedAnalysis: analysisAdmission,
|
||||
...(session === undefined
|
||||
? {}
|
||||
: { admitRecovery: session.admitRecoveryObservation.bind(session) }),
|
||||
};
|
||||
const evidenceOptions = {
|
||||
logger: toolLogger,
|
||||
@@ -369,6 +372,12 @@ export const createServer = (
|
||||
enhanced: () =>
|
||||
registerEnhancedTools(server, {
|
||||
...analysisOptions,
|
||||
...(session === undefined
|
||||
? {}
|
||||
: { evidenceForAnalysis: session.evidenceForAnalysis.bind(session) }),
|
||||
...(session === undefined
|
||||
? {}
|
||||
: { admitRecovery: session.admitRecoveryObservation.bind(session) }),
|
||||
analysisProfile:
|
||||
session === undefined
|
||||
? undefined
|
||||
|
||||
@@ -43,6 +43,7 @@ export const registerCompareJavaScriptExportShapesTool = (
|
||||
const recorded = recordEvidenceSources(
|
||||
options.recordEvidence,
|
||||
result.value.sources,
|
||||
{ evidence: result.value.evidence },
|
||||
);
|
||||
if (!recorded.ok)
|
||||
return server.delivery.toCallToolResult(recorded, contract);
|
||||
|
||||
@@ -43,6 +43,7 @@ export const registerCompareApplicationVersionsTool = (
|
||||
const recorded = recordEvidenceSources(
|
||||
options.recordEvidence,
|
||||
result.value.sources,
|
||||
{ evidence: result.value.evidence },
|
||||
);
|
||||
if (!recorded.ok)
|
||||
return server.delivery.toCallToolResult(recorded, compareContract);
|
||||
|
||||
@@ -72,6 +72,7 @@ export const deliverApplicationEvidence = async (
|
||||
const sources = recordEvidenceSources(
|
||||
options.recordEvidence,
|
||||
execution.sources,
|
||||
{ evidence: selected.value },
|
||||
);
|
||||
if (!sources.ok) return delivery.toCallToolResult(sources, contract);
|
||||
const saved = recordApplicationResult(options, parent, unknownKind);
|
||||
|
||||
@@ -48,6 +48,7 @@ export const registerReconstructionObligationLedgerTool = (
|
||||
const recorded = recordEvidenceSources(
|
||||
options.recordEvidence,
|
||||
resolved.value.sources.bundle.records,
|
||||
{ evidence: result.value },
|
||||
);
|
||||
if (!recorded.ok)
|
||||
return server.delivery.toCallToolResult(recorded, contract);
|
||||
|
||||
@@ -79,6 +79,7 @@ const registerProjection = (context: {
|
||||
const recordedSources = recordEvidenceSources(
|
||||
options.recordEvidence,
|
||||
result.value.sources,
|
||||
{ evidence: result.value.evidence },
|
||||
);
|
||||
if (!recordedSources.ok)
|
||||
return server.delivery.toCallToolResult(recordedSources, contract);
|
||||
|
||||
@@ -41,6 +41,7 @@ export const registerTraceJavaScriptSemanticsTool = (
|
||||
const recorded = recordEvidenceSources(
|
||||
options.recordEvidence,
|
||||
result.value.sources,
|
||||
{ evidence: result.value.evidence },
|
||||
);
|
||||
if (!recorded.ok)
|
||||
return server.delivery.toCallToolResult(recorded, contract);
|
||||
|
||||
@@ -58,6 +58,10 @@ export const registerArtifactComparisonTool = (
|
||||
const recordedSources = recordEvidenceSources(
|
||||
(source) => session.recordEvidence(source),
|
||||
[left, right],
|
||||
{
|
||||
evidence,
|
||||
evidenceForAnalysis: session.evidenceForAnalysis.bind(session),
|
||||
},
|
||||
);
|
||||
if (!recordedSources.ok)
|
||||
return server.delivery.toCallToolResult(recordedSources, contract);
|
||||
|
||||
@@ -149,6 +149,7 @@ export const registerElectronTools = (
|
||||
const recorded = recordEvidenceSources(
|
||||
options.recordEvidence,
|
||||
result.value.sources,
|
||||
{ evidence: result.value.evidence },
|
||||
);
|
||||
return recorded.ok
|
||||
? { ok: true, value: result.value.evidence }
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Evidence } from "../domain/evidence.js";
|
||||
import type { ToolResultDelivery } from "./ToolResultDelivery.js";
|
||||
import type { EvidenceMcpServer } from "./EvidenceMcpServer.js";
|
||||
import type {
|
||||
@@ -17,6 +18,7 @@ import type { ValidatedEnhancedCall } from "../application/EnhancedToolTypes.js"
|
||||
import {
|
||||
composedLimitationBudget,
|
||||
executeComposedWorkflow,
|
||||
retainComposedWorkflowSources,
|
||||
createWorkflowEvidence,
|
||||
retainPartialWorkflowEvidence,
|
||||
workflowSnapshotRecord,
|
||||
@@ -37,6 +39,7 @@ import { executeFunctionAnalysisEvidence } from "../application/FunctionAnalysis
|
||||
import type { WithAdmittedAnalysis } from "./analysisAdmission.js";
|
||||
import type { EvidenceLookup } from "../application/EvidenceInputResolver.js";
|
||||
import { retainAnalysisResultSources } from "../application/investigation/recordEvidenceSources.js";
|
||||
import type { RecoveryAdmission } from "../application/investigation/InvestigationRetention.js";
|
||||
import {
|
||||
requireAnalysisViewRetention,
|
||||
summarizeRetainedAnalysis,
|
||||
@@ -57,6 +60,8 @@ export interface EnhancedToolRegistration {
|
||||
) => AnalysisProfileCommitment | undefined)
|
||||
| undefined;
|
||||
readonly recordEvidence: EvidenceWriter["recordEvidence"] | undefined;
|
||||
readonly admitRecovery?: RecoveryAdmission;
|
||||
readonly evidenceForAnalysis?: (id: string) => Evidence | undefined;
|
||||
readonly evidenceById?: EvidenceLookup | undefined;
|
||||
readonly recordUnknown: UnknownRegistryPort["recordUnknown"] | undefined;
|
||||
readonly allowsSnapshotReplay:
|
||||
@@ -288,10 +293,17 @@ const executeEnhancedTool = async (
|
||||
parameters,
|
||||
upstreamProfile: registration.analysisProfile?.(name),
|
||||
});
|
||||
result = retainAnalysisResultSources(
|
||||
name,
|
||||
result = retainComposedWorkflowSources(
|
||||
registration.recordEvidence,
|
||||
result,
|
||||
{
|
||||
target: registration.activeTarget?.(),
|
||||
operation: name,
|
||||
parameters,
|
||||
upstreamProfile: registration.analysisProfile?.(name),
|
||||
},
|
||||
registration.evidenceForAnalysis,
|
||||
registration.admitRecovery,
|
||||
);
|
||||
const finish = async (
|
||||
response: CallToolResult,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { sessionEvidenceForExecution } from "../application/binary/SessionEvidenceReuse.js";
|
||||
import type { EvidenceMcpServer } from "./EvidenceMcpServer.js";
|
||||
import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js";
|
||||
import {
|
||||
@@ -12,6 +13,8 @@ import type {
|
||||
} from "../application/AnalysisProvider.js";
|
||||
import type { ToolContract } from "../contracts/toolContractTypes.js";
|
||||
import { ok } from "../domain/result.js";
|
||||
import { admitCompletedAnalysis } from "../application/investigation/admitCompletedAnalysis.js";
|
||||
import type { RecoveryAdmission } from "../application/investigation/InvestigationRetention.js";
|
||||
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
|
||||
import { createAnalysisExecutionEvidence } from "../domain/analysisObservation.js";
|
||||
import { jsonObjectSchema, type JsonValue } from "../domain/jsonValue.js";
|
||||
@@ -31,6 +34,7 @@ interface EvidenceToolRegistration {
|
||||
readonly activeTarget: (() => BinaryTarget | undefined) | undefined;
|
||||
readonly recordEvidence: EvidenceWriter["recordEvidence"] | undefined;
|
||||
readonly evidenceById?: EvidenceToolFamilyOptions["evidenceById"];
|
||||
readonly admitRecovery?: RecoveryAdmission;
|
||||
readonly withAdmittedAnalysis: WithAdmittedAnalysis;
|
||||
readonly analysisForAdmission?: (
|
||||
analysis: AnalysisOperationPort,
|
||||
@@ -90,26 +94,50 @@ export const registerEvidenceTools = (
|
||||
if (!execution.ok)
|
||||
return server.delivery.toCallToolResult(execution, contract);
|
||||
const sourceEvidence = execution.value.sourceEvidence ?? [];
|
||||
const evidence = createAnalysisExecutionEvidence(
|
||||
contract.name,
|
||||
contract.retainedSummary?.evidenceParameters === "omit-detail"
|
||||
? providerParameters
|
||||
: parameters,
|
||||
{
|
||||
...execution.value,
|
||||
subject:
|
||||
execution.value.subject === undefined
|
||||
? (options.activeTarget?.() ?? null)
|
||||
: execution.value.subject,
|
||||
},
|
||||
"observed",
|
||||
);
|
||||
const sourceRecorded = recordEvidenceSources(
|
||||
options.recordEvidence,
|
||||
sourceEvidence,
|
||||
);
|
||||
if (!sourceRecorded.ok)
|
||||
return server.delivery.toCallToolResult(sourceRecorded, contract);
|
||||
const evidenceParameters =
|
||||
contract.retainedSummary?.evidenceParameters === "omit-detail"
|
||||
? providerParameters
|
||||
: parameters;
|
||||
const observation = {
|
||||
...execution.value,
|
||||
subject:
|
||||
execution.value.subject === undefined
|
||||
? (options.activeTarget?.() ?? null)
|
||||
: execution.value.subject,
|
||||
};
|
||||
const reusable =
|
||||
options.evidenceById === undefined
|
||||
? undefined
|
||||
: sessionEvidenceForExecution(
|
||||
execution.value,
|
||||
contract.name,
|
||||
evidenceParameters,
|
||||
options.evidenceById,
|
||||
);
|
||||
if (reusable === undefined) {
|
||||
const admitted = admitCompletedAnalysis(
|
||||
contract.name,
|
||||
evidenceParameters,
|
||||
observation,
|
||||
options.admitRecovery,
|
||||
);
|
||||
if (!admitted.ok)
|
||||
return server.delivery.toCallToolResult(admitted, contract);
|
||||
}
|
||||
const evidence =
|
||||
reusable ??
|
||||
createAnalysisExecutionEvidence(
|
||||
contract.name,
|
||||
evidenceParameters,
|
||||
observation,
|
||||
"observed",
|
||||
);
|
||||
const projected =
|
||||
delivery?.projectEvidence(parameters, evidence) ?? ok(evidence);
|
||||
if (!projected.ok)
|
||||
|
||||
@@ -58,6 +58,10 @@ export const registerFunctionComparisonTool = (
|
||||
const retained = recordEvidenceSources(
|
||||
session.recordEvidence.bind(session),
|
||||
sources,
|
||||
{
|
||||
evidence,
|
||||
evidenceForAnalysis: session.evidenceForAnalysis.bind(session),
|
||||
},
|
||||
);
|
||||
if (!retained.ok)
|
||||
return server.delivery.toCallToolResult(retained, contract);
|
||||
|
||||
@@ -111,6 +111,10 @@ const registerInvestigationWorkflow = (
|
||||
const retained = recordEvidenceSources(
|
||||
session.recordEvidence.bind(session),
|
||||
sources,
|
||||
{
|
||||
evidence,
|
||||
evidenceForAnalysis: session.evidenceForAnalysis.bind(session),
|
||||
},
|
||||
);
|
||||
if (!retained.ok)
|
||||
return server.delivery.toCallToolResult(retained, contract);
|
||||
|
||||
@@ -43,6 +43,7 @@ export const registerCompareManagedMembers = (
|
||||
const recorded = recordEvidenceSources(
|
||||
options.recordEvidence,
|
||||
result.value.sources,
|
||||
{ evidence: result.value.evidence },
|
||||
);
|
||||
if (!recorded.ok)
|
||||
return server.delivery.toCallToolResult(recorded, compareContract);
|
||||
|
||||
@@ -52,6 +52,7 @@ export const registerProjectManagedApplicationGraph = (
|
||||
request.value.sources.members?.evidence,
|
||||
request.value.sources.boundaries?.evidence,
|
||||
].filter((source): source is Evidence => source !== undefined),
|
||||
{ evidence: result.value },
|
||||
);
|
||||
if (!recordedSources.ok)
|
||||
return server.delivery.toCallToolResult(
|
||||
|
||||
@@ -49,6 +49,7 @@ export const registerVerifyManagedNativeBoundaries = (
|
||||
const recordedSources = recordEvidenceSources(
|
||||
options.recordEvidence,
|
||||
execution.sourceEvidence,
|
||||
{ evidence: execution.evidence },
|
||||
);
|
||||
if (!recordedSources.ok)
|
||||
return server.delivery.toCallToolResult(
|
||||
|
||||
@@ -59,6 +59,10 @@ export const registerProcessComparisonTool = (
|
||||
const retained = recordEvidenceSources(
|
||||
session.recordEvidence.bind(session),
|
||||
sources,
|
||||
{
|
||||
evidence,
|
||||
evidenceForAnalysis: session.evidenceForAnalysis.bind(session),
|
||||
},
|
||||
);
|
||||
if (!retained.ok)
|
||||
return server.delivery.toCallToolResult(retained, contract);
|
||||
|
||||
@@ -8,6 +8,7 @@ import { workflowUnknownInput } from "../application/investigation/WorkflowUnkno
|
||||
import {
|
||||
getNavigationContext,
|
||||
inspectAddressContext,
|
||||
omitContextEvidenceForDelivery,
|
||||
} from "../application/AnalysisContextQueries.js";
|
||||
import type { BinarySessionPort } from "../application/binary/BinarySessionPort.js";
|
||||
import {
|
||||
@@ -323,11 +324,30 @@ const registerContextTools = (
|
||||
const admitted = await admission(
|
||||
addressContract.name,
|
||||
context.mcpReq.signal,
|
||||
async (analysis) =>
|
||||
server.delivery.toCallToolResult(
|
||||
await inspectAddressContext(analysis, input, context.mcpReq.signal),
|
||||
async (analysis) => {
|
||||
const result = await inspectAddressContext(
|
||||
analysis,
|
||||
input,
|
||||
context.mcpReq.signal,
|
||||
);
|
||||
if (!result.ok)
|
||||
return server.delivery.toCallToolResult(result, addressContract);
|
||||
const value = result.value;
|
||||
return server.delivery.toCallToolResult(
|
||||
result,
|
||||
addressContract,
|
||||
),
|
||||
{},
|
||||
(failure, budgetBytes) =>
|
||||
(function* () {
|
||||
for (const alternative of omitContextEvidenceForDelivery(
|
||||
value,
|
||||
failure,
|
||||
budgetBytes,
|
||||
))
|
||||
yield { result: alternative };
|
||||
})(),
|
||||
);
|
||||
},
|
||||
);
|
||||
return admitted.ok
|
||||
? admitted.value
|
||||
|
||||
@@ -55,10 +55,7 @@ const singleDefaultOptions: readonly {
|
||||
{ command: "inspect-signal-decoders", options: ["--offset"] },
|
||||
];
|
||||
|
||||
const optionLine = (
|
||||
help: string,
|
||||
option: string,
|
||||
): string | undefined => {
|
||||
const optionLine = (help: string, option: string): string | undefined => {
|
||||
const line = help
|
||||
.split(/\r?\n/u)
|
||||
.find((candidate) => candidate.trimStart().startsWith(`${option} `));
|
||||
@@ -80,7 +77,9 @@ describe("CLI help default annotations", () => {
|
||||
}
|
||||
const statements = line.match(/default/giu)?.length ?? 0;
|
||||
if (statements !== 1)
|
||||
repeated.push(`${command} ${option}: ${statements} default statements`);
|
||||
repeated.push(
|
||||
`${command} ${option}: ${statements} default statements`,
|
||||
);
|
||||
}
|
||||
}
|
||||
expect(repeated).toEqual([]);
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import { expect, it } from "vitest";
|
||||
import { createTestProviderRouter } from "../../fixtures/binarySession.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { createAnalysisExecution } from "../../../src/application/AnalysisProvider.js";
|
||||
import { BinarySession } from "../../../src/application/binary/BinarySession.js";
|
||||
import { InvestigationRecords } from "../../../src/application/investigation/InvestigationRecords.js";
|
||||
import { createAnalysisProfile } from "../../../src/domain/analysisProfile.js";
|
||||
import { err, ok } from "../../../src/domain/result.js";
|
||||
import { AnalysisCapabilityUnavailableError } from "../../../src/domain/analysisErrorCore.js";
|
||||
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
|
||||
import { SESSION_TOOL_CONTRACTS } from "../../../src/contracts/sessionToolContracts.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
|
||||
it.each(["compact", "full"] as const)(
|
||||
"preserves retained siblings and capacity recovery (%s schema)",
|
||||
async (profile) => {
|
||||
const identity = {
|
||||
id: "fixture",
|
||||
name: "Fixture analysis provider",
|
||||
version: "1",
|
||||
};
|
||||
const directory = await createTestTempDirectory("rea-context-capacity-");
|
||||
const target = join(directory, "fixture.com");
|
||||
await writeFile(target, Buffer.from([0xb8, 0, 0x4c, 0xcd, 0x21]));
|
||||
const router = createTestProviderRouter(
|
||||
() => ({
|
||||
execute: async (operation) => {
|
||||
if (operation === "address_name")
|
||||
return ok(createAnalysisExecution("entry", identity));
|
||||
if (operation === "resolve_containing_procedure")
|
||||
return ok(
|
||||
createAnalysisExecution(
|
||||
{
|
||||
query_address: "0x100",
|
||||
found: true,
|
||||
procedure: {
|
||||
address: "0x100",
|
||||
name: "f".repeat(16000),
|
||||
classification: null,
|
||||
body: { available: false, reason: "not recorded" },
|
||||
},
|
||||
},
|
||||
identity,
|
||||
),
|
||||
);
|
||||
return err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
identity.id,
|
||||
operation,
|
||||
"not provided",
|
||||
),
|
||||
);
|
||||
},
|
||||
close: async () => ok(null),
|
||||
}),
|
||||
{
|
||||
resolveAnalysisProfile: async () =>
|
||||
ok({ profile: createAnalysisProfile(identity, { fixture: true }) }),
|
||||
},
|
||||
);
|
||||
const records = new InvestigationRecords({
|
||||
maximumRepresentationBytes: 8000,
|
||||
});
|
||||
const session = new BinarySession(router, records);
|
||||
const server = createServer(
|
||||
{ kind: "session", session },
|
||||
{
|
||||
environment: { ...process.env, REA_MCP_OUTPUT_SCHEMA_PROFILE: profile },
|
||||
},
|
||||
);
|
||||
const client = new Client({ name: "context-capacity", version: "1" });
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
try {
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
const definitions = new Map(
|
||||
(await client.listTools()).tools.map((tool) => [tool.name, tool]),
|
||||
);
|
||||
const call = (name: string, args: Record<string, string>) => {
|
||||
const toolDefinition = definitions.get(name);
|
||||
if (toolDefinition === undefined)
|
||||
throw new Error(`Tool ${name} was not advertised`);
|
||||
return client.callTool({ name, arguments: args }, { toolDefinition });
|
||||
};
|
||||
expect(
|
||||
(
|
||||
await call("open_binary", {
|
||||
path: target,
|
||||
format: "dos-com",
|
||||
provider_id: "fixture",
|
||||
})
|
||||
).isError,
|
||||
).not.toBe(true);
|
||||
const arguments_ = { document: "fixture", address: "0x100" };
|
||||
const response = await call("inspect_address_context", arguments_);
|
||||
expect(response.isError).not.toBe(true);
|
||||
const contract = SESSION_TOOL_CONTRACTS.find(
|
||||
(
|
||||
candidate,
|
||||
): candidate is Extract<
|
||||
(typeof SESSION_TOOL_CONTRACTS)[number],
|
||||
{ readonly name: "inspect_address_context" }
|
||||
> => candidate.name === "inspect_address_context",
|
||||
);
|
||||
if (contract === undefined)
|
||||
throw new Error("inspect_address_context contract is missing");
|
||||
const context = contract.outputSchema.parse(
|
||||
response.structuredContent,
|
||||
).result;
|
||||
const direct = await call("resolve_containing_procedure", arguments_);
|
||||
expect(direct.isError).toBe(true);
|
||||
const expected = parseMcpToolError(direct).error;
|
||||
expect(expected.code).toBe("resource_constraint");
|
||||
expect(expected.details).toMatchObject({
|
||||
partial_observation: {
|
||||
kind: "unretained-evidence",
|
||||
evidence: {
|
||||
operation: "resolve_containing_procedure",
|
||||
normalized_result: { found: true, query_address: "0x100" },
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(context.name).toEqual({ state: "available", value: "entry" });
|
||||
expect(context.procedure).toEqual({
|
||||
state: "unavailable",
|
||||
reason: expected.message,
|
||||
remediation: expected.remediation.action,
|
||||
error: expected,
|
||||
});
|
||||
expect(records.statistics().evidenceRecords).toBe(1);
|
||||
expect(session.immutableEvidenceBundle().records[0]).toMatchObject({
|
||||
operation: "address_name",
|
||||
normalized_result: "entry",
|
||||
});
|
||||
} finally {
|
||||
await client.close();
|
||||
await server.close();
|
||||
await session.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -5,6 +5,7 @@ import type { CallToolResult } from "@modelcontextprotocol/server";
|
||||
import { afterAll, expect, it, onTestFinished, vi } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { ANALYSIS_VIEW_TOOL_CONTRACTS } from "../../../src/contracts/analysisViewToolContracts.js";
|
||||
import { batchUnknowns } from "../../../src/domain/analysisView/batchDecompileView.js";
|
||||
import { analysisViewResultSchema } from "../../../src/domain/analysisView/analysisView.js";
|
||||
import { AnalysisOutputError } from "../../../src/domain/analysisErrorCore.js";
|
||||
import {
|
||||
@@ -264,10 +265,9 @@ it.each(["full", "compact"] as const)(
|
||||
},
|
||||
});
|
||||
expect(summary.unknowns).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining("1 items have unknown procedure identity"),
|
||||
expect.stringContaining("not attributed to one successful item"),
|
||||
]),
|
||||
batchUnknowns(
|
||||
batchDecompileResultSchema.parse(parent.normalized_result).items,
|
||||
),
|
||||
);
|
||||
const callsAfterBatch = harness.calls.count;
|
||||
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import type { CallToolResult } from "@modelcontextprotocol/server";
|
||||
import { access, mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { expect, it, vi } from "vitest";
|
||||
import { createAnalysisExecution } from "../../../src/application/AnalysisProvider.js";
|
||||
import { BinarySession } from "../../../src/application/binary/BinarySession.js";
|
||||
import { InvestigationRecords } from "../../../src/application/investigation/InvestigationRecords.js";
|
||||
import { createEvidence } from "../../../src/domain/evidence.js";
|
||||
import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js";
|
||||
import { err, ok } from "../../../src/domain/result.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
import { createTestProviderRouter } from "../../fixtures/binarySession.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
|
||||
import { awaitAcknowledgement } from "../../fixtures/lifecycleAcknowledgement.js";
|
||||
|
||||
const expectRecoveredBatch = (response: CallToolResult, owned: string) => {
|
||||
expect(parseMcpToolError(response).error).toMatchObject({
|
||||
code: "resource_constraint",
|
||||
details: {
|
||||
completed_observation: {
|
||||
evidence: {
|
||||
normalized_result: {
|
||||
items: [
|
||||
{ status: "ok" },
|
||||
{
|
||||
status: "error",
|
||||
error: {
|
||||
code: "cleanup_incomplete",
|
||||
details: { resources: [owned] },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
it("keeps a mixed batch cleanup owner after source retention fails and retries it", async () => {
|
||||
const root = await createTestTempDirectory("rea-batch-owned-cleanup-");
|
||||
const target = join(root, "fixture.com"),
|
||||
owned = join(root, "owned");
|
||||
await writeFile(target, Buffer.from([0xb8, 0, 0x4c, 0xcd, 0x21]));
|
||||
const provider = { id: "fixture", name: "Fixture", version: "1" };
|
||||
const source = createEvidence(undefined, provider, {
|
||||
operation: "health",
|
||||
parameters: {},
|
||||
result: "source",
|
||||
});
|
||||
const failure = new ProviderCleanupError(provider.id, [owned], {
|
||||
reason: "controlled cleanup failure",
|
||||
});
|
||||
let count = 0,
|
||||
pending = false,
|
||||
allow = false,
|
||||
closes = 0;
|
||||
let entered!: () => void;
|
||||
let release!: () => void;
|
||||
const closing = new Promise<void>((resolve) => {
|
||||
entered = resolve;
|
||||
});
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const session = new BinarySession(
|
||||
createTestProviderRouter(() => ({
|
||||
execute: async (operation) => {
|
||||
if (operation === "procedure_pseudo_code" && ++count === 2) {
|
||||
await mkdir(owned);
|
||||
pending = true;
|
||||
return err(failure);
|
||||
}
|
||||
return ok(
|
||||
createAnalysisExecution(
|
||||
operation === "procedure_address"
|
||||
? "0x100"
|
||||
: operation === "address_name"
|
||||
? "example"
|
||||
: operation === "health"
|
||||
? null
|
||||
: "int example(void) { return 1; }",
|
||||
provider,
|
||||
),
|
||||
);
|
||||
},
|
||||
close: async () => {
|
||||
closes++;
|
||||
if (!pending) return ok(null);
|
||||
if (closes === 2) {
|
||||
entered();
|
||||
await gate;
|
||||
return err(failure);
|
||||
}
|
||||
if (!allow) return err(failure);
|
||||
await rm(owned, { recursive: true });
|
||||
pending = false;
|
||||
failure.resolveCleanup();
|
||||
return ok(null);
|
||||
},
|
||||
})),
|
||||
new InvestigationRecords({ maximumRepresentationBytes: 0 }),
|
||||
);
|
||||
const admit = session.withAdmittedAnalysis.bind(session);
|
||||
vi.spyOn(session, "withAdmittedAnalysis").mockImplementation(
|
||||
(operation, signal, run) =>
|
||||
admit(operation, signal, (analysis) =>
|
||||
run({
|
||||
...analysis,
|
||||
execute: async (...args) => {
|
||||
const result = await analysis.execute(...args);
|
||||
if (args[0] === "procedure_pseudo_code" && result.ok)
|
||||
return {
|
||||
...result,
|
||||
value: { ...result.value, sourceEvidence: [source] },
|
||||
};
|
||||
return result;
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
const server = createServer({ kind: "session", session });
|
||||
const client = new Client({ name: "batch-owned-cleanup", version: "1" });
|
||||
const [ct, st] = InMemoryTransport.createLinkedPair();
|
||||
try {
|
||||
await server.connect(st);
|
||||
await client.connect(ct);
|
||||
const call = (name: string, args: Record<string, unknown> = {}) =>
|
||||
client.callTool({ name, arguments: args }, { timeout: 120000 });
|
||||
expect(
|
||||
(await call("open_binary", { path: target, format: "dos-com" })).isError,
|
||||
).not.toBe(true);
|
||||
const response = await call("batch_decompile", {
|
||||
addresses: ["first", "second"],
|
||||
});
|
||||
expectRecoveredBatch(response, owned);
|
||||
expect(pending).toBe(true);
|
||||
expect(failure.cleanupIncomplete).toBe(true);
|
||||
await expect(access(owned)).resolves.toBeUndefined();
|
||||
const close1 = await call("close_binary");
|
||||
expect(close1.isError).toBe(true);
|
||||
expect(parseMcpToolError(close1).error).toMatchObject({
|
||||
code: "cleanup_incomplete",
|
||||
recovery: { kind: "retry_cleanup", operation: "close_binary" },
|
||||
details: { resources: [owned] },
|
||||
});
|
||||
expect(pending).toBe(true);
|
||||
const controller = new AbortController();
|
||||
const cancelledClose = client.callTool(
|
||||
{ name: "close_binary", arguments: {} },
|
||||
{ signal: controller.signal, timeout: 120000 },
|
||||
);
|
||||
try {
|
||||
await awaitAcknowledgement(closing, cancelledClose, "final cleanup");
|
||||
controller.abort();
|
||||
await expect(cancelledClose).rejects.toThrow();
|
||||
} finally {
|
||||
controller.abort();
|
||||
release();
|
||||
await Promise.allSettled([cancelledClose]);
|
||||
}
|
||||
allow = true;
|
||||
const close2 = await call("close_binary");
|
||||
expect(close2.isError).not.toBe(true);
|
||||
expect(pending).toBe(false);
|
||||
expect(failure.cleanupIncomplete).toBe(false);
|
||||
expect(closes).toBe(3);
|
||||
await expect(access(owned)).rejects.toThrow();
|
||||
} finally {
|
||||
allow = true;
|
||||
release();
|
||||
await client.close();
|
||||
await server.close();
|
||||
await session.close();
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import pino from "pino";
|
||||
import { expect, it, onTestFinished } from "vitest";
|
||||
import {
|
||||
createAnalysisExecution,
|
||||
type AnalysisOperationPort,
|
||||
} from "../../../src/application/AnalysisProvider.js";
|
||||
import { EvidenceLedger } from "../../../src/application/investigation/EvidenceLedger.js";
|
||||
import { createEvidence } from "../../../src/domain/evidence.js";
|
||||
import { AnalysisResourceConstraintError } from "../../../src/domain/analysisErrorCore.js";
|
||||
import { err, ok } from "../../../src/domain/result.js";
|
||||
import { EvidenceMcpServer } from "../../../src/server/EvidenceMcpServer.js";
|
||||
import { registerEnhancedTools } from "../../../src/server/registerEnhancedTools.js";
|
||||
import { withAdmittedAnalysis } from "../../../src/server/analysisAdmission.js";
|
||||
import { createToolResultDelivery } from "../../../src/server/toolResult.js";
|
||||
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
|
||||
const provider = { id: "fixture", name: "Fixture", version: "1" };
|
||||
const source = createEvidence(undefined, provider, {
|
||||
operation: "health",
|
||||
parameters: {},
|
||||
result: "source",
|
||||
});
|
||||
it.each([
|
||||
{ size: 100, denyAdmission: false, ownerKnown: true },
|
||||
{ size: 100_000, denyAdmission: false, ownerKnown: true },
|
||||
{ size: 100, denyAdmission: true, ownerKnown: true },
|
||||
{ size: 100, denyAdmission: false, ownerKnown: false },
|
||||
])(
|
||||
"preserves capacity error with size $size, denied admission $denyAdmission, and known owner $ownerKnown",
|
||||
async ({ size, denyAdmission, ownerKnown }) => {
|
||||
const ledger = new EvidenceLedger({ maximumRepresentationBytes: 0 });
|
||||
const record = (evidence: typeof source) => ledger.record(evidence);
|
||||
const analysis: AnalysisOperationPort = {
|
||||
execute: async (operation) => {
|
||||
return ok(
|
||||
createAnalysisExecution(
|
||||
operation === "procedure_address"
|
||||
? "0x1000"
|
||||
: operation === "address_name"
|
||||
? "example"
|
||||
: "x".repeat(size),
|
||||
provider,
|
||||
operation === "procedure_pseudo_code"
|
||||
? { sourceEvidence: [source] }
|
||||
: {},
|
||||
),
|
||||
);
|
||||
},
|
||||
};
|
||||
const server = new EvidenceMcpServer(
|
||||
{ name: "batch-overflow", version: "1" },
|
||||
{},
|
||||
record,
|
||||
createToolResultDelivery(10 * 1024),
|
||||
);
|
||||
registerEnhancedTools(server, {
|
||||
withAdmittedAnalysis: withAdmittedAnalysis({ kind: "fixed", analysis }),
|
||||
logger: pino({ enabled: false }),
|
||||
activeTarget: () => undefined,
|
||||
analysisProfile: undefined,
|
||||
recordEvidence: record,
|
||||
...(ownerKnown ? { hasEvidence: (id: string) => ledger.has(id) } : {}),
|
||||
...(denyAdmission
|
||||
? {
|
||||
admitRecovery: () =>
|
||||
err(
|
||||
new AnalysisResourceConstraintError(
|
||||
"batch_decompile",
|
||||
"memory",
|
||||
"no headroom",
|
||||
null,
|
||||
),
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
evidenceById: ownerKnown ? (id) => ledger.forAnalysis(id) : () => source,
|
||||
recordUnknown: undefined,
|
||||
allowsSnapshotReplay: undefined,
|
||||
recordWorkflowSnapshot: undefined,
|
||||
});
|
||||
const client = new Client({ name: "batch-overflow-test", version: "1" });
|
||||
onTestFinished(async () => {
|
||||
await client.close();
|
||||
await server.close();
|
||||
});
|
||||
const [ct, st] = InMemoryTransport.createLinkedPair();
|
||||
await server.connect(st);
|
||||
await client.connect(ct);
|
||||
const response = await client.callTool({
|
||||
name: "batch_decompile",
|
||||
arguments: { addresses: ["example"] },
|
||||
});
|
||||
expect(response.isError).toBe(true);
|
||||
const error = parseMcpToolError(response).error;
|
||||
expect(error).toMatchObject({
|
||||
code: "resource_constraint",
|
||||
details: { partial_observation: { kind: "unretained-evidence" } },
|
||||
});
|
||||
if (denyAdmission)
|
||||
expect(error.details).toMatchObject({
|
||||
completed_observation_omitted: {
|
||||
reason: "insufficient-authentication-headroom",
|
||||
source_closure: "not-established",
|
||||
},
|
||||
});
|
||||
else if (!ownerKnown)
|
||||
expect(error.details).toMatchObject({
|
||||
completed_observation: {
|
||||
kind: "inline-evidence",
|
||||
retention: "unknown",
|
||||
source_closure: "not-established",
|
||||
evidence: { operation: "batch_decompile" },
|
||||
},
|
||||
});
|
||||
else if (size === 100)
|
||||
expect(error.details).toMatchObject({
|
||||
completed_observation: {
|
||||
kind: "unretained-evidence",
|
||||
source_closure: "not-established",
|
||||
evidence: { operation: "batch_decompile" },
|
||||
},
|
||||
});
|
||||
else
|
||||
expect(error.details).toMatchObject({
|
||||
completed_observation_omitted: {
|
||||
reason: "mcp-response-limit",
|
||||
source_closure: "not-established",
|
||||
},
|
||||
});
|
||||
expect(ledger.statistics().evidenceRecords).toBe(0);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,331 @@
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import { expect, it } from "vitest";
|
||||
import { createTestProviderRouter } from "../../fixtures/binarySession.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
|
||||
import { createAnalysisExecution } from "../../../src/application/AnalysisProvider.js";
|
||||
import { BinarySession } from "../../../src/application/binary/BinarySession.js";
|
||||
import { InvestigationRecords } from "../../../src/application/investigation/InvestigationRecords.js";
|
||||
import { createAnalysisProfile } from "../../../src/domain/analysisProfile.js";
|
||||
import { err, ok } from "../../../src/domain/result.js";
|
||||
import { AnalysisCapabilityUnavailableError } from "../../../src/domain/analysisErrorCore.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
|
||||
const verifyContext = (
|
||||
content: unknown,
|
||||
twoLarge: boolean,
|
||||
session: BinarySession,
|
||||
): void => {
|
||||
expect(content).toMatchObject({
|
||||
result: {
|
||||
name: { state: "available", value: "entry" },
|
||||
procedure: {
|
||||
state: "unavailable",
|
||||
error: {
|
||||
code: "resource_constraint",
|
||||
details: {
|
||||
partial_observation_omitted: {
|
||||
reason: "mcp-response-limit",
|
||||
retention: "unretained",
|
||||
original_response_bytes_at_least: expect.any(Number),
|
||||
evidence_id: expect.stringMatching(/^ev_[a-f0-9]{64}$/u),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const procedureDetails = (
|
||||
content as {
|
||||
result: {
|
||||
procedure: {
|
||||
error: {
|
||||
details: {
|
||||
partial_observation_omitted: { evidence_id: string };
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
).result.procedure.error.details;
|
||||
expect(procedureDetails).not.toHaveProperty("partial_observation");
|
||||
expect(procedureDetails).not.toHaveProperty("evidence_reference");
|
||||
expect(
|
||||
session.hasEvidence(
|
||||
procedureDetails.partial_observation_omitted.evidence_id,
|
||||
),
|
||||
).toBe(false);
|
||||
const smallFacet = twoLarge ? "inline_comment" : "comment";
|
||||
expect(content).toMatchObject({
|
||||
result: {
|
||||
[smallFacet]: {
|
||||
state: "unavailable",
|
||||
error: {
|
||||
code: "resource_constraint",
|
||||
details: {
|
||||
partial_observation: {
|
||||
kind: "unretained-evidence",
|
||||
evidence: {
|
||||
operation: smallFacet,
|
||||
normalized_result: "short note",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
...(twoLarge
|
||||
? {
|
||||
comment: {
|
||||
state: "unavailable",
|
||||
error: {
|
||||
details: {
|
||||
partial_observation_omitted: {
|
||||
reason: "mcp-response-limit",
|
||||
retention: "unretained",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
it.each([
|
||||
["compact", false],
|
||||
["full", false],
|
||||
["compact", true],
|
||||
] as const)(
|
||||
"keeps context and smaller partials when unretained observations exceed the MCP budget (%s, two large=%s)",
|
||||
async (profile, twoLarge) => {
|
||||
const identity = {
|
||||
id: "fixture",
|
||||
name: "Fixture analysis provider",
|
||||
version: "1",
|
||||
};
|
||||
const directory = await createTestTempDirectory("rea-context-capacity-");
|
||||
const target = join(directory, "fixture.com");
|
||||
await writeFile(target, Buffer.from([0xb8, 0, 0x4c, 0xcd, 0x21]));
|
||||
const router = createTestProviderRouter(
|
||||
() => ({
|
||||
execute: async (operation) => {
|
||||
if (operation === "address_name")
|
||||
return ok(createAnalysisExecution("entry", identity));
|
||||
if (operation === "comment")
|
||||
return ok(
|
||||
createAnalysisExecution(
|
||||
twoLarge ? "g".repeat(6 * 1024 * 1024) : "short note",
|
||||
identity,
|
||||
),
|
||||
);
|
||||
if (operation === "inline_comment" && twoLarge)
|
||||
return ok(createAnalysisExecution("short note", identity));
|
||||
if (operation === "resolve_containing_procedure")
|
||||
return ok(
|
||||
createAnalysisExecution(
|
||||
{
|
||||
query_address: "0x100",
|
||||
found: true,
|
||||
procedure: {
|
||||
address: "0x100",
|
||||
name: "f".repeat(6 * 1024 * 1024),
|
||||
classification: null,
|
||||
body: { available: false, reason: "not recorded" },
|
||||
},
|
||||
},
|
||||
identity,
|
||||
),
|
||||
);
|
||||
return err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
identity.id,
|
||||
operation,
|
||||
"not provided",
|
||||
),
|
||||
);
|
||||
},
|
||||
close: async () => ok(null),
|
||||
}),
|
||||
{
|
||||
resolveAnalysisProfile: async () =>
|
||||
ok({ profile: createAnalysisProfile(identity, { fixture: true }) }),
|
||||
},
|
||||
);
|
||||
const records = new InvestigationRecords({
|
||||
maximumRepresentationBytes: 8000,
|
||||
});
|
||||
const session = new BinarySession(router, records);
|
||||
const server = createServer(
|
||||
{ kind: "session", session },
|
||||
{
|
||||
environment: {
|
||||
...process.env,
|
||||
REA_MCP_TOOL_FAMILIES: "session",
|
||||
REA_MCP_OUTPUT_SCHEMA_PROFILE: profile,
|
||||
},
|
||||
},
|
||||
);
|
||||
const client = new Client({ name: "context-capacity", version: "1" });
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
try {
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
const definitions = new Map(
|
||||
(await client.listTools()).tools.map((tool) => [tool.name, tool]),
|
||||
);
|
||||
const call = (name: string, args: Record<string, string>) => {
|
||||
const toolDefinition = definitions.get(name);
|
||||
if (toolDefinition === undefined)
|
||||
throw new Error(`Tool ${name} was not advertised`);
|
||||
return client.callTool({ name, arguments: args }, { toolDefinition });
|
||||
};
|
||||
expect(
|
||||
(
|
||||
await call("open_binary", {
|
||||
path: target,
|
||||
format: "dos-com",
|
||||
provider_id: "fixture",
|
||||
})
|
||||
).isError,
|
||||
).not.toBe(true);
|
||||
const arguments_ = { document: "fixture", address: "0x100" };
|
||||
const response = await call("inspect_address_context", arguments_);
|
||||
expect(response.isError).not.toBe(true);
|
||||
verifyContext(response.structuredContent, twoLarge, session);
|
||||
const bundle = await client.callTool({
|
||||
name: "get_evidence_bundle",
|
||||
arguments: { detail: "complete" },
|
||||
});
|
||||
expect(bundle.structuredContent).toMatchObject({
|
||||
result: {
|
||||
records: [{ operation: "address_name", normalized_result: "entry" }],
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await client.close();
|
||||
await server.close();
|
||||
await session.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each([true, false])(
|
||||
"recovers retained context facets when possible (profile=%s)",
|
||||
async (profile) => {
|
||||
const identity = {
|
||||
id: "fixture",
|
||||
name: "Fixture analysis provider",
|
||||
version: "1",
|
||||
};
|
||||
const target = join(
|
||||
await createTestTempDirectory("rea-context-oversize-success-"),
|
||||
"fixture.com",
|
||||
);
|
||||
await writeFile(target, Buffer.from([0xb8, 0, 0x4c, 0xcd, 0x21]));
|
||||
const router = createTestProviderRouter(
|
||||
() => ({
|
||||
execute: async (operation) =>
|
||||
operation === "health"
|
||||
? ok(createAnalysisExecution(null, identity))
|
||||
: operation === "address_name"
|
||||
? ok(
|
||||
createAnalysisExecution(
|
||||
"x".repeat(6 * 1024 * 1024),
|
||||
identity,
|
||||
),
|
||||
)
|
||||
: err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
identity.id,
|
||||
operation,
|
||||
"not provided",
|
||||
),
|
||||
),
|
||||
close: async () => ok(null),
|
||||
}),
|
||||
profile
|
||||
? {
|
||||
resolveAnalysisProfile: async () =>
|
||||
ok({
|
||||
profile: createAnalysisProfile(identity, { fixture: true }),
|
||||
}),
|
||||
}
|
||||
: {},
|
||||
);
|
||||
const session = new BinarySession(router);
|
||||
const server = createServer(
|
||||
{ kind: "session", session },
|
||||
{ environment: { ...process.env, REA_MCP_TOOL_FAMILIES: "session" } },
|
||||
);
|
||||
const client = new Client({
|
||||
name: "context-oversize-success",
|
||||
version: "1",
|
||||
});
|
||||
const [ct, st] = InMemoryTransport.createLinkedPair();
|
||||
try {
|
||||
await server.connect(st);
|
||||
await client.connect(ct);
|
||||
const opened = await client.callTool({
|
||||
name: "open_binary",
|
||||
arguments: { path: target, format: "dos-com" },
|
||||
});
|
||||
expect(opened.isError, JSON.stringify(opened.content)).not.toBe(true);
|
||||
const result = await client.callTool({
|
||||
name: "inspect_address_context",
|
||||
arguments: { address: "0x100", document: "fixture" },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
const error = parseMcpToolError(result).error;
|
||||
expect(error).toMatchObject({
|
||||
code: "resource_constraint",
|
||||
details: { reported_limits: { boundary: "mcp-response" } },
|
||||
});
|
||||
expect(error.remediation.action).toContain("get_evidence_bundle");
|
||||
expect(error.remediation.action).toContain("export_evidence_bundle");
|
||||
expect(error.remediation.action).toContain("may not have been retained");
|
||||
const advertised = new Set(
|
||||
(await client.listTools()).tools.map((tool) => tool.name),
|
||||
);
|
||||
expect(advertised.has("get_evidence_bundle")).toBe(true);
|
||||
expect(advertised.has("export_evidence_bundle")).toBe(true);
|
||||
expect(advertised.has("address_name")).toBe(false);
|
||||
const summary = await client.callTool({
|
||||
name: "get_evidence_bundle",
|
||||
arguments: {
|
||||
detail: "summary",
|
||||
filters: { operation: "address_name" },
|
||||
},
|
||||
});
|
||||
expect(summary.isError).not.toBe(true);
|
||||
expect(summary.structuredContent).toMatchObject({
|
||||
result: { matching_records: profile ? 1 : 0 },
|
||||
});
|
||||
const path = join(
|
||||
await createTestTempDirectory("rea-context-recover-"),
|
||||
"evidence.json",
|
||||
);
|
||||
const exported = await client.callTool({
|
||||
name: "export_evidence_bundle",
|
||||
arguments: { path },
|
||||
});
|
||||
expect(exported.isError).not.toBe(true);
|
||||
const bundle = JSON.parse(await readFile(path, "utf8")) as {
|
||||
records: { operation: string; normalized_result: unknown }[];
|
||||
};
|
||||
const record = bundle.records.find(
|
||||
(entry) => entry.operation === "address_name",
|
||||
);
|
||||
if (profile)
|
||||
expect(record?.normalized_result).toBe("x".repeat(6 * 1024 * 1024));
|
||||
else expect(record).toBeUndefined();
|
||||
} finally {
|
||||
await client.close();
|
||||
await server.close();
|
||||
await session.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,108 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { createApplicationMcpHarness } from "../../fixtures/applicationMcpHarness.js";
|
||||
import { createTestProviderRouter } from "../../fixtures/binarySession.js";
|
||||
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
|
||||
import { BinarySession } from "../../../src/application/binary/BinarySession.js";
|
||||
import { InvestigationRecords } from "../../../src/application/investigation/InvestigationRecords.js";
|
||||
import { compareFunctionEvidence } from "../../../src/application/investigation/FunctionComparisonEvidence.js";
|
||||
import { FUNCTION_COMPARISON_EXAMPLE } from "../../../src/contracts/functionComparisonExample.js";
|
||||
import { safeParseEvidence } from "../../../src/domain/evidenceValidation.js";
|
||||
import { parseEvidenceBundle } from "../../../src/domain/evidenceBundle.js";
|
||||
import { toolContract } from "../../../src/contracts/toolContracts.js";
|
||||
import { analysisErrorProjectionSchema } from "../../../src/contracts/errorSchemas.js";
|
||||
|
||||
it.each([
|
||||
{ profile: "compact", preexisting: false },
|
||||
{ profile: "compact", preexisting: true },
|
||||
{ profile: "full", preexisting: false },
|
||||
{ profile: "full", preexisting: true },
|
||||
] as const)(
|
||||
"preserves the completed comparison on atomic retention failure ($profile schema, preexisting: $preexisting)",
|
||||
async ({ profile, preexisting }) => {
|
||||
const input = { ...FUNCTION_COMPARISON_EXAMPLE };
|
||||
const computed = compareFunctionEvidence(input);
|
||||
if (!computed.ok) throw computed.error;
|
||||
expect(computed.value.unknown).toBeDefined();
|
||||
const initial = [
|
||||
input.left,
|
||||
input.right,
|
||||
...(preexisting ? [computed.value.evidence] : []),
|
||||
];
|
||||
const sizing = new InvestigationRecords();
|
||||
for (const evidence of initial)
|
||||
expect(sizing.recordEvidence(evidence).ok).toBe(true);
|
||||
const records = new InvestigationRecords({
|
||||
maximumRepresentationBytes:
|
||||
sizing.statistics().retainedRepresentationBytes,
|
||||
});
|
||||
for (const evidence of initial)
|
||||
expect(records.recordEvidence(evidence).ok).toBe(true);
|
||||
const session = new BinarySession(
|
||||
createTestProviderRouter(() => {
|
||||
throw new Error("Comparison must not launch a provider");
|
||||
}),
|
||||
records,
|
||||
);
|
||||
const harness = await createApplicationMcpHarness({
|
||||
session,
|
||||
serverOptions: {
|
||||
environment: { ...process.env, REA_MCP_OUTPUT_SCHEMA_PROFILE: profile },
|
||||
},
|
||||
});
|
||||
try {
|
||||
const reply = await harness.client.callTool({
|
||||
name: "compare_functions",
|
||||
arguments: input,
|
||||
});
|
||||
expect(reply.isError).toBe(true);
|
||||
const error = parseMcpToolError(reply).error;
|
||||
analysisErrorProjectionSchema.parse(error);
|
||||
expect(error).toMatchObject({
|
||||
code: "resource_constraint",
|
||||
details: { operation: "record_unknown", resource: "capacity" },
|
||||
});
|
||||
const partial = error.details?.partial_observation;
|
||||
if (preexisting) {
|
||||
expect(partial).toEqual({
|
||||
kind: "retained-evidence",
|
||||
evidence_id: computed.value.evidence.evidence_id,
|
||||
});
|
||||
} else {
|
||||
expect(partial).toMatchObject({
|
||||
kind: "unretained-evidence",
|
||||
evidence: {
|
||||
evidence_id: computed.value.evidence.evidence_id,
|
||||
operation: "compare_functions",
|
||||
},
|
||||
});
|
||||
const authenticated = safeParseEvidence(
|
||||
(partial as { evidence: unknown }).evidence,
|
||||
);
|
||||
if (!authenticated.ok) throw authenticated.error;
|
||||
expect(authenticated.value).toEqual(computed.value.evidence);
|
||||
}
|
||||
expect(session.hasEvidence(computed.value.evidence.evidence_id)).toBe(
|
||||
preexisting,
|
||||
);
|
||||
expect(session.hasEvidence(input.left.evidence_id)).toBe(true);
|
||||
expect(session.hasEvidence(input.right.evidence_id)).toBe(true);
|
||||
expect(records.statistics().unknownRevisions).toBe(0);
|
||||
const bundle = await harness.client.callTool({
|
||||
name: "get_evidence_bundle",
|
||||
arguments: { detail: "complete" },
|
||||
});
|
||||
expect(bundle.isError).not.toBe(true);
|
||||
const result = toolContract("get_evidence_bundle").outputSchema.parse(
|
||||
bundle.structuredContent,
|
||||
);
|
||||
const ids = parseEvidenceBundle(result.result).records.map(
|
||||
({ evidence_id }) => evidence_id,
|
||||
);
|
||||
expect(ids.includes(computed.value.evidence.evidence_id)).toBe(
|
||||
preexisting,
|
||||
);
|
||||
} finally {
|
||||
await harness.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,261 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import { STDIO_DEFAULT_MAX_BUFFER_SIZE } from "@modelcontextprotocol/server";
|
||||
import { expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { createTestProviderRouter } from "../../fixtures/binarySession.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
|
||||
import { BinarySession } from "../../../src/application/binary/BinarySession.js";
|
||||
import { InvestigationRecords } from "../../../src/application/investigation/InvestigationRecords.js";
|
||||
import { createAnalysisExecution } from "../../../src/application/AnalysisProvider.js";
|
||||
import { safeParseEvidence } from "../../../src/domain/evidenceValidation.js";
|
||||
import { recoveryOmissionRemediation } from "../../../src/domain/analysisErrorProjection.js";
|
||||
import { UNRETAINED_EVIDENCE_EXPORT_GUIDANCE } from "../../../src/domain/analysisErrorPresentation.js";
|
||||
import { createEvidence } from "../../../src/domain/evidence.js";
|
||||
import { ok } from "../../../src/domain/result.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
import { EvidenceMcpServer } from "../../../src/server/EvidenceMcpServer.js";
|
||||
import { createToolResultDelivery } from "../../../src/server/toolResult.js";
|
||||
|
||||
it.each(["compact", "full"] as const)(
|
||||
"delivers authenticated but unretained provider Evidence inline (%s schema)",
|
||||
async (profile) => {
|
||||
const identity = {
|
||||
id: "fixture",
|
||||
name: "Fixture analysis provider",
|
||||
version: "1",
|
||||
};
|
||||
const target = join(
|
||||
await createTestTempDirectory("rea-evidence-refusal-"),
|
||||
"fixture.com",
|
||||
);
|
||||
await writeFile(target, Buffer.from([0xb8, 0, 0x4c, 0xcd, 0x21]));
|
||||
const router = createTestProviderRouter(() => ({
|
||||
execute: async () =>
|
||||
ok(
|
||||
createAnalysisExecution("entry", identity, {
|
||||
limitations: ["fixture limitation"],
|
||||
}),
|
||||
),
|
||||
close: async () => ok(null),
|
||||
}));
|
||||
const records = new InvestigationRecords({ maximumRepresentationBytes: 0 });
|
||||
const session = new BinarySession(router, records);
|
||||
const server = createServer(
|
||||
{ kind: "session", session },
|
||||
{
|
||||
environment: { ...process.env, REA_MCP_OUTPUT_SCHEMA_PROFILE: profile },
|
||||
},
|
||||
);
|
||||
const client = new Client({ name: "unretained-evidence", version: "1" });
|
||||
const [ct, st] = InMemoryTransport.createLinkedPair();
|
||||
try {
|
||||
await server.connect(st);
|
||||
await client.connect(ct);
|
||||
const definitions = new Map(
|
||||
(await client.listTools()).tools.map((tool) => [tool.name, tool]),
|
||||
);
|
||||
const definition = definitions.get("address_name");
|
||||
if (definition === undefined)
|
||||
throw new Error("address_name was not advertised");
|
||||
expect(
|
||||
(
|
||||
await client.callTool({
|
||||
name: "open_binary",
|
||||
arguments: { path: target, format: "dos-com" },
|
||||
})
|
||||
).isError,
|
||||
).not.toBe(true);
|
||||
const result = await client.callTool(
|
||||
{
|
||||
name: "address_name",
|
||||
arguments: { address: "0x100", document: "fixture" },
|
||||
},
|
||||
{ toolDefinition: definition },
|
||||
);
|
||||
expect(result.isError).toBe(true);
|
||||
const error = parseMcpToolError(result).error;
|
||||
expect(error.code).toBe("resource_constraint");
|
||||
expect(error.details).toMatchObject({
|
||||
operation: "record_evidence",
|
||||
resource: "capacity",
|
||||
partial_observation: {
|
||||
kind: "unretained-evidence",
|
||||
evidence: {
|
||||
operation: "address_name",
|
||||
normalized_result: "entry",
|
||||
provider: identity,
|
||||
limitations: ["fixture limitation"],
|
||||
},
|
||||
},
|
||||
});
|
||||
const partial = error.details?.partial_observation as {
|
||||
kind: string;
|
||||
evidence: unknown;
|
||||
};
|
||||
const authenticated = safeParseEvidence(partial.evidence);
|
||||
expect(authenticated.ok).toBe(true);
|
||||
if (!authenticated.ok) throw authenticated.error;
|
||||
expect(session.hasEvidence(authenticated.value.evidence_id)).toBe(false);
|
||||
expect(records.statistics().evidenceRecords).toBe(0);
|
||||
expect(error.remediation.action).toContain(
|
||||
UNRETAINED_EVIDENCE_EXPORT_GUIDANCE,
|
||||
);
|
||||
expect(error.details).not.toHaveProperty("evidence_reference");
|
||||
const bundle = await client.callTool({
|
||||
name: "get_evidence_bundle",
|
||||
arguments: { detail: "complete" },
|
||||
});
|
||||
expect(bundle.isError).not.toBe(true);
|
||||
expect(bundle.structuredContent).toMatchObject({
|
||||
result: { records: [] },
|
||||
});
|
||||
} finally {
|
||||
await client.close();
|
||||
await server.close();
|
||||
await session.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps an oversized unretained observation failure bounded and the connection usable", async () => {
|
||||
const identity = {
|
||||
id: "fixture",
|
||||
name: "Fixture analysis provider",
|
||||
version: "1",
|
||||
};
|
||||
const target = join(
|
||||
await createTestTempDirectory("rea-evidence-overflow-"),
|
||||
"fixture.com",
|
||||
);
|
||||
await writeFile(target, Buffer.from([0xb8, 0, 0x4c, 0xcd, 0x21]));
|
||||
const value = "x".repeat(STDIO_DEFAULT_MAX_BUFFER_SIZE + 65536);
|
||||
const router = createTestProviderRouter(() => ({
|
||||
execute: async () => ok(createAnalysisExecution(value, identity)),
|
||||
close: async () => ok(null),
|
||||
}));
|
||||
const records = new InvestigationRecords({ maximumRepresentationBytes: 0 });
|
||||
const session = new BinarySession(router, records);
|
||||
const server = createServer({ kind: "session", session });
|
||||
const client = new Client({
|
||||
name: "unretained-evidence-overflow",
|
||||
version: "1",
|
||||
});
|
||||
const [ct, st] = InMemoryTransport.createLinkedPair();
|
||||
try {
|
||||
await server.connect(st);
|
||||
await client.connect(ct);
|
||||
expect(
|
||||
(
|
||||
await client.callTool({
|
||||
name: "open_binary",
|
||||
arguments: { path: target, format: "dos-com" },
|
||||
})
|
||||
).isError,
|
||||
).not.toBe(true);
|
||||
const result = await client.callTool(
|
||||
{
|
||||
name: "address_name",
|
||||
arguments: { address: "0x100", document: "fixture" },
|
||||
},
|
||||
{ timeout: 120000 },
|
||||
);
|
||||
expect(result.isError).toBe(true);
|
||||
const error = parseMcpToolError(result).error;
|
||||
expect(error).toMatchObject({
|
||||
code: "resource_constraint",
|
||||
details: {
|
||||
operation: "record_evidence",
|
||||
resource: "capacity",
|
||||
reported_limits: { maximum_representation_bytes: 0 },
|
||||
partial_observation_omitted: {
|
||||
reason: "mcp-response-limit",
|
||||
constraint: "receive-buffer",
|
||||
retention: "unretained",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(error.remediation.action).toContain(
|
||||
recoveryOmissionRemediation("unretained"),
|
||||
);
|
||||
expect(error.remediation.action).toContain(
|
||||
UNRETAINED_EVIDENCE_EXPORT_GUIDANCE,
|
||||
);
|
||||
expect(error.details).not.toHaveProperty("partial_observation");
|
||||
expect(error.details).not.toHaveProperty("evidence_reference");
|
||||
expect(Buffer.byteLength(JSON.stringify(result))).toBeLessThan(
|
||||
STDIO_DEFAULT_MAX_BUFFER_SIZE,
|
||||
);
|
||||
expect(records.statistics().evidenceRecords).toBe(0);
|
||||
expect(
|
||||
(
|
||||
await client.callTool({
|
||||
name: "get_evidence_bundle",
|
||||
arguments: { detail: "summary" },
|
||||
})
|
||||
).isError,
|
||||
).not.toBe(true);
|
||||
} finally {
|
||||
await client.close();
|
||||
await server.close();
|
||||
await session.close();
|
||||
}
|
||||
}, 120000);
|
||||
|
||||
it("bounds fallback when the retention error itself has oversized diagnostics", async () => {
|
||||
const records = new InvestigationRecords({ maximumRepresentationBytes: 0 });
|
||||
const evidence = createEvidence(
|
||||
undefined,
|
||||
{ id: "fixture", name: "Fixture", version: "1" },
|
||||
{ operation: "address_name", parameters: {}, result: "entry" },
|
||||
{ immutable: true },
|
||||
);
|
||||
const refused = records.recordEvidence(evidence);
|
||||
if (refused.ok) throw new Error("Expected retention refusal");
|
||||
refused.error.retainDiagnostics({
|
||||
oversized: "x".repeat(STDIO_DEFAULT_MAX_BUFFER_SIZE + 65536),
|
||||
});
|
||||
const delivery = createToolResultDelivery(STDIO_DEFAULT_MAX_BUFFER_SIZE);
|
||||
const server = new EvidenceMcpServer(
|
||||
{ name: "retention-overflow", version: "1" },
|
||||
{ capabilities: {} },
|
||||
(record) => records.recordEvidence(record),
|
||||
delivery,
|
||||
);
|
||||
server.registerTool(
|
||||
"large_retention_failure",
|
||||
{ inputSchema: z.object({}) },
|
||||
async () => delivery.toErrorToolResult(refused.error, true),
|
||||
);
|
||||
const client = new Client({ name: "retention-overflow", version: "1" });
|
||||
const [ct, st] = InMemoryTransport.createLinkedPair();
|
||||
try {
|
||||
await server.connect(st);
|
||||
await client.connect(ct);
|
||||
const result = await client.callTool({
|
||||
name: "large_retention_failure",
|
||||
arguments: {},
|
||||
});
|
||||
const error = parseMcpToolError(result).error;
|
||||
expect(error).toMatchObject({
|
||||
code: "resource_constraint",
|
||||
details: {
|
||||
reported_limits: {
|
||||
boundary: "mcp-response",
|
||||
original_error_code: "resource_constraint",
|
||||
retention: "failed",
|
||||
retention_error: { code: "resource_constraint" },
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(Buffer.byteLength(JSON.stringify(result))).toBeLessThan(
|
||||
STDIO_DEFAULT_MAX_BUFFER_SIZE,
|
||||
);
|
||||
expect(records.statistics().evidenceRecords).toBe(0);
|
||||
} finally {
|
||||
await client.close();
|
||||
await server.close();
|
||||
}
|
||||
}, 120000);
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
import type { AnalysisError } from "../../../src/domain/analysisErrorBase.js";
|
||||
import { AnalysisCapabilityUnavailableError } from "../../../src/domain/analysisErrorCore.js";
|
||||
import { createAnalysisProfile } from "../../../src/domain/analysisProfile.js";
|
||||
import { parseEvidence } from "../../../src/domain/evidence.js";
|
||||
import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js";
|
||||
import { evidenceBundleSchema } from "../../../src/domain/evidenceBundle.js";
|
||||
import { nativeValueTraceSchema } from "../../../src/domain/native/nativeValueTrace.js";
|
||||
import { ok } from "../../../src/domain/result.js";
|
||||
@@ -287,6 +287,51 @@ const openSession = async (port: AnalysisOperationPort) => {
|
||||
return { client, session };
|
||||
};
|
||||
|
||||
it("keeps the stopped trace and source-publication failure together", async () => {
|
||||
const { port } = failingDiamond({
|
||||
"analyze_function:0x2000": childFailure("analyze_function", true),
|
||||
});
|
||||
const source = createEvidence(undefined, provider, {
|
||||
operation: "analyze_function",
|
||||
parameters: { procedure: "0x1000" },
|
||||
result: { source: "observed" },
|
||||
});
|
||||
const client = await connect({
|
||||
execute: async (operation, parameters, options) => {
|
||||
const outcome = await port.execute(operation, parameters, options);
|
||||
if (
|
||||
outcome.ok &&
|
||||
operation === "analyze_function" &&
|
||||
parameters.procedure === "0x1000"
|
||||
)
|
||||
return ok({ ...outcome.value, sourceEvidence: [source] });
|
||||
return outcome;
|
||||
},
|
||||
});
|
||||
|
||||
const { error } = parseMcpToolError(await client.callTool(trace));
|
||||
expect(error.code).toBe("cleanup_incomplete");
|
||||
const partial = parseEvidence(error.details?.partial_observation);
|
||||
const admitted = nativeValueTraceSchema.parse(partial.normalized_result);
|
||||
expect(admitted.procedures.map(({ address }) => address)).toEqual(["0x1000"]);
|
||||
expect(admitted.traversal).toEqual({
|
||||
status: "truncated",
|
||||
reasons: ["cleanup_incomplete"],
|
||||
});
|
||||
expect(partial.evidence_links).toContain(source.evidence_id);
|
||||
expect(error.details?.secondary_failures).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
code: "capability_unavailable",
|
||||
details: expect.objectContaining({
|
||||
provider_id: "rea",
|
||||
operation: "trace_native_values",
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("retains the admitted trace and its completed sources as session Evidence", async () => {
|
||||
const { port, reads } = failingDiamond(
|
||||
{
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { expect, it } from "vitest";
|
||||
import { createAnalysisExecution } from "../../../src/application/AnalysisProvider.js";
|
||||
import { BinarySession } from "../../../src/application/binary/BinarySession.js";
|
||||
import { createAnalysisProfile } from "../../../src/domain/analysisProfile.js";
|
||||
import {
|
||||
createEvidence,
|
||||
parseEvidence,
|
||||
withEvidenceCallerInputPath,
|
||||
type Evidence,
|
||||
} from "../../../src/domain/evidence.js";
|
||||
import { ok } from "../../../src/domain/result.js";
|
||||
import { createImmutableEvidenceBundle } from "../../../src/domain/evidenceBundle.js";
|
||||
import { OFFICIAL_TOOL_CONTRACTS } from "../../../src/contracts/officialToolContracts.js";
|
||||
import { silentLogger } from "../../../src/logger.js";
|
||||
import { EvidenceMcpServer } from "../../../src/server/EvidenceMcpServer.js";
|
||||
import {
|
||||
withAdmittedAnalysis,
|
||||
type WithAdmittedAnalysis,
|
||||
} from "../../../src/server/analysisAdmission.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
import { registerEvidenceTools } from "../../../src/server/registerEvidenceTools.js";
|
||||
import { createToolResultDelivery } from "../../../src/server/toolResult.js";
|
||||
import { createTestProviderRouter } from "../../fixtures/binarySession.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
it.each(["normal", "copied", "parameters", "forged", "source"] as const)(
|
||||
"preserves direct observation identity across the %s session boundary",
|
||||
async (mode) => {
|
||||
const provider = {
|
||||
id: "fixture",
|
||||
name: "Fixture analysis provider",
|
||||
version: "1",
|
||||
};
|
||||
const path = join(
|
||||
await createTestTempDirectory("rea-session-reuse-"),
|
||||
"fixture.com",
|
||||
);
|
||||
await writeFile(path, Buffer.from([0xb8, 0, 0x4c, 0xcd, 0x21]));
|
||||
let forged: Evidence | undefined;
|
||||
let source: Evidence | undefined;
|
||||
const router = createTestProviderRouter(
|
||||
() => ({
|
||||
execute: async () =>
|
||||
ok({
|
||||
...createAnalysisExecution(["0x100"], provider, {
|
||||
...(mode === "source" && source !== undefined
|
||||
? { sourceEvidence: [source] }
|
||||
: {}),
|
||||
}),
|
||||
...(mode === "forged" && forged !== undefined
|
||||
? { evidenceId: forged.evidence_id }
|
||||
: {}),
|
||||
}),
|
||||
close: async () => ok(null),
|
||||
}),
|
||||
{
|
||||
resolveAnalysisProfile: async () =>
|
||||
ok({ profile: createAnalysisProfile(provider, {}) }),
|
||||
},
|
||||
);
|
||||
const session = new BinarySession(router);
|
||||
const opened = await session.open(path, { formatHint: "dos-com" });
|
||||
if (!opened.ok) throw opened.error;
|
||||
source = createEvidence(
|
||||
opened.value,
|
||||
provider,
|
||||
{ operation: "health", parameters: {}, result: "source" },
|
||||
{ immutable: true },
|
||||
);
|
||||
forged = createEvidence(
|
||||
opened.value,
|
||||
provider,
|
||||
{
|
||||
operation: "xrefs",
|
||||
parameters: { address: "0x100", document: "fixture" },
|
||||
result: ["0xabad1dea"],
|
||||
},
|
||||
{ immutable: true },
|
||||
);
|
||||
if (mode === "forged") expect(session.recordEvidence(forged).ok).toBe(true);
|
||||
const base = withAdmittedAnalysis({ kind: "session", session });
|
||||
const admitted: WithAdmittedAnalysis = (name, signal, operation) =>
|
||||
base(name, signal, (port) =>
|
||||
operation({
|
||||
...port,
|
||||
execute: async (op, args, options) => {
|
||||
const result = await port.execute(op, args, options);
|
||||
return mode === "copied" && op === "xrefs" && result.ok
|
||||
? ok({ ...result.value, result: ["0x200"] })
|
||||
: result;
|
||||
},
|
||||
}),
|
||||
);
|
||||
const server = new EvidenceMcpServer(
|
||||
{ name: "reuse-control", version: "1" },
|
||||
{ capabilities: {} },
|
||||
session.recordEvidence.bind(session),
|
||||
createToolResultDelivery(undefined),
|
||||
);
|
||||
const contract = OFFICIAL_TOOL_CONTRACTS.find(
|
||||
({ name }) => name === "xrefs",
|
||||
);
|
||||
if (contract === undefined) throw new Error("Missing xrefs contract");
|
||||
registerEvidenceTools(server, [contract], {
|
||||
logger: silentLogger,
|
||||
activeTarget: () => session.activeTarget(),
|
||||
recordEvidence: session.recordEvidence.bind(session),
|
||||
evidenceById: session.evidenceForAnalysis.bind(session),
|
||||
admitRecovery: session.admitRecoveryObservation.bind(session),
|
||||
withAdmittedAnalysis: admitted,
|
||||
...(mode === "parameters"
|
||||
? {
|
||||
parametersForExecution: (_operation, parameters) => ({
|
||||
...parameters,
|
||||
address: "0x300",
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
const client = new Client({ name: "reuse-control", version: "1" });
|
||||
const [ct, st] = InMemoryTransport.createLinkedPair();
|
||||
try {
|
||||
await server.connect(st);
|
||||
await client.connect(ct);
|
||||
const response = await client.callTool({
|
||||
name: "xrefs",
|
||||
arguments: { address: "0x100", document: "fixture" },
|
||||
});
|
||||
expect(response.isError).not.toBe(true);
|
||||
const result = response.structuredContent as {
|
||||
normalized_result: string[];
|
||||
parameters: { address: string };
|
||||
evidence_id: string;
|
||||
evidence_links: string[];
|
||||
};
|
||||
expect(result.normalized_result).toEqual([
|
||||
mode === "copied" ? "0x200" : "0x100",
|
||||
]);
|
||||
expect(result.parameters.address).toBe("0x100");
|
||||
if (mode === "forged")
|
||||
expect(result.evidence_id).not.toBe(forged.evidence_id);
|
||||
if (mode === "source")
|
||||
expect(result.evidence_links).toContain(source.evidence_id);
|
||||
} finally {
|
||||
await client.close();
|
||||
await server.close();
|
||||
await session.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves the selected path when reusing an identical observation for a different file", async () => {
|
||||
const directory = await createTestTempDirectory("rea-reused-subject-");
|
||||
const first = join(directory, "first.com");
|
||||
const second = join(directory, "second.com");
|
||||
const bytes = Buffer.from([0xb8, 0, 0x4c, 0xcd, 0x21]);
|
||||
await writeFile(first, bytes);
|
||||
await writeFile(second, bytes);
|
||||
const provider = {
|
||||
id: "fixture",
|
||||
name: "Fixture analysis provider",
|
||||
version: "1",
|
||||
};
|
||||
const router = createTestProviderRouter(
|
||||
() => ({
|
||||
execute: async () => ok(createAnalysisExecution(["0x200"], provider)),
|
||||
close: async () => ok(null),
|
||||
}),
|
||||
{
|
||||
resolveAnalysisProfile: async () =>
|
||||
ok({ profile: createAnalysisProfile(provider, {}) }),
|
||||
},
|
||||
);
|
||||
const session = new BinarySession(router);
|
||||
const server = createServer({ kind: "session", session });
|
||||
const client = new Client({ name: "reused-subject", version: "1" });
|
||||
const [ct, st] = InMemoryTransport.createLinkedPair();
|
||||
try {
|
||||
await server.connect(st);
|
||||
await client.connect(ct);
|
||||
const call = async (name: string, args: Record<string, unknown>) => {
|
||||
const r = await client.callTool({ name, arguments: args });
|
||||
expect(r.isError).not.toBe(true);
|
||||
return r.structuredContent;
|
||||
};
|
||||
await call("open_binary", { path: first, format: "dos-com" });
|
||||
const before = await call("procedure_callees", { procedure: "0x100" });
|
||||
await call("open_binary", { path: second, format: "dos-com" });
|
||||
const after = await call("procedure_callees", { procedure: "0x100" });
|
||||
expect(before).toMatchObject({
|
||||
subject: { name: "first.com", local_path: first },
|
||||
});
|
||||
expect(after).toMatchObject({
|
||||
subject: { name: "second.com", local_path: second },
|
||||
});
|
||||
expect(parseEvidence(after).evidence_id).toEqual(
|
||||
parseEvidence(before).evidence_id,
|
||||
);
|
||||
expect(
|
||||
session.importEvidenceBundle(
|
||||
createImmutableEvidenceBundle([
|
||||
withEvidenceCallerInputPath(parseEvidence(after), "earlier-input"),
|
||||
]),
|
||||
).ok,
|
||||
).toBe(true);
|
||||
const sameTarget = await call("procedure_callees", { procedure: "0x100" });
|
||||
expect(sameTarget).not.toHaveProperty("caller_input_path");
|
||||
} finally {
|
||||
await client.close();
|
||||
await server.close();
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,331 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { createApplicationMcpHarness } from "../../fixtures/applicationMcpHarness.js";
|
||||
import { createTestProviderRouter } from "../../fixtures/binarySession.js";
|
||||
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
|
||||
import { BinarySession } from "../../../src/application/binary/BinarySession.js";
|
||||
import { InvestigationRecords } from "../../../src/application/investigation/InvestigationRecords.js";
|
||||
import { compareFunctionEvidence } from "../../../src/application/investigation/FunctionComparisonEvidence.js";
|
||||
import { FUNCTION_COMPARISON_EXAMPLE } from "../../../src/contracts/functionComparisonExample.js";
|
||||
import { parseEvidence } from "../../../src/domain/evidence.js";
|
||||
import { INVESTIGATION_EXAMPLES } from "../../../src/contracts/investigationExamples.js";
|
||||
import {
|
||||
JAVASCRIPT_APPLICATION_VERSION_COMPARISON_EXAMPLE,
|
||||
JAVASCRIPT_FEATURE_TRACE_EXAMPLE,
|
||||
SOURCE_TO_BUNDLE_COMPARISON_EXAMPLE,
|
||||
} from "../../../src/contracts/javascript/javascriptApplicationWorkflowExamples.js";
|
||||
import { compareApplicationVersionsEvidence } from "../../../src/application/javascript/JavaScriptApplicationWorkflowService.js";
|
||||
import { createToolResultDelivery } from "../../../src/server/toolResult.js";
|
||||
import { recoveryOmissionRemediation } from "../../../src/domain/analysisErrorProjection.js";
|
||||
|
||||
it("preserves the source when an inline result with unknown retention exceeds delivery", async () => {
|
||||
const session = new BinarySession(
|
||||
createTestProviderRouter(() => {
|
||||
throw new Error("no provider needed");
|
||||
}),
|
||||
new InvestigationRecords({ maximumRepresentationBytes: 0 }),
|
||||
);
|
||||
const { client } = await createApplicationMcpHarness({
|
||||
session,
|
||||
serverOptions: { delivery: createToolResultDelivery(10 * 1024) },
|
||||
});
|
||||
const response = await client.callTool({
|
||||
name: "compare_application_versions",
|
||||
arguments: JAVASCRIPT_APPLICATION_VERSION_COMPARISON_EXAMPLE,
|
||||
});
|
||||
const error = parseMcpToolError(response).error;
|
||||
expect(error.remediation.action).toContain(
|
||||
recoveryOmissionRemediation("unknown"),
|
||||
);
|
||||
expect(error).toMatchObject({
|
||||
code: "resource_constraint",
|
||||
details: {
|
||||
partial_observation: { kind: "unretained-evidence" },
|
||||
completed_observation_omitted: {
|
||||
reason: "mcp-response-limit",
|
||||
retention: "unknown",
|
||||
source_closure: "not-established",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("does not infer ledger membership from an independently registered reader", async () => {
|
||||
const computed = compareApplicationVersionsEvidence(
|
||||
JAVASCRIPT_APPLICATION_VERSION_COMPARISON_EXAMPLE,
|
||||
);
|
||||
if (!computed.ok) throw computed.error;
|
||||
const sizing = new InvestigationRecords();
|
||||
expect(sizing.recordEvidence(computed.value.evidence).ok).toBe(true);
|
||||
const records = new InvestigationRecords({
|
||||
maximumRepresentationBytes: sizing.statistics().retainedRepresentationBytes,
|
||||
});
|
||||
expect(records.recordEvidence(computed.value.evidence).ok).toBe(true);
|
||||
const session = new BinarySession(
|
||||
createTestProviderRouter(() => {
|
||||
throw new Error("no provider needed");
|
||||
}),
|
||||
records,
|
||||
);
|
||||
const { client } = await createApplicationMcpHarness({ session });
|
||||
const response = await client.callTool({
|
||||
name: "compare_application_versions",
|
||||
arguments: JAVASCRIPT_APPLICATION_VERSION_COMPARISON_EXAMPLE,
|
||||
});
|
||||
expect(parseMcpToolError(response).error).toMatchObject({
|
||||
details: {
|
||||
completed_observation: {
|
||||
kind: "inline-evidence",
|
||||
retention: "unknown",
|
||||
evidence: { evidence_id: computed.value.evidence.evidence_id },
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(session.hasEvidence(computed.value.evidence.evidence_id)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "compare_process_captures",
|
||||
input: INVESTIGATION_EXAMPLES.compare_process_captures.input,
|
||||
kind: "unretained-evidence",
|
||||
},
|
||||
{
|
||||
name: "compare_application_versions",
|
||||
input: JAVASCRIPT_APPLICATION_VERSION_COMPARISON_EXAMPLE,
|
||||
kind: "inline-evidence",
|
||||
},
|
||||
] as const)(
|
||||
"returns completed $name with honest retention when source recording fails",
|
||||
async ({ name, input, kind }) => {
|
||||
const session = new BinarySession(
|
||||
createTestProviderRouter(() => {
|
||||
throw new Error("no provider needed");
|
||||
}),
|
||||
new InvestigationRecords({ maximumRepresentationBytes: 0 }),
|
||||
);
|
||||
const { client } = await createApplicationMcpHarness({ session });
|
||||
const response = await client.callTool({ name, arguments: input });
|
||||
const error = parseMcpToolError(response).error;
|
||||
expect(error).toMatchObject({
|
||||
code: "resource_constraint",
|
||||
details: {
|
||||
partial_observation: { kind: "unretained-evidence" },
|
||||
completed_observation: {
|
||||
kind,
|
||||
source_closure: "not-established",
|
||||
evidence: { operation: name },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (kind === "inline-evidence")
|
||||
expect(error.details?.completed_observation).toMatchObject({
|
||||
retention: "unknown",
|
||||
});
|
||||
expect(session.exportEvidenceBundle().records).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["zero", "normal"] as const)(
|
||||
"tests completed derived work at %s source capacity",
|
||||
async (mode) => {
|
||||
const records = new InvestigationRecords(
|
||||
mode === "zero" ? { maximumRepresentationBytes: 0 } : {},
|
||||
);
|
||||
const session = new BinarySession(
|
||||
createTestProviderRouter(() => {
|
||||
throw new Error("no provider needed");
|
||||
}),
|
||||
records,
|
||||
);
|
||||
const { client } = await createApplicationMcpHarness({ session });
|
||||
const input = { ...FUNCTION_COMPARISON_EXAMPLE };
|
||||
const expected = compareFunctionEvidence(input);
|
||||
if (!expected.ok) throw expected.error;
|
||||
const response = await client.callTool({
|
||||
name: "compare_functions",
|
||||
arguments: input,
|
||||
});
|
||||
if (mode === "zero") {
|
||||
expect(response.isError).toBe(true);
|
||||
const error = parseMcpToolError(response).error;
|
||||
expect(error).toMatchObject({
|
||||
code: "resource_constraint",
|
||||
details: {
|
||||
partial_observation: {
|
||||
kind: "unretained-evidence",
|
||||
evidence: {
|
||||
evidence_id: input.left.evidence_id,
|
||||
operation: "analyze_function",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(error).toMatchObject({
|
||||
details: {
|
||||
completed_observation: {
|
||||
kind: "unretained-evidence",
|
||||
source_closure: "not-established",
|
||||
evidence: { evidence_id: expected.value.evidence.evidence_id },
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(records.statistics().evidenceRecords).toBe(0);
|
||||
} else {
|
||||
expect(response.isError).not.toBe(true);
|
||||
expect(parseEvidence(response.structuredContent)).toEqual(
|
||||
expected.value.evidence,
|
||||
);
|
||||
expect(session.hasEvidence(input.left.evidence_id)).toBe(true);
|
||||
expect(session.hasEvidence(input.right.evidence_id)).toBe(true);
|
||||
expect(session.hasEvidence(expected.value.evidence.evidence_id)).toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("reports a preexisting primary when source admission fails", async () => {
|
||||
const input = { ...FUNCTION_COMPARISON_EXAMPLE };
|
||||
const expected = compareFunctionEvidence(input);
|
||||
if (!expected.ok) throw expected.error;
|
||||
const sizing = new InvestigationRecords();
|
||||
expect(sizing.recordEvidence(expected.value.evidence).ok).toBe(true);
|
||||
const records = new InvestigationRecords({
|
||||
maximumRepresentationBytes: sizing.statistics().retainedRepresentationBytes,
|
||||
});
|
||||
expect(records.recordEvidence(expected.value.evidence).ok).toBe(true);
|
||||
const session = new BinarySession(
|
||||
createTestProviderRouter(() => {
|
||||
throw new Error("no provider needed");
|
||||
}),
|
||||
records,
|
||||
);
|
||||
const { client } = await createApplicationMcpHarness({ session });
|
||||
const response = await client.callTool({
|
||||
name: "compare_functions",
|
||||
arguments: input,
|
||||
});
|
||||
expect(response.isError).toBe(true);
|
||||
expect(parseMcpToolError(response).error).toMatchObject({
|
||||
code: "resource_constraint",
|
||||
details: {
|
||||
partial_observation: {
|
||||
kind: "unretained-evidence",
|
||||
evidence: { evidence_id: input.left.evidence_id },
|
||||
},
|
||||
completed_observation: {
|
||||
kind: "retained-evidence",
|
||||
source_closure: "not-established",
|
||||
evidence_id: expected.value.evidence.evidence_id,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(records.statistics()).toMatchObject({
|
||||
evidenceRecords: 1,
|
||||
unknownRevisions: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "trace_application_feature",
|
||||
input: JAVASCRIPT_FEATURE_TRACE_EXAMPLE,
|
||||
},
|
||||
{
|
||||
name: "compare_source_to_bundle",
|
||||
input: SOURCE_TO_BUNDLE_COMPARISON_EXAMPLE,
|
||||
},
|
||||
] as const)(
|
||||
"returns the selected $name view when source publication is refused",
|
||||
async ({ name, input }) => {
|
||||
const session = new BinarySession(
|
||||
createTestProviderRouter(() => {
|
||||
throw new Error("no provider needed");
|
||||
}),
|
||||
new InvestigationRecords({ maximumRepresentationBytes: 0 }),
|
||||
);
|
||||
const { client } = await createApplicationMcpHarness({ session });
|
||||
const result = await client.callTool({
|
||||
name,
|
||||
arguments: { ...input, view: { kind: "summary" } },
|
||||
});
|
||||
const { error } = parseMcpToolError(result);
|
||||
expect(error).toMatchObject({
|
||||
code: "resource_constraint",
|
||||
details: {
|
||||
partial_observation: { kind: "unretained-evidence" },
|
||||
completed_observation: {
|
||||
kind: "inline-evidence",
|
||||
retention: "unknown",
|
||||
source_closure: "not-established",
|
||||
evidence: { normalized_result: { kind: "summary" } },
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(session.exportEvidenceBundle().records).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "trace_application_feature",
|
||||
input: JAVASCRIPT_FEATURE_TRACE_EXAMPLE,
|
||||
},
|
||||
{
|
||||
name: "compare_source_to_bundle",
|
||||
input: SOURCE_TO_BUNDLE_COMPARISON_EXAMPLE,
|
||||
},
|
||||
] as const)(
|
||||
"preserves the completed $name parent when a selected view cannot be retained",
|
||||
async ({ name, input }) => {
|
||||
const router = () =>
|
||||
createTestProviderRouter(() => {
|
||||
throw new Error("no provider needed");
|
||||
});
|
||||
const normal = new BinarySession(router(), new InvestigationRecords());
|
||||
const normalHarness = await createApplicationMcpHarness({
|
||||
session: normal,
|
||||
});
|
||||
const complete = await normalHarness.client.callTool({
|
||||
name,
|
||||
arguments: input,
|
||||
});
|
||||
expect(complete.isError).not.toBe(true);
|
||||
const parent = parseEvidence(complete.structuredContent);
|
||||
const sizing = new InvestigationRecords();
|
||||
expect(parent.evidence_links.length).toBeGreaterThan(0);
|
||||
for (const id of parent.evidence_links) {
|
||||
const source = normal.evidenceForAnalysis(id);
|
||||
if (source === undefined) throw new Error(`Missing source ${id}`);
|
||||
expect(sizing.recordEvidence(source).ok).toBe(true);
|
||||
}
|
||||
const session = new BinarySession(
|
||||
router(),
|
||||
new InvestigationRecords({
|
||||
maximumRepresentationBytes:
|
||||
sizing.statistics().retainedRepresentationBytes,
|
||||
}),
|
||||
);
|
||||
const { client } = await createApplicationMcpHarness({ session });
|
||||
const result = await client.callTool({
|
||||
name,
|
||||
arguments: { ...input, view: { kind: "summary" } },
|
||||
});
|
||||
const { error } = parseMcpToolError(result);
|
||||
expect(error).toMatchObject({
|
||||
code: "resource_constraint",
|
||||
details: {
|
||||
partial_observation: {
|
||||
kind: "unretained-evidence",
|
||||
evidence: { evidence_id: parent.evidence_id, operation: name },
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(
|
||||
session
|
||||
.exportEvidenceBundle()
|
||||
.records.map((record) => record.evidence_id)
|
||||
.sort(),
|
||||
).toEqual([...parent.evidence_links].sort());
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,392 @@
|
||||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import { STDIO_DEFAULT_MAX_BUFFER_SIZE } from "@modelcontextprotocol/server";
|
||||
import { afterAll, expect, it } from "vitest";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { createTestProviderRouter } from "../../fixtures/binarySession.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { BinarySession } from "../../../src/application/binary/BinarySession.js";
|
||||
import { InvestigationRecords } from "../../../src/application/investigation/InvestigationRecords.js";
|
||||
import { createAnalysisExecution } from "../../../src/application/AnalysisProvider.js";
|
||||
import { createEvidence } from "../../../src/domain/evidence.js";
|
||||
import { AnalysisCapabilityUnavailableError } from "../../../src/domain/analysisErrorCore.js";
|
||||
import { err, ok } from "../../../src/domain/result.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js";
|
||||
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
|
||||
import { createMcpSchemaValidation } from "../../fixtures/mcpSchemaValidation.js";
|
||||
|
||||
const validation = createMcpSchemaValidation();
|
||||
afterAll(() => validation.clear());
|
||||
|
||||
it.each(
|
||||
["full", "compact"].flatMap((profile) =>
|
||||
["large-source", "large-primary", "invalid-source"].map((mode) => ({
|
||||
profile,
|
||||
mode,
|
||||
})),
|
||||
),
|
||||
)(
|
||||
"keeps sibling context with $mode ($profile schema)",
|
||||
async ({ mode, profile }) => {
|
||||
const id = { id: "fixture", name: "Fixture", version: "1" };
|
||||
const original = createEvidence(
|
||||
undefined,
|
||||
id,
|
||||
{
|
||||
operation: "health",
|
||||
parameters: {},
|
||||
result:
|
||||
mode === "large-source"
|
||||
? "s".repeat(STDIO_DEFAULT_MAX_BUFFER_SIZE + 65536)
|
||||
: "small source",
|
||||
},
|
||||
{ immutable: true },
|
||||
);
|
||||
const source =
|
||||
mode === "invalid-source"
|
||||
? { ...original, normalized_result: "tampered" }
|
||||
: original;
|
||||
const name =
|
||||
mode === "large-primary"
|
||||
? "p".repeat(STDIO_DEFAULT_MAX_BUFFER_SIZE + 65536)
|
||||
: "primary function";
|
||||
const target = join(
|
||||
await createTestTempDirectory("rea-source-context-"),
|
||||
"fixture.com",
|
||||
);
|
||||
await writeFile(target, Buffer.from([0xb8, 0, 0x4c, 0xcd, 0x21]));
|
||||
const router = createTestProviderRouter(() => ({
|
||||
execute: async (op) =>
|
||||
op === "health"
|
||||
? ok(createAnalysisExecution(null, id))
|
||||
: op === "address_name"
|
||||
? ok(createAnalysisExecution("entry", id))
|
||||
: op === "resolve_containing_procedure"
|
||||
? ok(
|
||||
createAnalysisExecution(
|
||||
{
|
||||
query_address: "0x100",
|
||||
found: true,
|
||||
procedure: {
|
||||
address: "0x100",
|
||||
name,
|
||||
classification: null,
|
||||
body: { available: false, reason: "not recorded" },
|
||||
},
|
||||
},
|
||||
id,
|
||||
{ sourceEvidence: [source] },
|
||||
),
|
||||
)
|
||||
: err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
id.id,
|
||||
op,
|
||||
"not provided",
|
||||
),
|
||||
),
|
||||
close: async () => ok(null),
|
||||
}));
|
||||
const records = new InvestigationRecords({ maximumRepresentationBytes: 0 });
|
||||
const session = new BinarySession(router, records);
|
||||
const server = createServer(
|
||||
{ kind: "session", session },
|
||||
{
|
||||
environment: {
|
||||
...process.env,
|
||||
REA_MCP_TOOL_FAMILIES: "session",
|
||||
REA_MCP_OUTPUT_SCHEMA_PROFILE: profile,
|
||||
},
|
||||
},
|
||||
);
|
||||
const client = new Client({ name: "source-context", version: "1" });
|
||||
const [ct, st] = InMemoryTransport.createLinkedPair();
|
||||
try {
|
||||
await server.connect(st);
|
||||
await client.connect(ct);
|
||||
const opened = await client.callTool({
|
||||
name: "open_binary",
|
||||
arguments: { path: target, format: "dos-com" },
|
||||
});
|
||||
expect(opened.isError, JSON.stringify(opened.content)).not.toBe(true);
|
||||
const tool = (await client.listTools()).tools.find(
|
||||
({ name }) => name === "inspect_address_context",
|
||||
);
|
||||
if (tool?.outputSchema === undefined)
|
||||
throw new Error("Missing context output schema");
|
||||
expect(validation.assertions.validateSchema(tool.outputSchema)).toBe(
|
||||
true,
|
||||
);
|
||||
const result = await client.callTool(
|
||||
{
|
||||
name: "inspect_address_context",
|
||||
arguments: { address: "0x100", document: "fixture" },
|
||||
},
|
||||
{ timeout: 120000, toolDefinition: tool },
|
||||
);
|
||||
expect(result.isError, JSON.stringify(result.content)).not.toBe(true);
|
||||
expect(
|
||||
validation.assertions.validate(
|
||||
tool.outputSchema,
|
||||
result.structuredContent,
|
||||
),
|
||||
JSON.stringify(validation.assertions.errors),
|
||||
).toBe(true);
|
||||
expect(result.structuredContent).toMatchObject({
|
||||
result: {
|
||||
name: { state: "available", value: "entry" },
|
||||
procedure: {
|
||||
state: "unavailable",
|
||||
error: {
|
||||
code:
|
||||
mode === "invalid-source"
|
||||
? "evidence_integrity_mismatch"
|
||||
: "resource_constraint",
|
||||
details:
|
||||
mode === "large-source"
|
||||
? {
|
||||
partial_observation_omitted: {
|
||||
reason: "mcp-response-limit",
|
||||
},
|
||||
completed_observation: { kind: "unretained-evidence" },
|
||||
}
|
||||
: mode === "large-primary"
|
||||
? {
|
||||
partial_observation: { kind: "unretained-evidence" },
|
||||
completed_observation_omitted: {
|
||||
reason: "mcp-response-limit",
|
||||
},
|
||||
}
|
||||
: {
|
||||
completed_observation: { kind: "unretained-evidence" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await client.close();
|
||||
await server.close();
|
||||
await session.close();
|
||||
}
|
||||
},
|
||||
120000,
|
||||
);
|
||||
|
||||
it.each([false, true])(
|
||||
"retains a sibling source failure when cleanup fails (large source: %s)",
|
||||
async (large) => {
|
||||
const provider = { id: "fixture", name: "Fixture", version: "1" };
|
||||
const source = createEvidence(
|
||||
undefined,
|
||||
provider,
|
||||
{
|
||||
operation: "health",
|
||||
parameters: {},
|
||||
result: large
|
||||
? "s".repeat(STDIO_DEFAULT_MAX_BUFFER_SIZE + 65536)
|
||||
: "source",
|
||||
},
|
||||
{ immutable: true },
|
||||
);
|
||||
const target = join(
|
||||
await createTestTempDirectory("rea-mixed-context-"),
|
||||
"fixture.com",
|
||||
);
|
||||
await writeFile(target, Buffer.from([0xb8, 0, 0x4c, 0xcd, 0x21]));
|
||||
const session = new BinarySession(
|
||||
createTestProviderRouter(() => ({
|
||||
execute: async (operation) =>
|
||||
operation === "health"
|
||||
? ok(createAnalysisExecution(null, provider))
|
||||
: operation === "address_name"
|
||||
? ok(
|
||||
createAnalysisExecution("completed name", provider, {
|
||||
sourceEvidence: [source],
|
||||
}),
|
||||
)
|
||||
: operation === "comment"
|
||||
? err(
|
||||
new ProviderCleanupError(
|
||||
provider.id,
|
||||
["fixture-owner"],
|
||||
{},
|
||||
),
|
||||
)
|
||||
: err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
provider.id,
|
||||
operation,
|
||||
"not provided",
|
||||
),
|
||||
),
|
||||
close: async () => ok(null),
|
||||
})),
|
||||
new InvestigationRecords({ maximumRepresentationBytes: 0 }),
|
||||
);
|
||||
const server = createServer({ kind: "session", session });
|
||||
const client = new Client({ name: "source-cleanup-context", version: "1" });
|
||||
const [ct, st] = InMemoryTransport.createLinkedPair();
|
||||
try {
|
||||
await server.connect(st);
|
||||
await client.connect(ct);
|
||||
expect(
|
||||
(
|
||||
await client.callTool({
|
||||
name: "open_binary",
|
||||
arguments: { path: target, format: "dos-com" },
|
||||
})
|
||||
).isError,
|
||||
).not.toBe(true);
|
||||
const result = await client.callTool(
|
||||
{
|
||||
name: "inspect_address_context",
|
||||
arguments: { address: "0x100", document: "fixture" },
|
||||
},
|
||||
{ timeout: 120000 },
|
||||
);
|
||||
const error = parseMcpToolError(result).error;
|
||||
expect(error).toMatchObject({
|
||||
code: "cleanup_incomplete",
|
||||
details: {
|
||||
resources: ["fixture-owner"],
|
||||
secondary_failures: [
|
||||
{
|
||||
code: "resource_constraint",
|
||||
details: {
|
||||
...(large
|
||||
? {
|
||||
partial_observation_omitted: {
|
||||
reason: "mcp-response-limit",
|
||||
},
|
||||
}
|
||||
: {
|
||||
partial_observation: {
|
||||
kind: "unretained-evidence",
|
||||
evidence: { evidence_id: source.evidence_id },
|
||||
},
|
||||
}),
|
||||
completed_observation: {
|
||||
kind: "unretained-evidence",
|
||||
evidence: { normalized_result: "completed name" },
|
||||
source_closure: "not-established",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await client.close();
|
||||
await server.close();
|
||||
await session.close();
|
||||
}
|
||||
},
|
||||
120000,
|
||||
);
|
||||
|
||||
it("keeps a smaller sibling recovery when a later completed observation exceeds delivery", async () => {
|
||||
const provider = { id: "fixture", name: "Fixture", version: "1" };
|
||||
const source = createEvidence(
|
||||
undefined,
|
||||
provider,
|
||||
{
|
||||
operation: "health",
|
||||
parameters: {},
|
||||
result: "source",
|
||||
},
|
||||
{ immutable: true },
|
||||
);
|
||||
const target = join(
|
||||
await createTestTempDirectory("rea-mixed-recoveries-"),
|
||||
"fixture.com",
|
||||
);
|
||||
await writeFile(target, Buffer.from([0xb8, 0, 0x4c, 0xcd, 0x21]));
|
||||
const session = new BinarySession(
|
||||
createTestProviderRouter(() => ({
|
||||
execute: async (operation) => {
|
||||
if (operation === "health")
|
||||
return ok(createAnalysisExecution(null, provider));
|
||||
if (operation === "address_name")
|
||||
return ok(
|
||||
createAnalysisExecution("small", provider, {
|
||||
sourceEvidence: [source],
|
||||
}),
|
||||
);
|
||||
if (operation === "inline_comment")
|
||||
return ok(
|
||||
createAnalysisExecution(
|
||||
"p".repeat(STDIO_DEFAULT_MAX_BUFFER_SIZE + 65536),
|
||||
provider,
|
||||
{ sourceEvidence: [source] },
|
||||
),
|
||||
);
|
||||
if (operation === "comment")
|
||||
return err(
|
||||
new ProviderCleanupError(provider.id, ["fixture-owner"], {}),
|
||||
);
|
||||
return err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
provider.id,
|
||||
operation,
|
||||
"not provided",
|
||||
),
|
||||
);
|
||||
},
|
||||
close: async () => ok(null),
|
||||
})),
|
||||
new InvestigationRecords({ maximumRepresentationBytes: 0 }),
|
||||
);
|
||||
const server = createServer({ kind: "session", session });
|
||||
const client = new Client({
|
||||
name: "multiple-recovery-context",
|
||||
version: "1",
|
||||
});
|
||||
const [ct, st] = InMemoryTransport.createLinkedPair();
|
||||
try {
|
||||
await server.connect(st);
|
||||
await client.connect(ct);
|
||||
expect(
|
||||
(
|
||||
await client.callTool({
|
||||
name: "open_binary",
|
||||
arguments: { path: target, format: "dos-com" },
|
||||
})
|
||||
).isError,
|
||||
).not.toBe(true);
|
||||
const result = await client.callTool(
|
||||
{
|
||||
name: "inspect_address_context",
|
||||
arguments: { address: "0x100", document: "fixture" },
|
||||
},
|
||||
{ timeout: 120000 },
|
||||
);
|
||||
expect(parseMcpToolError(result).error).toMatchObject({
|
||||
code: "cleanup_incomplete",
|
||||
details: {
|
||||
resources: ["fixture-owner"],
|
||||
secondary_failures: [
|
||||
{
|
||||
details: {
|
||||
partial_observation: { kind: "unretained-evidence" },
|
||||
completed_observation: {
|
||||
evidence: { normalized_result: "small" },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
details: {
|
||||
partial_observation: { kind: "unretained-evidence" },
|
||||
completed_observation_omitted: { reason: "mcp-response-limit" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await client.close();
|
||||
await server.close();
|
||||
await session.close();
|
||||
}
|
||||
}, 120000);
|
||||
@@ -0,0 +1,607 @@
|
||||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import { STDIO_DEFAULT_MAX_BUFFER_SIZE } from "@modelcontextprotocol/server";
|
||||
import { expect, it } from "vitest";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { createTestProviderRouter } from "../../fixtures/binarySession.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
|
||||
import { BinarySession } from "../../../src/application/binary/BinarySession.js";
|
||||
import { InvestigationRecords } from "../../../src/application/investigation/InvestigationRecords.js";
|
||||
import { createAnalysisExecution } from "../../../src/application/AnalysisProvider.js";
|
||||
import { createAnalysisExecutionEvidence } from "../../../src/domain/analysisObservation.js";
|
||||
import { UNRETAINED_EVIDENCE_EXPORT_GUIDANCE } from "../../../src/domain/analysisErrorPresentation.js";
|
||||
import { createEvidence, type Evidence } from "../../../src/domain/evidence.js";
|
||||
import { safeParseEvidence } from "../../../src/domain/evidenceValidation.js";
|
||||
import { ok } from "../../../src/domain/result.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
|
||||
const assertPublication = ({
|
||||
error,
|
||||
source,
|
||||
primary,
|
||||
sourceKind,
|
||||
primaryKind,
|
||||
preexisting,
|
||||
differentPath,
|
||||
selectedPath,
|
||||
session,
|
||||
}: {
|
||||
error: ReturnType<typeof parseMcpToolError>["error"];
|
||||
source: Evidence;
|
||||
primary: Evidence | undefined;
|
||||
sourceKind:
|
||||
| "small"
|
||||
| "invalid"
|
||||
| "invalid-id"
|
||||
| "invalid-json"
|
||||
| "invalid-null"
|
||||
| "large"
|
||||
| "large-limited";
|
||||
primaryKind: "small" | "large";
|
||||
preexisting: boolean;
|
||||
differentPath: boolean;
|
||||
selectedPath: string;
|
||||
session: BinarySession;
|
||||
}): void => {
|
||||
expect(error.code).toBe(
|
||||
sourceKind === "invalid" ||
|
||||
sourceKind === "invalid-id" ||
|
||||
sourceKind === "invalid-json" ||
|
||||
sourceKind === "invalid-null"
|
||||
? "evidence_integrity_mismatch"
|
||||
: "resource_constraint",
|
||||
);
|
||||
if (error.code === "resource_constraint") {
|
||||
expect(error.remediation.action).toContain(
|
||||
UNRETAINED_EVIDENCE_EXPORT_GUIDANCE,
|
||||
);
|
||||
}
|
||||
if (sourceKind === "small")
|
||||
expect(error.details).toMatchObject({
|
||||
partial_observation: {
|
||||
kind: "unretained-evidence",
|
||||
evidence: { evidence_id: source.evidence_id },
|
||||
},
|
||||
});
|
||||
if (sourceKind === "large" || sourceKind === "large-limited")
|
||||
expect(error.details).toMatchObject({
|
||||
partial_observation_omitted: {
|
||||
reason: "mcp-response-limit",
|
||||
evidence_id: source.evidence_id,
|
||||
},
|
||||
});
|
||||
if (
|
||||
sourceKind === "invalid" ||
|
||||
sourceKind === "invalid-id" ||
|
||||
sourceKind === "invalid-json" ||
|
||||
sourceKind === "invalid-null"
|
||||
)
|
||||
expect(error.details).not.toHaveProperty("partial_observation");
|
||||
if (preexisting && differentPath)
|
||||
expect(error.details).toMatchObject({
|
||||
completed_observation: {
|
||||
kind: "unretained-evidence",
|
||||
evidence: {
|
||||
evidence_id: primary!.evidence_id,
|
||||
subject: { local_path: selectedPath },
|
||||
},
|
||||
},
|
||||
});
|
||||
else if (preexisting)
|
||||
expect(error.details).toMatchObject({
|
||||
completed_observation: {
|
||||
kind: "retained-evidence",
|
||||
source_closure: "not-established",
|
||||
evidence_id: primary!.evidence_id,
|
||||
},
|
||||
});
|
||||
else if (sourceKind === "invalid-id" || sourceKind === "invalid-null")
|
||||
expect(error.details).toMatchObject({
|
||||
completed_observation: {
|
||||
kind: "unretained-observation",
|
||||
source_closure: "not-established",
|
||||
observation: {
|
||||
operation: "address_name",
|
||||
result: "completed primary",
|
||||
},
|
||||
},
|
||||
});
|
||||
else if (primaryKind === "small") {
|
||||
expect(error.details).toMatchObject({
|
||||
completed_observation: {
|
||||
kind: "unretained-evidence",
|
||||
source_closure: "not-established",
|
||||
evidence: {
|
||||
operation: "address_name",
|
||||
normalized_result: "completed primary",
|
||||
},
|
||||
},
|
||||
});
|
||||
const details = error.details;
|
||||
if (details === undefined) throw new Error("Expected error details");
|
||||
const record = (details.completed_observation as { evidence: unknown })
|
||||
.evidence;
|
||||
const parsed = safeParseEvidence(record);
|
||||
if (!parsed.ok) throw parsed.error;
|
||||
expect(parsed.value.evidence_links).toEqual([source.evidence_id]);
|
||||
expect(session.hasEvidence(parsed.value.evidence_id)).toBe(false);
|
||||
} else
|
||||
expect(error.details).toMatchObject({
|
||||
completed_observation_omitted: {
|
||||
reason: "mcp-response-limit",
|
||||
retention: "unretained",
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
it.each([
|
||||
{
|
||||
source: "small",
|
||||
primary: "small",
|
||||
preexisting: false,
|
||||
differentPath: false,
|
||||
},
|
||||
{
|
||||
source: "invalid",
|
||||
primary: "small",
|
||||
preexisting: false,
|
||||
differentPath: false,
|
||||
},
|
||||
{
|
||||
source: "invalid-id",
|
||||
primary: "small",
|
||||
preexisting: false,
|
||||
differentPath: false,
|
||||
},
|
||||
{
|
||||
source: "invalid-json",
|
||||
primary: "small",
|
||||
preexisting: false,
|
||||
differentPath: false,
|
||||
},
|
||||
{
|
||||
source: "invalid-null",
|
||||
primary: "small",
|
||||
preexisting: false,
|
||||
differentPath: false,
|
||||
},
|
||||
{
|
||||
source: "large",
|
||||
primary: "small",
|
||||
preexisting: false,
|
||||
differentPath: false,
|
||||
},
|
||||
{
|
||||
source: "large-limited",
|
||||
primary: "small",
|
||||
preexisting: false,
|
||||
differentPath: false,
|
||||
},
|
||||
{
|
||||
source: "small",
|
||||
primary: "large",
|
||||
preexisting: false,
|
||||
differentPath: false,
|
||||
},
|
||||
{
|
||||
source: "large",
|
||||
primary: "large",
|
||||
preexisting: false,
|
||||
differentPath: false,
|
||||
},
|
||||
{
|
||||
source: "large",
|
||||
primary: "small",
|
||||
preexisting: true,
|
||||
differentPath: false,
|
||||
},
|
||||
{ source: "large", primary: "small", preexisting: true, differentPath: true },
|
||||
] as const)(
|
||||
"retains independently bounded primary and source failure ($source source, $primary primary, preexisting: $preexisting, different path: $differentPath)",
|
||||
async ({
|
||||
source: sourceKind,
|
||||
primary: primaryKind,
|
||||
preexisting,
|
||||
differentPath,
|
||||
}) => {
|
||||
const identity = { id: "fixture", name: "Fixture", version: "1" };
|
||||
const base = createEvidence(
|
||||
undefined,
|
||||
identity,
|
||||
{
|
||||
operation: "health",
|
||||
parameters: {},
|
||||
result:
|
||||
sourceKind === "large" || sourceKind === "large-limited"
|
||||
? "s".repeat(STDIO_DEFAULT_MAX_BUFFER_SIZE + 65536)
|
||||
: "source fact",
|
||||
},
|
||||
{ immutable: true },
|
||||
);
|
||||
const source =
|
||||
sourceKind === "invalid"
|
||||
? { ...base, normalized_result: "tampered" }
|
||||
: sourceKind === "invalid-null"
|
||||
? (null as unknown as typeof base)
|
||||
: sourceKind === "invalid-json"
|
||||
? ({
|
||||
...base,
|
||||
normalized_result: undefined,
|
||||
} as unknown as typeof base)
|
||||
: sourceKind === "invalid-id"
|
||||
? { ...base, evidence_id: "not-a-valid-id" }
|
||||
: base;
|
||||
const result =
|
||||
primaryKind === "large"
|
||||
? "p".repeat(STDIO_DEFAULT_MAX_BUFFER_SIZE + 65536)
|
||||
: "completed primary";
|
||||
const path = join(
|
||||
await createTestTempDirectory("rea-source-publication-"),
|
||||
"fixture.com",
|
||||
);
|
||||
await writeFile(path, Buffer.from([0xb8, 0, 0x4c, 0xcd, 0x21]));
|
||||
const router = createTestProviderRouter(() => ({
|
||||
execute: async () =>
|
||||
ok(
|
||||
createAnalysisExecution(result, identity, {
|
||||
sourceEvidence: [source],
|
||||
}),
|
||||
),
|
||||
close: async () => ok(null),
|
||||
}));
|
||||
const records = new InvestigationRecords({
|
||||
maximumRepresentationBytes: preexisting ? 20000 : 0,
|
||||
...(sourceKind === "large-limited"
|
||||
? { availableAuthenticationBytes: () => 200000 }
|
||||
: {}),
|
||||
});
|
||||
const session = new BinarySession(router, records);
|
||||
const server = createServer({ kind: "session", session });
|
||||
const client = new Client({ name: "source-publication", version: "1" });
|
||||
const [ct, st] = InMemoryTransport.createLinkedPair();
|
||||
try {
|
||||
await server.connect(st);
|
||||
await client.connect(ct);
|
||||
expect(
|
||||
(
|
||||
await client.callTool({
|
||||
name: "open_binary",
|
||||
arguments: { path, format: "dos-com" },
|
||||
})
|
||||
).isError,
|
||||
).not.toBe(true);
|
||||
const target = session.activeTarget();
|
||||
if (target === undefined) throw new Error("Expected open target");
|
||||
const primary = preexisting
|
||||
? createAnalysisExecutionEvidence(
|
||||
"address_name",
|
||||
{ document: "fixture", address: "0x100" },
|
||||
{
|
||||
...createAnalysisExecution(result, identity, {
|
||||
sourceEvidence: [source],
|
||||
}),
|
||||
subject: differentPath
|
||||
? { ...target, path: `${path}.old` }
|
||||
: target,
|
||||
},
|
||||
"observed",
|
||||
)
|
||||
: undefined;
|
||||
if (primary !== undefined)
|
||||
expect(session.recordEvidence(primary).ok).toBe(true);
|
||||
const response = await client.callTool(
|
||||
{
|
||||
name: "address_name",
|
||||
arguments: { document: "fixture", address: "0x100" },
|
||||
},
|
||||
{ timeout: 120000 },
|
||||
);
|
||||
expect(response.isError).toBe(true);
|
||||
const error = parseMcpToolError(response).error;
|
||||
assertPublication({
|
||||
error,
|
||||
source,
|
||||
primary,
|
||||
sourceKind,
|
||||
primaryKind,
|
||||
preexisting,
|
||||
differentPath,
|
||||
selectedPath: path,
|
||||
session,
|
||||
});
|
||||
expect(records.statistics().evidenceRecords).toBe(preexisting ? 1 : 0);
|
||||
expect(
|
||||
(
|
||||
await client.callTool({
|
||||
name: "get_evidence_bundle",
|
||||
arguments: { detail: "summary" },
|
||||
})
|
||||
).isError,
|
||||
).not.toBe(true);
|
||||
} finally {
|
||||
await client.close();
|
||||
await server.close();
|
||||
await session.close();
|
||||
}
|
||||
},
|
||||
120000,
|
||||
);
|
||||
|
||||
it("reports omission when the primary cannot be safely authenticated", async () => {
|
||||
const identity = { id: "fixture", name: "Fixture", version: "1" };
|
||||
const source = createEvidence(
|
||||
undefined,
|
||||
identity,
|
||||
{ operation: "health", parameters: {}, result: "source" },
|
||||
{ immutable: true },
|
||||
);
|
||||
const path = join(
|
||||
await createTestTempDirectory("rea-recovery-headroom-"),
|
||||
"fixture.com",
|
||||
);
|
||||
await writeFile(path, Buffer.from([0xb8, 0, 0x4c, 0xcd, 0x21]));
|
||||
const router = createTestProviderRouter(() => ({
|
||||
execute: async () =>
|
||||
ok(
|
||||
createAnalysisExecution("primary", identity, {
|
||||
sourceEvidence: [source],
|
||||
}),
|
||||
),
|
||||
close: async () => ok(null),
|
||||
}));
|
||||
const records = new InvestigationRecords({
|
||||
maximumRepresentationBytes: 0,
|
||||
availableAuthenticationBytes: () => 0,
|
||||
});
|
||||
const session = new BinarySession(router, records);
|
||||
const server = createServer({ kind: "session", session });
|
||||
const client = new Client({ name: "recovery-headroom", version: "1" });
|
||||
const [ct, st] = InMemoryTransport.createLinkedPair();
|
||||
try {
|
||||
await server.connect(st);
|
||||
await client.connect(ct);
|
||||
expect(
|
||||
(
|
||||
await client.callTool({
|
||||
name: "open_binary",
|
||||
arguments: { path, format: "dos-com" },
|
||||
})
|
||||
).isError,
|
||||
).not.toBe(true);
|
||||
const response = await client.callTool({
|
||||
name: "address_name",
|
||||
arguments: { document: "fixture", address: "0x100" },
|
||||
});
|
||||
const error = parseMcpToolError(response).error;
|
||||
expect(error).toMatchObject({
|
||||
code: "resource_constraint",
|
||||
details: {
|
||||
partial_observation: {
|
||||
kind: "unretained-evidence",
|
||||
evidence: { evidence_id: source.evidence_id },
|
||||
},
|
||||
completed_observation_omitted: {
|
||||
reason: "insufficient-authentication-headroom",
|
||||
source_closure: "not-established",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(error.details).not.toHaveProperty("completed_observation");
|
||||
} finally {
|
||||
await client.close();
|
||||
await server.close();
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("accounts for a wide source ID list before constructing recovery Evidence", async () => {
|
||||
const identity = { id: "fixture", name: "Fixture", version: "1" };
|
||||
const source = createEvidence(
|
||||
undefined,
|
||||
identity,
|
||||
{ operation: "health", parameters: {}, result: "source" },
|
||||
{ immutable: true },
|
||||
);
|
||||
const path = join(
|
||||
await createTestTempDirectory("rea-wide-recovery-"),
|
||||
"fixture.com",
|
||||
);
|
||||
await writeFile(path, Buffer.from([0xb8, 0, 0x4c, 0xcd, 0x21]));
|
||||
const sources = Array.from({ length: 5000 }, () => source);
|
||||
const router = createTestProviderRouter(() => ({
|
||||
execute: async () =>
|
||||
ok(
|
||||
createAnalysisExecution("primary", identity, {
|
||||
sourceEvidence: sources,
|
||||
}),
|
||||
),
|
||||
close: async () => ok(null),
|
||||
}));
|
||||
const records = new InvestigationRecords({
|
||||
maximumRepresentationBytes: 0,
|
||||
availableAuthenticationBytes: () => 100000,
|
||||
});
|
||||
const session = new BinarySession(router, records);
|
||||
const server = createServer({ kind: "session", session });
|
||||
const client = new Client({ name: "wide-recovery", version: "1" });
|
||||
const [ct, st] = InMemoryTransport.createLinkedPair();
|
||||
try {
|
||||
await server.connect(st);
|
||||
await client.connect(ct);
|
||||
expect(
|
||||
(
|
||||
await client.callTool({
|
||||
name: "open_binary",
|
||||
arguments: { path, format: "dos-com" },
|
||||
})
|
||||
).isError,
|
||||
).not.toBe(true);
|
||||
const response = await client.callTool({
|
||||
name: "address_name",
|
||||
arguments: { document: "fixture", address: "0x100" },
|
||||
});
|
||||
const error = parseMcpToolError(response).error;
|
||||
expect(error).toMatchObject({
|
||||
code: "resource_constraint",
|
||||
details: {
|
||||
completed_observation_omitted: {
|
||||
reason: "insufficient-authentication-headroom",
|
||||
source_closure: "not-established",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(error.details).not.toHaveProperty("completed_observation");
|
||||
} finally {
|
||||
await client.close();
|
||||
await server.close();
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["compact", "full"] as const)(
|
||||
"reports omitted primary when a retained provider source precedes an authentication refusal (%s)",
|
||||
async (profile) => {
|
||||
const identity = { id: "fixture", name: "Fixture", version: "1" };
|
||||
const source = createEvidence(
|
||||
undefined,
|
||||
identity,
|
||||
{ operation: "health", parameters: {}, result: "historical source" },
|
||||
{ immutable: true },
|
||||
);
|
||||
const path = join(
|
||||
await createTestTempDirectory("rea-source-primary-headroom-"),
|
||||
"fixture.com",
|
||||
);
|
||||
await writeFile(path, Buffer.from([0xb8, 0, 0x4c, 0xcd, 0x21]));
|
||||
const router = createTestProviderRouter(() => ({
|
||||
execute: async () =>
|
||||
ok(
|
||||
createAnalysisExecution("completed primary", identity, {
|
||||
sourceEvidence: [source],
|
||||
}),
|
||||
),
|
||||
close: async () => ok(null),
|
||||
}));
|
||||
const records = new InvestigationRecords({
|
||||
maximumRepresentationBytes: 1000000,
|
||||
availableAuthenticationBytes: () => 0,
|
||||
});
|
||||
const session = new BinarySession(router, records);
|
||||
const server = createServer(
|
||||
{ kind: "session", session },
|
||||
{
|
||||
environment: { ...process.env, REA_MCP_OUTPUT_SCHEMA_PROFILE: profile },
|
||||
},
|
||||
);
|
||||
const client = new Client({
|
||||
name: "source-primary-headroom",
|
||||
version: "1",
|
||||
});
|
||||
const [ct, st] = InMemoryTransport.createLinkedPair();
|
||||
try {
|
||||
await server.connect(st);
|
||||
await client.connect(ct);
|
||||
expect(
|
||||
(
|
||||
await client.callTool({
|
||||
name: "open_binary",
|
||||
arguments: { path, format: "dos-com" },
|
||||
})
|
||||
).isError,
|
||||
).not.toBe(true);
|
||||
const response = await client.callTool({
|
||||
name: "address_name",
|
||||
arguments: { document: "fixture", address: "0x100" },
|
||||
});
|
||||
expect(response.isError).toBe(true);
|
||||
expect(parseMcpToolError(response).error).toMatchObject({
|
||||
code: "resource_constraint",
|
||||
details: {
|
||||
completed_observation_omitted: {
|
||||
reason: "insufficient-authentication-headroom",
|
||||
source_closure: "not-established",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(records.statistics().evidenceRecords).toBe(1);
|
||||
expect(session.hasEvidence(source.evidence_id)).toBe(true);
|
||||
} finally {
|
||||
await client.close();
|
||||
await server.close();
|
||||
await session.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("accounts for source links before admitting a completed direct observation", async () => {
|
||||
const identity = { id: "fixture", name: "Fixture", version: "1" };
|
||||
const source = createEvidence(
|
||||
undefined,
|
||||
identity,
|
||||
{ operation: "health", parameters: {}, result: "source" },
|
||||
{ immutable: true },
|
||||
);
|
||||
const path = join(
|
||||
await createTestTempDirectory("rea-wide-primary-admission-"),
|
||||
"fixture.com",
|
||||
);
|
||||
await writeFile(path, Buffer.from([0xb8, 0, 0x4c, 0xcd, 0x21]));
|
||||
const router = createTestProviderRouter(() => ({
|
||||
execute: async () =>
|
||||
ok(
|
||||
createAnalysisExecution("primary", identity, {
|
||||
sourceEvidence: Array.from({ length: 5000 }, () => source),
|
||||
}),
|
||||
),
|
||||
close: async () => ok(null),
|
||||
}));
|
||||
const records = new InvestigationRecords({
|
||||
maximumRepresentationBytes: 1000000,
|
||||
availableAuthenticationBytes: () => 100000,
|
||||
});
|
||||
const session = new BinarySession(router, records);
|
||||
const server = createServer({ kind: "session", session });
|
||||
const client = new Client({ name: "wide-primary-admission", version: "1" });
|
||||
const [ct, st] = InMemoryTransport.createLinkedPair();
|
||||
try {
|
||||
await server.connect(st);
|
||||
await client.connect(ct);
|
||||
expect(
|
||||
(
|
||||
await client.callTool({
|
||||
name: "open_binary",
|
||||
arguments: { path, format: "dos-com" },
|
||||
})
|
||||
).isError,
|
||||
).not.toBe(true);
|
||||
const response = await client.callTool({
|
||||
name: "address_name",
|
||||
arguments: { document: "fixture", address: "0x100" },
|
||||
});
|
||||
expect(response.isError).toBe(true);
|
||||
const error = parseMcpToolError(response).error;
|
||||
expect(error).toMatchObject({
|
||||
code: "resource_constraint",
|
||||
details: {
|
||||
completed_observation_omitted: {
|
||||
reason: "insufficient-authentication-headroom",
|
||||
source_closure: "not-established",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(error.details?.reported_limits).toMatchObject({
|
||||
available_heap_bytes: 100000,
|
||||
});
|
||||
const reported = error.details?.reported_limits as
|
||||
| { projected_value_bytes: number }
|
||||
| undefined;
|
||||
expect(reported?.projected_value_bytes).toBeGreaterThan(100000);
|
||||
expect(records.statistics().evidenceRecords).toBe(1);
|
||||
expect(session.hasEvidence(source.evidence_id)).toBe(true);
|
||||
} finally {
|
||||
await client.close();
|
||||
await server.close();
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
@@ -15,6 +15,7 @@ import { toolContract } from "../../../src/contracts/toolContracts.js";
|
||||
import type { ToolContract } from "../../../src/contracts/toolContractTypes.js";
|
||||
import { err } from "../../../src/domain/result.js";
|
||||
import { EvidenceIntegrityError } from "../../../src/domain/evidenceErrors.js";
|
||||
import { AnalysisCancelledError } from "../../../src/domain/analysisErrorCore.js";
|
||||
import { HopperProcessError } from "../../../src/domain/hopperErrors.js";
|
||||
import { processScenarioSchema } from "../../../src/domain/process/processScenario.js";
|
||||
import { createToolResultDelivery } from "../../../src/server/toolResult.js";
|
||||
@@ -526,12 +527,56 @@ describe("untrusted provenance on failed tool results", () => {
|
||||
err(new EvidenceIntegrityError("fixture rejects partial")),
|
||||
);
|
||||
expect(unrecorded.structuredContent).toMatchObject({
|
||||
error: { details: { partial_observation: evidence } },
|
||||
error: {
|
||||
details: {
|
||||
partial_observation: {
|
||||
kind: "inline-evidence",
|
||||
retention: "unknown",
|
||||
evidence,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(unrecorded._meta).toEqual(UNTRUSTED_TARGET_CONTENT);
|
||||
expect(original.partialObservation).toBe(evidence);
|
||||
});
|
||||
|
||||
it("preserves cleanup details when an unretained partial exceeds the response budget", () => {
|
||||
const evidence = createEvidence(
|
||||
undefined,
|
||||
{ id: "fixture", name: "Fixture", version: "1" },
|
||||
{
|
||||
operation: "get_call_graph",
|
||||
parameters: {},
|
||||
result: { nodes: "x".repeat(8192) },
|
||||
},
|
||||
);
|
||||
const error = new AnalysisCancelledError("get_call_graph", {
|
||||
cleanup: { reason: "close failed", resources: ["session:fixture"] },
|
||||
partialObservation: evidence,
|
||||
});
|
||||
const result = createToolResultDelivery(4096).toPartialErrorToolResult(
|
||||
error,
|
||||
true,
|
||||
() => err(new EvidenceIntegrityError("fixture rejects partial")),
|
||||
);
|
||||
expect(result.structuredContent).toMatchObject({
|
||||
error: {
|
||||
code: "cleanup_incomplete",
|
||||
details: {
|
||||
cleanup: "incomplete",
|
||||
resources: ["session:fixture"],
|
||||
partial_observation_omitted: {
|
||||
reason: "mcp-response-limit",
|
||||
retention: "unknown",
|
||||
evidence_id: evidence.evidence_id,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.content).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("marks the response-budget error that replaces an oversized target-content result", () => {
|
||||
const small = createToolResultDelivery(4096);
|
||||
const result = small.toCallToolResult(
|
||||
|
||||
@@ -51,8 +51,11 @@ import { observed } from "../fixtures/analysisExecution.js";
|
||||
import {
|
||||
createBinarySessionTargets,
|
||||
createTestBinarySession,
|
||||
createTestProviderRouter,
|
||||
} from "../fixtures/binarySession.js";
|
||||
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
|
||||
import { BinarySession } from "../../src/application/binary/BinarySession.js";
|
||||
import { InvestigationRecords } from "../../src/application/investigation/InvestigationRecords.js";
|
||||
import { AnalysisCleanupLease } from "../../src/artifacts/AnalysisCleanupLease.js";
|
||||
|
||||
const cleanupError = new ProviderCleanupError(
|
||||
@@ -278,6 +281,61 @@ it.each([false, true])(
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves the caller path on an authenticated record refused by the ledger", async () => {
|
||||
const [path] = await createBinarySessionTargets();
|
||||
const callerPath = `${dirname(path)}/./${basename(path)}`;
|
||||
const records = new InvestigationRecords({ maximumRepresentationBytes: 0 });
|
||||
const session = new BinarySession(
|
||||
createTestProviderRouter(
|
||||
() => ({
|
||||
execute: async (operation) =>
|
||||
observed(operation === "health" ? null : { byte: 42 }),
|
||||
close: async () => ok(null),
|
||||
}),
|
||||
{
|
||||
resolveAnalysisProfile: async () =>
|
||||
ok({
|
||||
profile: createAnalysisProfile(
|
||||
{
|
||||
id: "fixture",
|
||||
name: "Fixture analysis provider",
|
||||
version: "1",
|
||||
},
|
||||
{ fixture: true },
|
||||
),
|
||||
}),
|
||||
},
|
||||
),
|
||||
records,
|
||||
);
|
||||
const createSession = () => session;
|
||||
const result = analysisCliErrorEnvelopeSchema.parse(
|
||||
await runDirectAnalysis(
|
||||
{
|
||||
readConfiguration: () => parseConfig({}),
|
||||
createBinarySession: createSession,
|
||||
createArtifactBinarySession: createSession,
|
||||
createNativeDispatchBinarySession: createSession,
|
||||
createNativeBinarySession: createSession,
|
||||
createManagedBinarySession: createSession,
|
||||
},
|
||||
callerPath,
|
||||
"read_bytes",
|
||||
{ address: "0x100", document: "fixture" },
|
||||
).then(projectCliAnalysisResult),
|
||||
);
|
||||
expect(result.code).toBe("resource_constraint");
|
||||
const partial = jsonObjectSchema.parse(
|
||||
jsonObjectSchema.parse(result.details).partial_observation,
|
||||
);
|
||||
expect(partial.kind).toBe("unretained-evidence");
|
||||
const evidence = parseEvidence(partial.evidence);
|
||||
expect(evidence.caller_input_path).toBe(callerPath);
|
||||
expect(evidence.subject?.local_path).toBe(path);
|
||||
expect(evidence.normalized_result).toEqual({ byte: 42 });
|
||||
expect(records.statistics().evidenceRecords).toBe(0);
|
||||
});
|
||||
|
||||
describe("one-shot finalization cancellation", () => {
|
||||
it.each(["read_bytes", "analyze_function"] as const)(
|
||||
"retains completed %s Evidence when cancellation arrives during final close",
|
||||
@@ -323,8 +381,11 @@ describe("one-shot finalization cancellation", () => {
|
||||
async (closeFails) => {
|
||||
const [path] = await createBinarySessionTargets();
|
||||
const controller = new AbortController();
|
||||
const failure = new AnalysisTimeoutError("read_bytes", 100, {
|
||||
capturedOutput: primaryError.capturedOutput,
|
||||
});
|
||||
const { dependencies } = factories({
|
||||
executionError: primaryError,
|
||||
executionError: failure,
|
||||
onClose: () => controller.abort({ later: true }),
|
||||
...(closeFails ? { cleanupError } : {}),
|
||||
});
|
||||
@@ -337,9 +398,9 @@ describe("one-shot finalization cancellation", () => {
|
||||
).then(projectCliAnalysisResult);
|
||||
expect(result).toMatchObject({
|
||||
code: closeFails ? "cleanup_incomplete" : "provider_timeout",
|
||||
message: projectAnalysisError(primaryError).message,
|
||||
message: projectAnalysisError(failure).message,
|
||||
details: {
|
||||
captured_output: primaryError.capturedOutput,
|
||||
captured_output: failure.capturedOutput,
|
||||
...(closeFails
|
||||
? { cleanup_error: projectAnalysisError(cleanupError) }
|
||||
: {}),
|
||||
@@ -709,12 +770,13 @@ it.each(["complete", "summary"] as const)(
|
||||
it("preserves a managed provider failure when cancellation arrives during close", async () => {
|
||||
const [path] = await createBinarySessionTargets();
|
||||
const controller = new AbortController();
|
||||
const reason = new Error("later cancellation");
|
||||
const failure = new AnalysisInputError("inspect_managed_members", undefined, [
|
||||
{ path: ["path"], reason: "invalid_format" },
|
||||
]);
|
||||
const { dependencies, session } = factories({
|
||||
executionError: failure,
|
||||
onClose: () => controller.abort(new Error("later cancellation")),
|
||||
onClose: () => controller.abort(reason),
|
||||
});
|
||||
const result = await runProviderAnalysis(
|
||||
dependencies,
|
||||
@@ -728,6 +790,13 @@ it("preserves a managed provider failure when cancellation arrives during close"
|
||||
error: "Analysis failed",
|
||||
...projectAnalysisError(failure),
|
||||
});
|
||||
expect(
|
||||
failure.secondaryFailures.some(
|
||||
(secondary) =>
|
||||
secondary instanceof AnalysisCancelledError &&
|
||||
secondary.cause === reason,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(session.activeTarget()).toBeUndefined();
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,491 @@
|
||||
import { expect, it, vi } from "vitest";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { dirname, basename, join } from "node:path";
|
||||
import {
|
||||
createBinarySessionTargets,
|
||||
createTestProviderRouter,
|
||||
} from "../fixtures/binarySession.js";
|
||||
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
|
||||
import { BinarySession } from "../../src/application/binary/BinarySession.js";
|
||||
import { InvestigationRecords } from "../../src/application/investigation/InvestigationRecords.js";
|
||||
import {
|
||||
runDirectAnalysis,
|
||||
runProviderAnalysis,
|
||||
} from "../../src/application/DirectAnalysis.js";
|
||||
import type { DirectAnalysisDependencies } from "../../src/application/DirectAnalysisDependencies.js";
|
||||
import { createAnalysisExecution } from "../../src/application/AnalysisProvider.js";
|
||||
import { createAnalysisExecutionEvidence } from "../../src/domain/analysisObservation.js";
|
||||
import { createEvidence } from "../../src/domain/evidence.js";
|
||||
import { parseConfig } from "../../src/config/parseConfig.js";
|
||||
import { err, ok } from "../../src/domain/result.js";
|
||||
import { ProviderCleanupError } from "../../src/domain/providerCleanupError.js";
|
||||
import { primaryAnalysisError } from "../../src/domain/analysisErrorBase.js";
|
||||
import { AnalysisCancelledError } from "../../src/domain/analysisErrorCore.js";
|
||||
|
||||
it.each(["inspect_macho", "inspect_managed_members"] as const)(
|
||||
"recovers %s Evidence and caller input identity after source admission fails",
|
||||
async (tool) => {
|
||||
const [target] = await createBinarySessionTargets();
|
||||
const path = `${dirname(target)}/./${basename(target)}`;
|
||||
const provider = { id: "fixture", name: "Fixture", version: "1" };
|
||||
const source = createEvidence(undefined, provider, {
|
||||
operation: "health",
|
||||
parameters: {},
|
||||
result: "large".repeat(20000),
|
||||
});
|
||||
const calls: Readonly<Record<string, unknown>>[] = [];
|
||||
const run = async (maximumRepresentationBytes: number) => {
|
||||
const session = new BinarySession(
|
||||
createTestProviderRouter(() => ({
|
||||
execute: async (operation, parameters) => {
|
||||
if (operation !== "health") calls.push(parameters);
|
||||
return ok(
|
||||
createAnalysisExecution(
|
||||
operation === "health" ? null : "completed",
|
||||
provider,
|
||||
operation === "health" ? {} : { sourceEvidence: [source] },
|
||||
),
|
||||
);
|
||||
},
|
||||
close: async () => ok(null),
|
||||
})),
|
||||
new InvestigationRecords({ maximumRepresentationBytes }),
|
||||
);
|
||||
const create = () => session;
|
||||
const dependencies: DirectAnalysisDependencies = {
|
||||
readConfiguration: () => parseConfig({}),
|
||||
createBinarySession: create,
|
||||
createNativeDispatchBinarySession: create,
|
||||
createArtifactBinarySession: create,
|
||||
createNativeBinarySession: create,
|
||||
createManagedBinarySession: create,
|
||||
};
|
||||
return runProviderAnalysis(dependencies, path, tool, {});
|
||||
};
|
||||
const success = await run(500000);
|
||||
if (!success.ok) throw new Error(JSON.stringify(success.error));
|
||||
const refused = await run(20000);
|
||||
if (refused.ok) throw new Error("Expected source admission failure");
|
||||
expect(refused.error).toMatchObject({
|
||||
details: {
|
||||
completed_observation: {
|
||||
evidence: {
|
||||
evidence_id: success.value.evidence_id,
|
||||
parameters: { input_path: path },
|
||||
caller_input_path: path,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(calls).toEqual([{}, {}]);
|
||||
},
|
||||
);
|
||||
|
||||
it("delivers a preexisting completed record inline after one-shot cleanup", async () => {
|
||||
const provider = { id: "fixture", name: "Fixture", version: "1" };
|
||||
const source = createEvidence(
|
||||
undefined,
|
||||
provider,
|
||||
{ operation: "health", parameters: {}, result: "large".repeat(20000) },
|
||||
{ immutable: true },
|
||||
);
|
||||
const path = join(
|
||||
await createTestTempDirectory("rea-oneshot-source-"),
|
||||
"fixture.com",
|
||||
);
|
||||
await writeFile(path, Buffer.from([0xb8, 0, 0x4c, 0xcd, 0x21]));
|
||||
const router = createTestProviderRouter(() => ({
|
||||
execute: async (op) =>
|
||||
ok(
|
||||
createAnalysisExecution(
|
||||
op === "health" ? null : "completed",
|
||||
provider,
|
||||
op === "health" ? {} : { sourceEvidence: [source] },
|
||||
),
|
||||
),
|
||||
close: async () => ok(null),
|
||||
}));
|
||||
const records = new InvestigationRecords({
|
||||
maximumRepresentationBytes: 20000,
|
||||
});
|
||||
const session = new BinarySession(router, records);
|
||||
const opened = await session.open(path, { formatHint: "dos-com" });
|
||||
if (!opened.ok) throw opened.error;
|
||||
const args = { address: "0x100", length: 4 };
|
||||
const primary = createAnalysisExecutionEvidence(
|
||||
"read_bytes",
|
||||
args,
|
||||
{
|
||||
...createAnalysisExecution("completed", provider, {
|
||||
sourceEvidence: [source],
|
||||
}),
|
||||
subject: opened.value,
|
||||
},
|
||||
"observed",
|
||||
);
|
||||
expect(session.recordEvidence(primary).ok).toBe(true);
|
||||
const create = () => session;
|
||||
const dependencies: DirectAnalysisDependencies = {
|
||||
readConfiguration: () => parseConfig({}),
|
||||
createBinarySession: create,
|
||||
createNativeDispatchBinarySession: create,
|
||||
createArtifactBinarySession: create,
|
||||
createNativeBinarySession: create,
|
||||
createManagedBinarySession: create,
|
||||
};
|
||||
const result = await runDirectAnalysis(
|
||||
dependencies,
|
||||
path,
|
||||
"read_bytes",
|
||||
args,
|
||||
{ formatHint: "dos-com" },
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("Expected source failure");
|
||||
expect(result.error).toMatchObject({
|
||||
code: "resource_constraint",
|
||||
details: {
|
||||
completed_observation: {
|
||||
kind: "inline-evidence",
|
||||
retention: "one-shot-session",
|
||||
source_closure: "not-established",
|
||||
evidence: {
|
||||
evidence_id: primary.evidence_id,
|
||||
caller_input_path: path,
|
||||
normalized_result: "completed",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps caller spelling on a raw observation when a source ID is malformed", async () => {
|
||||
const provider = { id: "fixture", name: "Fixture", version: "1" };
|
||||
const base = createEvidence(undefined, provider, {
|
||||
operation: "health",
|
||||
parameters: {},
|
||||
result: "source",
|
||||
});
|
||||
const source = { ...base, evidence_id: "invalid" };
|
||||
const path = join(
|
||||
await createTestTempDirectory("rea-oneshot-invalid-source-"),
|
||||
"fixture.com",
|
||||
);
|
||||
await writeFile(path, Buffer.from([0xb8, 0, 0x4c, 0xcd, 0x21]));
|
||||
const router = createTestProviderRouter(() => ({
|
||||
execute: async (op) =>
|
||||
ok(
|
||||
createAnalysisExecution(
|
||||
op === "health" ? null : "completed",
|
||||
provider,
|
||||
op === "health" ? {} : { sourceEvidence: [source] },
|
||||
),
|
||||
),
|
||||
close: async () => ok(null),
|
||||
}));
|
||||
const session = new BinarySession(router);
|
||||
const create = () => session;
|
||||
const dependencies: DirectAnalysisDependencies = {
|
||||
readConfiguration: () => parseConfig({}),
|
||||
createBinarySession: create,
|
||||
createNativeDispatchBinarySession: create,
|
||||
createArtifactBinarySession: create,
|
||||
createNativeBinarySession: create,
|
||||
createManagedBinarySession: create,
|
||||
};
|
||||
const result = await runDirectAnalysis(
|
||||
dependencies,
|
||||
path,
|
||||
"read_bytes",
|
||||
{ address: "0x100", length: 4 },
|
||||
{ formatHint: "dos-com" },
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("Expected source failure");
|
||||
expect(result.error).toMatchObject({
|
||||
code: "evidence_integrity_mismatch",
|
||||
details: {
|
||||
completed_observation: {
|
||||
kind: "unretained-observation",
|
||||
source_closure: "not-established",
|
||||
observation: {
|
||||
operation: "read_bytes",
|
||||
result: "completed",
|
||||
caller_input_path: path,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
"keeps the source failure and primary when cancellation arrives during final close (close fails: %s)",
|
||||
async (closeFails) => {
|
||||
const provider = { id: "fixture", name: "Fixture", version: "1" };
|
||||
const source = createEvidence(
|
||||
undefined,
|
||||
provider,
|
||||
{ operation: "health", parameters: {}, result: "large".repeat(20000) },
|
||||
{ immutable: true },
|
||||
);
|
||||
const path = join(
|
||||
await createTestTempDirectory("rea-source-final-close-"),
|
||||
"fixture.com",
|
||||
);
|
||||
await writeFile(path, Buffer.from([0xb8, 0, 0x4c, 0xcd, 0x21]));
|
||||
const session = new BinarySession(
|
||||
createTestProviderRouter(() => ({
|
||||
execute: async (op) =>
|
||||
ok(
|
||||
createAnalysisExecution(
|
||||
op === "health" ? null : "completed",
|
||||
provider,
|
||||
op === "health" ? {} : { sourceEvidence: [source] },
|
||||
),
|
||||
),
|
||||
close: async () => ok(null),
|
||||
})),
|
||||
new InvestigationRecords({ maximumRepresentationBytes: 20000 }),
|
||||
);
|
||||
const opened = await session.open(path, { formatHint: "dos-com" });
|
||||
if (!opened.ok) throw opened.error;
|
||||
const args = { address: "0x100", length: 4 };
|
||||
const primary = createAnalysisExecutionEvidence(
|
||||
"read_bytes",
|
||||
args,
|
||||
{
|
||||
...createAnalysisExecution("completed", provider, {
|
||||
sourceEvidence: [source],
|
||||
}),
|
||||
subject: opened.value,
|
||||
},
|
||||
"observed",
|
||||
);
|
||||
expect(session.recordEvidence(primary).ok).toBe(true);
|
||||
const create = () => session;
|
||||
const dependencies: DirectAnalysisDependencies = {
|
||||
readConfiguration: () => parseConfig({}),
|
||||
createBinarySession: create,
|
||||
createNativeDispatchBinarySession: create,
|
||||
createArtifactBinarySession: create,
|
||||
createNativeBinarySession: create,
|
||||
createManagedBinarySession: create,
|
||||
};
|
||||
const controller = new AbortController();
|
||||
const reason = { during: "final close" };
|
||||
const recordEvidence = vi.spyOn(session, "recordEvidence");
|
||||
const nativeClose = session.close.bind(session);
|
||||
const failure = new ProviderCleanupError("fixture", ["fixture-owner"], {});
|
||||
const close = vi
|
||||
.spyOn(session, "close")
|
||||
.mockImplementationOnce(async () => {
|
||||
controller.abort(reason);
|
||||
return closeFails ? err(failure) : nativeClose();
|
||||
});
|
||||
try {
|
||||
const result = await runDirectAnalysis(
|
||||
dependencies,
|
||||
path,
|
||||
"read_bytes",
|
||||
args,
|
||||
{
|
||||
formatHint: "dos-com",
|
||||
signal: controller.signal,
|
||||
},
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("Expected source failure");
|
||||
expect(result.error).toMatchObject({
|
||||
code: closeFails ? "cleanup_incomplete" : "resource_constraint",
|
||||
details: {
|
||||
partial_observation: {
|
||||
kind: "unretained-evidence",
|
||||
evidence: { evidence_id: source.evidence_id },
|
||||
},
|
||||
completed_observation: {
|
||||
kind: "inline-evidence",
|
||||
retention: "one-shot-session",
|
||||
source_closure: "not-established",
|
||||
evidence: {
|
||||
evidence_id: primary.evidence_id,
|
||||
caller_input_path: path,
|
||||
normalized_result: "completed",
|
||||
},
|
||||
},
|
||||
...(closeFails
|
||||
? {
|
||||
resources: ["fixture-owner"],
|
||||
cleanup: "incomplete",
|
||||
execution_failure: "resource_constraint",
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
const sourceFailure = recordEvidence.mock.results
|
||||
.map(({ value }) => value)
|
||||
.find((value) => value !== undefined && !value.ok);
|
||||
expect(sourceFailure).toBeDefined();
|
||||
expect(
|
||||
primaryAnalysisError(sourceFailure!.error).secondaryFailures,
|
||||
).toContainEqual(expect.objectContaining({ cause: reason }));
|
||||
expect(
|
||||
primaryAnalysisError(sourceFailure!.error).secondaryFailures.some(
|
||||
(failure) =>
|
||||
failure instanceof AnalysisCancelledError &&
|
||||
failure.cause === reason,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(result.error).toMatchObject({
|
||||
details: {
|
||||
secondary_failures: [expect.objectContaining({ code: "cancelled" })],
|
||||
},
|
||||
});
|
||||
expect(session.activeTarget() !== undefined).toBe(closeFails);
|
||||
} finally {
|
||||
close.mockRestore();
|
||||
const closed = await nativeClose();
|
||||
if (!closed.ok) throw closed.error;
|
||||
expect(session.activeTarget()).toBeUndefined();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("reports an omitted completed one-shot result when authentication headroom is exhausted", async () => {
|
||||
const provider = { id: "fixture", name: "Fixture", version: "1" };
|
||||
const source = createEvidence(
|
||||
undefined,
|
||||
provider,
|
||||
{ operation: "health", parameters: {}, result: "historical source" },
|
||||
{ immutable: true },
|
||||
);
|
||||
const path = join(
|
||||
await createTestTempDirectory("rea-oneshot-headroom-"),
|
||||
"fixture.com",
|
||||
);
|
||||
await writeFile(path, Buffer.from([0xb8, 0, 0x4c, 0xcd, 0x21]));
|
||||
const router = createTestProviderRouter(() => ({
|
||||
execute: async () =>
|
||||
ok(
|
||||
createAnalysisExecution("completed", provider, {
|
||||
sourceEvidence: [source],
|
||||
}),
|
||||
),
|
||||
close: async () => ok(null),
|
||||
}));
|
||||
const records = new InvestigationRecords({
|
||||
maximumRepresentationBytes: 1000000,
|
||||
availableAuthenticationBytes: () => 0,
|
||||
});
|
||||
const create = () => new BinarySession(router, records);
|
||||
const dependencies: DirectAnalysisDependencies = {
|
||||
readConfiguration: () => parseConfig({}),
|
||||
createBinarySession: create,
|
||||
createNativeDispatchBinarySession: create,
|
||||
createArtifactBinarySession: create,
|
||||
createNativeBinarySession: create,
|
||||
createManagedBinarySession: create,
|
||||
};
|
||||
const result = await runDirectAnalysis(
|
||||
dependencies,
|
||||
path,
|
||||
"procedure_pseudo_code",
|
||||
{ procedure: "0x100" },
|
||||
{ formatHint: "dos-com" },
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("Expected admission failure");
|
||||
expect(result.error).toMatchObject({
|
||||
code: "resource_constraint",
|
||||
details: {
|
||||
completed_observation_omitted: {
|
||||
reason: "insufficient-authentication-headroom",
|
||||
source_closure: "not-established",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(records.statistics().evidenceRecords).toBe(1);
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
"preserves admission omission during final-close cancellation (close fails: %s)",
|
||||
async (closeFails) => {
|
||||
const provider = { id: "fixture", name: "Fixture", version: "1" };
|
||||
const source = createEvidence(
|
||||
undefined,
|
||||
provider,
|
||||
{ operation: "health", parameters: {}, result: "history" },
|
||||
{ immutable: true },
|
||||
);
|
||||
const path = join(
|
||||
await createTestTempDirectory("rea-admission-final-close-"),
|
||||
"fixture.com",
|
||||
);
|
||||
await writeFile(path, Buffer.from([0xb8, 0, 0x4c, 0xcd, 0x21]));
|
||||
const session = new BinarySession(
|
||||
createTestProviderRouter(() => ({
|
||||
execute: async () =>
|
||||
ok(
|
||||
createAnalysisExecution("completed", provider, {
|
||||
sourceEvidence: [source],
|
||||
}),
|
||||
),
|
||||
close: async () => ok(null),
|
||||
})),
|
||||
new InvestigationRecords({
|
||||
maximumRepresentationBytes: 1000000,
|
||||
availableAuthenticationBytes: () => 0,
|
||||
}),
|
||||
);
|
||||
const create = () => session;
|
||||
const dependencies: DirectAnalysisDependencies = {
|
||||
readConfiguration: () => parseConfig({}),
|
||||
createBinarySession: create,
|
||||
createNativeDispatchBinarySession: create,
|
||||
createArtifactBinarySession: create,
|
||||
createNativeBinarySession: create,
|
||||
createManagedBinarySession: create,
|
||||
};
|
||||
const controller = new AbortController();
|
||||
const originalClose = session.close.bind(session);
|
||||
const failure = new ProviderCleanupError("fixture", ["fixture-owner"], {});
|
||||
const close = vi
|
||||
.spyOn(session, "close")
|
||||
.mockImplementationOnce(async () => {
|
||||
controller.abort({ during: "final close" });
|
||||
return closeFails ? err(failure) : originalClose();
|
||||
});
|
||||
try {
|
||||
const result = await runDirectAnalysis(
|
||||
dependencies,
|
||||
path,
|
||||
"procedure_pseudo_code",
|
||||
{ procedure: "0x100" },
|
||||
{ formatHint: "dos-com", signal: controller.signal },
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("Expected admission failure");
|
||||
expect(result.error).toMatchObject({
|
||||
code: closeFails ? "cleanup_incomplete" : "resource_constraint",
|
||||
details: {
|
||||
completed_observation_omitted: {
|
||||
reason: "insufficient-authentication-headroom",
|
||||
source_closure: "not-established",
|
||||
},
|
||||
secondary_failures: [expect.objectContaining({ code: "cancelled" })],
|
||||
...(closeFails
|
||||
? {
|
||||
resources: ["fixture-owner"],
|
||||
cleanup: "incomplete",
|
||||
execution_failure: "resource_constraint",
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
expect(session.activeTarget() !== undefined).toBe(closeFails);
|
||||
} finally {
|
||||
close.mockRestore();
|
||||
const closed = await originalClose();
|
||||
if (!closed.ok) throw closed.error;
|
||||
}
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user