fix(mcp): retain complete application results across response limits
Retain complete authenticated application Evidence when the repeated MCP representation exceeds the default receive frame or Node string limit. Return a typed transport constraint, keep useful followup queries available, and stream full canonical bundles to atomic destinations. Verify complete Notion and Obsidian workflows on Linux and native Windows, full CLI/Evidence parity, and real large Chrome screenshot recovery. Preserve complete success contracts and caller-selected evidence paths. Refs #1065, #1053.
This commit is contained in:
parent
c701376777
commit
a1ed4232de
|
|
@ -100,17 +100,22 @@ a temporary output file larger than the running engine's string limit, verifies
|
|||
its bytes and digest, and removes it. Use `-- jsonl` for the compact JSONL check.
|
||||
Each check needs space for one output file plus a 1 GiB free-space reserve.
|
||||
|
||||
Other CLI formats, `--token-count`, and MCP transport serialization still
|
||||
assemble whole strings. Field selection remains useful when the caller needs a
|
||||
Other CLI formats and `--token-count` still assemble whole strings.
|
||||
Field selection remains useful when the caller needs a
|
||||
smaller view, for example `--format json --filter-output
|
||||
evidence_id,normalized_result.statistics`. Streaming output does not bound the
|
||||
memory needed to construct the analysis graph itself.
|
||||
The separate large-response transport work is tracked in
|
||||
[#1053](https://github.com/morluto/rea/issues/1053) and
|
||||
MCP prepares the complete repeated response incrementally against the pinned
|
||||
SDK's 10 MiB stdio receive-buffer budget. Oversized results return an actionable
|
||||
transport constraint and the exact same-session Evidence reference. Use
|
||||
`trace_application_feature` to inspect a selected module's relationships, or
|
||||
`export_evidence_bundle` to write the complete canonical bundle without a
|
||||
document-sized allocation. Same-session analysis reads reuse authenticated
|
||||
immutable snapshots; foreign inline Evidence is still parsed and authenticated.
|
||||
See [MCP tool results](mcp-contracts.md#tool-results) for larger client buffers
|
||||
and `REA_MCP_MAX_RESPONSE_BYTES`. Follow-up results remain complete and can also
|
||||
exceed the transport budget. Compact result views are tracked separately in
|
||||
[#1050](https://github.com/morluto/rea/issues/1050).
|
||||
Client framing limits also apply: the pinned Node MCP SDK's stdio transport
|
||||
defaults to a 10 MiB buffer. Its caller-selected `maxBufferSize` must accommodate
|
||||
the complete response, including text and structured Evidence projections.
|
||||
|
||||
## What is reconstructed
|
||||
|
||||
|
|
|
|||
|
|
@ -164,6 +164,26 @@ directly to `compare_functions`, and `inspect_artifact` Evidence to
|
|||
`compare_artifacts`. Use `get_evidence_bundle` when the task needs broader
|
||||
retained session history or an explicit bundle for transfer.
|
||||
|
||||
REA prepares complete MCP results within the pinned stdio client's 10 MiB
|
||||
receive-buffer budget, including both text and structured representations and
|
||||
room for the JSON-RPC envelope. If a result cannot fit, REA returns
|
||||
`resource_constraint` with `details.resource: "transport"` before constructing
|
||||
a document-sized string. Analysis Evidence remains complete in the current
|
||||
session. Its exact reference is reported in
|
||||
`details.reported_limits.evidence_reference`; use it with a focused application
|
||||
workflow, or call `export_evidence_bundle` with a destination path. Complete
|
||||
bundle exports stream canonical JSON into an atomically published file. A broad
|
||||
follow-up or `get_evidence_bundle` can also exceed the response budget; exporting
|
||||
preserves the complete session without sending it through a single MCP frame.
|
||||
|
||||
Clients that explicitly configure a larger receive buffer can set the REA
|
||||
server's `REA_MCP_MAX_RESPONSE_BYTES` environment variable to the same byte
|
||||
count. This setting must be a safe decimal integer at least 10485760; REA
|
||||
reserves 1024 bytes for the envelope. Raising it restores complete inline
|
||||
delivery for responses that fit that buffer and Node's single-string limit.
|
||||
It does not change the client's buffer, analysis coverage, or retained content.
|
||||
Ordinary responses keep their existing complete result contract.
|
||||
|
||||
## Retained application Evidence inputs
|
||||
|
||||
`trace_application_feature`, `trace_javascript_semantics`,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { chromium } from "playwright-core";
|
||||
|
|
@ -62,13 +65,21 @@ export async function verifyLargeScreenshotE2e(endpoint, origin) {
|
|||
);
|
||||
assert.equal(bytes.readUInt32BE(16), 2048);
|
||||
assert.equal(bytes.readUInt32BE(20), 1536);
|
||||
await verifyDefaultScreenshotDelivery({
|
||||
entrypoint,
|
||||
environment: env,
|
||||
endpoint,
|
||||
targetId: target.id,
|
||||
screenshot,
|
||||
});
|
||||
const maxBufferSize = 64 * 1024 * 1024;
|
||||
transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [entrypoint, "mcp"],
|
||||
env,
|
||||
env: { ...env, REA_MCP_MAX_RESPONSE_BYTES: String(maxBufferSize) },
|
||||
stderr: "pipe",
|
||||
// The SDK defaults to a 10 MiB receive buffer; inline PNG output is larger.
|
||||
maxBufferSize: 64 * 1024 * 1024,
|
||||
maxBufferSize,
|
||||
});
|
||||
client = new Client({ name: "browser-screenshot-real-e2e", version: "1" });
|
||||
await client.connect(transport);
|
||||
|
|
@ -102,6 +113,9 @@ export async function verifyLargeScreenshotE2e(endpoint, origin) {
|
|||
mocked: false,
|
||||
cli: true,
|
||||
stdio_mcp: true,
|
||||
default_buffer_recovery: true,
|
||||
complete_export: true,
|
||||
matched_large_buffer: true,
|
||||
png_bytes: bytes.byteLength,
|
||||
};
|
||||
} finally {
|
||||
|
|
@ -117,3 +131,73 @@ export async function verifyLargeScreenshotE2e(endpoint, origin) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
const verifyDefaultScreenshotDelivery = async ({
|
||||
entrypoint,
|
||||
environment,
|
||||
endpoint,
|
||||
targetId,
|
||||
screenshot,
|
||||
}) => {
|
||||
const workspace = await mkdtemp(join(tmpdir(), "rea-screenshot-delivery-"));
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [entrypoint, "mcp"],
|
||||
env: environment,
|
||||
stderr: "pipe",
|
||||
});
|
||||
const client = new Client({
|
||||
name: "browser-screenshot-default-e2e",
|
||||
version: "1",
|
||||
});
|
||||
try {
|
||||
await client.connect(transport);
|
||||
const constrained = await client.callTool(
|
||||
{
|
||||
name: "capture_web_screenshot",
|
||||
arguments: { cdp_endpoint: endpoint, target_id: targetId },
|
||||
},
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
assert.equal(constrained.isError, true);
|
||||
assert.equal(
|
||||
constrained.structuredContent?.error?.details?.resource,
|
||||
"transport",
|
||||
);
|
||||
const reference =
|
||||
constrained.structuredContent?.error?.details?.reported_limits
|
||||
?.evidence_reference;
|
||||
assert.equal(reference?.kind, "retained-evidence");
|
||||
await client.ping();
|
||||
const exported = await client.callTool({
|
||||
name: "export_evidence_bundle",
|
||||
arguments: { path: join(workspace, "screenshot-evidence.json") },
|
||||
});
|
||||
assert.notEqual(exported.isError, true, JSON.stringify(exported));
|
||||
const bundle = JSON.parse(
|
||||
await readFile(join(workspace, "screenshot-evidence.json"), "utf8"),
|
||||
);
|
||||
const record = bundle.records.find(
|
||||
(record) => record.evidence_id === reference.evidence_id,
|
||||
);
|
||||
assert.ok(
|
||||
record,
|
||||
"Full screenshot Evidence must remain exportable after the delivery constraint",
|
||||
);
|
||||
const retained = webScreenshotSchema.parse(
|
||||
parseEvidence(record).normalized_result,
|
||||
);
|
||||
assert.deepEqual(retained.artifact, screenshot.artifact);
|
||||
await client.ping();
|
||||
} finally {
|
||||
try {
|
||||
await client.close();
|
||||
} finally {
|
||||
try {
|
||||
await transport.close();
|
||||
} finally {
|
||||
await rm(workspace, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -10,7 +10,10 @@ export const withLargeResultMcp = async (
|
|||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [entrypoint, "mcp"],
|
||||
env: environment,
|
||||
env: {
|
||||
...environment,
|
||||
REA_MCP_MAX_RESPONSE_BYTES: String(maxBufferSize),
|
||||
},
|
||||
stderr: "inherit",
|
||||
maxBufferSize,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -144,6 +144,13 @@ Start with the default result and use its inline Evidence and graph context.
|
|||
Do not repeat an identical tool call. Make a focused follow-up only when the
|
||||
returned result leaves a specific question unanswered.
|
||||
|
||||
If MCP reports `resource_constraint` with `details.resource: "transport"`, use
|
||||
its reported same-session Evidence reference with a focused application tool,
|
||||
or call `export_evidence_bundle` to write the complete session to a selected
|
||||
path. The analysis remains complete in the ledger; avoid repeating it merely
|
||||
to request the same oversized response. Complete CLI JSON output also streams
|
||||
without a single MCP frame.
|
||||
|
||||
Every conclusion must distinguish observations, inferences, and unknowns. Cite
|
||||
Evidence IDs, preserve limitations and incomplete coverage, and never imply
|
||||
that static analysis observed execution. Runtime requests execute the declared
|
||||
|
|
|
|||
|
|
@ -2,10 +2,17 @@
|
|||
|
||||
Use `analyze_javascript_application` directly on the operator-supplied ASAR or
|
||||
extracted tree. The complete result, graph, and Evidence context are returned
|
||||
inline. Source-map contents are part of the static analysis. Start from its
|
||||
inline when they fit the MCP frame. Source-map contents are part of the static analysis. Start from its
|
||||
findings, coverage, unknowns, and graph context, then make a focused follow-up
|
||||
only when a specific question remains unanswered.
|
||||
|
||||
An oversized response reports `resource_constraint` and
|
||||
`details.resource: "transport"`. Reuse
|
||||
`details.reported_limits.evidence_reference` with `trace_application_feature`
|
||||
for the module or feature under investigation. `export_evidence_bundle` writes
|
||||
the complete canonical session to a caller-selected file. Both workflows keep
|
||||
the original Evidence and coverage; a broad follow-up can also exceed framing.
|
||||
|
||||
BrowserWindow preferences, preload and contextBridge surfaces, IPC
|
||||
registrations, utility processes, and native binding requests are static syntax
|
||||
observations. Only a unique exact literal IPC channel match supports an inferred
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import {
|
|||
describeEvidenceBundleFailure,
|
||||
describeValidationFailure,
|
||||
parseEvidenceBundle,
|
||||
serializeEvidenceBundle,
|
||||
type EvidenceBundle,
|
||||
} from "../domain/evidenceBundle.js";
|
||||
import {
|
||||
|
|
@ -11,7 +10,11 @@ import {
|
|||
} from "../domain/evidenceErrors.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { parseProcessCapture } from "../domain/process/processCapture.js";
|
||||
import { readJsonFile, writeTextFile } from "./JsonFiles.js";
|
||||
import {
|
||||
bufferedJsonParts,
|
||||
canonicalJsonParts,
|
||||
} from "../domain/jsonSerialization.js";
|
||||
import { readJsonFile, writeTextParts } from "./JsonFiles.js";
|
||||
|
||||
type EvidenceReadFailure = EvidenceFileError | EvidenceIntegrityError;
|
||||
type EvidenceWriteFailure = EvidenceFileError | EvidenceIntegrityError;
|
||||
|
|
@ -60,9 +63,9 @@ export const writeEvidenceBundle = async (
|
|||
EvidenceWriteFailure
|
||||
>
|
||||
> => {
|
||||
let encoded: string;
|
||||
let checked: EvidenceBundle;
|
||||
try {
|
||||
encoded = serializeEvidenceBundle(bundle);
|
||||
checked = parseEvidenceBundle(bundle);
|
||||
} catch (cause: unknown) {
|
||||
return err(
|
||||
new EvidenceIntegrityError("Evidence bundle validation failed", {
|
||||
|
|
@ -70,5 +73,9 @@ export const writeEvidenceBundle = async (
|
|||
}),
|
||||
);
|
||||
}
|
||||
return writeTextFile(encoded, path, overwrite);
|
||||
return writeTextParts(
|
||||
bufferedJsonParts(canonicalJsonParts(checked)),
|
||||
path,
|
||||
overwrite,
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,8 +1,15 @@
|
|||
import { link, lstat, mkdtemp, readFile, realpath, rm } from "node:fs/promises";
|
||||
import {
|
||||
link,
|
||||
lstat,
|
||||
mkdtemp,
|
||||
open,
|
||||
readFile,
|
||||
realpath,
|
||||
rename,
|
||||
rm,
|
||||
} from "node:fs/promises";
|
||||
import { basename, dirname, resolve } from "node:path";
|
||||
|
||||
import writeFileAtomic from "write-file-atomic";
|
||||
|
||||
import { EvidenceFileError } from "../domain/evidenceErrors.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
|
||||
|
|
@ -52,8 +59,16 @@ export const writeTextFile = async (
|
|||
overwrite: boolean,
|
||||
): Promise<
|
||||
Result<{ readonly path: string; readonly bytes: number }, EvidenceFileError>
|
||||
> => writeTextParts([encoded], path, overwrite);
|
||||
|
||||
/** Publish complete streamed text atomically, keeping only one encoded part in memory. */
|
||||
export const writeTextParts = async (
|
||||
parts: Iterable<string>,
|
||||
path: string,
|
||||
overwrite: boolean,
|
||||
): Promise<
|
||||
Result<{ readonly path: string; readonly bytes: number }, EvidenceFileError>
|
||||
> => {
|
||||
const bytes = Buffer.byteLength(encoded, "utf8");
|
||||
const requestedPath = resolve(path);
|
||||
try {
|
||||
const canonicalParent = await realpath(dirname(requestedPath));
|
||||
|
|
@ -72,13 +87,7 @@ export const writeTextFile = async (
|
|||
new EvidenceFileError("write", "not-file", { path: requestedPath }),
|
||||
);
|
||||
}
|
||||
if (overwrite)
|
||||
await writeFileAtomic(destination, encoded, {
|
||||
encoding: "utf8",
|
||||
mode: 0o600,
|
||||
fsync: true,
|
||||
});
|
||||
else await publishNewFile(destination, encoded);
|
||||
const bytes = await publishFile(destination, parts, overwrite);
|
||||
return ok({ path: requestedPath, bytes });
|
||||
} catch (cause: unknown) {
|
||||
if (!overwrite && fileErrorCode(cause) === "EEXIST")
|
||||
|
|
@ -103,22 +112,31 @@ const missingOrIo = (cause: unknown): "missing" | "io" => {
|
|||
return code === "ENOENT" || code === "ENOTDIR" ? "missing" : "io";
|
||||
};
|
||||
|
||||
const publishNewFile = async (
|
||||
const publishFile = async (
|
||||
destination: string,
|
||||
encoded: string,
|
||||
): Promise<void> => {
|
||||
parts: Iterable<string>,
|
||||
overwrite: boolean,
|
||||
): Promise<number> => {
|
||||
const stagingDirectory = await mkdtemp(
|
||||
resolve(dirname(destination), ".rea-write-"),
|
||||
);
|
||||
try {
|
||||
const staged = resolve(stagingDirectory, "content");
|
||||
await writeFileAtomic(staged, encoded, {
|
||||
encoding: "utf8",
|
||||
mode: 0o600,
|
||||
fsync: true,
|
||||
});
|
||||
const file = await open(staged, "wx", 0o600);
|
||||
let bytes = 0;
|
||||
try {
|
||||
for (const part of parts) {
|
||||
await file.writeFile(part, { encoding: "utf8" });
|
||||
bytes += Buffer.byteLength(part, "utf8");
|
||||
}
|
||||
await file.sync();
|
||||
} finally {
|
||||
await file.close();
|
||||
}
|
||||
if (overwrite) await rename(staged, destination);
|
||||
// A hard link publishes complete bytes atomically and cannot replace an existing name.
|
||||
await link(staged, destination);
|
||||
else await link(staged, destination);
|
||||
return bytes;
|
||||
} finally {
|
||||
await rm(stagingDirectory, { recursive: true, force: true });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,10 +69,20 @@ export abstract class BinarySessionRecords {
|
|||
return this.#records.evidenceById(evidenceId);
|
||||
}
|
||||
|
||||
/** Borrow immutable investigation Evidence without copying a complete graph. */
|
||||
evidenceForAnalysis(evidenceId: string): Evidence | undefined {
|
||||
return this.#records.evidenceForAnalysis(evidenceId);
|
||||
}
|
||||
|
||||
exportEvidenceBundle(): EvidenceBundle {
|
||||
return this.#records.exportEvidenceBundle();
|
||||
}
|
||||
|
||||
/** Borrow a sealed bundle for complete serialization without cloning retained graphs. */
|
||||
evidenceBundleForSerialization(): EvidenceBundle {
|
||||
return this.#records.evidenceBundleForSerialization();
|
||||
}
|
||||
|
||||
importEvidenceBundle(
|
||||
bundle: unknown,
|
||||
): Result<number, EvidenceIntegrityError> {
|
||||
|
|
|
|||
|
|
@ -116,6 +116,36 @@ describe("evidence ledger recording", () => {
|
|||
ledger.clear();
|
||||
expect(ledger.export().records).toEqual([]);
|
||||
});
|
||||
|
||||
it("protects borrowed snapshots while preserving detached mutable public reads", () => {
|
||||
const evidence = createEvidence(TARGET, PROVIDER, {
|
||||
operation: "health",
|
||||
parameters: {},
|
||||
result: { nested: { value: "original" } },
|
||||
});
|
||||
const ledger = new EvidenceLedger();
|
||||
expect(ledger.record(evidence).ok).toBe(true);
|
||||
const borrowed = ledger.forAnalysis(evidence.evidence_id);
|
||||
if (borrowed === undefined) throw new Error("Missing recorded Evidence");
|
||||
expect(Object.isFrozen(borrowed)).toBe(true);
|
||||
expect(Object.isFrozen(borrowed.normalized_result)).toBe(true);
|
||||
expect(Reflect.set(borrowed, "operation", "forged")).toBe(false);
|
||||
expect(ledger.forAnalysis(evidence.evidence_id)).toBe(borrowed);
|
||||
expect(ledger.record(borrowed)).toEqual({ ok: true, value: "duplicate" });
|
||||
const detached = ledger.get(evidence.evidence_id);
|
||||
if (detached === undefined) throw new Error("Missing detached Evidence");
|
||||
expect(Reflect.set(detached, "operation", "forged")).toBe(true);
|
||||
expect(ledger.record(detached).ok).toBe(false);
|
||||
expect(ledger.forAnalysis(evidence.evidence_id)).toBe(borrowed);
|
||||
const snapshot = ledger.forSerialization();
|
||||
expect(snapshot).toEqual(ledger.export());
|
||||
expect(snapshot.records[0]).toBe(borrowed);
|
||||
expect(Object.isFrozen(snapshot.records)).toBe(true);
|
||||
ledger.clear();
|
||||
expect(ledger.forAnalysis(evidence.evidence_id)).toBeUndefined();
|
||||
expect(ledger.forSerialization().records).toEqual([]);
|
||||
expect(snapshot.records).toEqual([evidence]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("evidence ledger metadata imports", () => {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import canonicalize from "canonicalize";
|
|||
|
||||
import {
|
||||
createEvidenceBundle,
|
||||
createImmutableEvidenceBundle,
|
||||
describeEvidenceBundleFailure,
|
||||
parseEvidenceBundle,
|
||||
validateResidualUnknownAddition,
|
||||
|
|
@ -9,7 +10,11 @@ import {
|
|||
} from "../../domain/evidenceBundle.js";
|
||||
import { EvidenceIntegrityError } from "../../domain/evidenceErrors.js";
|
||||
import { UnknownRegistryError } from "../../domain/unknownRegistryError.js";
|
||||
import { parseEvidence, type Evidence } from "../../domain/evidence.js";
|
||||
import {
|
||||
immutableEvidence,
|
||||
parseEvidence,
|
||||
type Evidence,
|
||||
} from "../../domain/evidence.js";
|
||||
import {
|
||||
createResidualUnknown,
|
||||
updateResidualUnknown,
|
||||
|
|
@ -42,7 +47,7 @@ export class EvidenceLedger {
|
|||
record(input: Evidence): RecordResult {
|
||||
let evidence: Evidence;
|
||||
try {
|
||||
evidence = parseEvidence(input);
|
||||
evidence = immutableEvidence(input);
|
||||
} catch (cause: unknown) {
|
||||
return err(
|
||||
new EvidenceIntegrityError("Evidence record validation failed", {
|
||||
|
|
@ -52,6 +57,10 @@ export class EvidenceLedger {
|
|||
}
|
||||
const existing = this.#records.get(evidence.evidence_id);
|
||||
if (existing !== undefined) {
|
||||
// A borrowed record is already authenticated and deeply immutable. Its
|
||||
// identity cannot change between reads, so comparing document-sized
|
||||
// canonical strings again would add no integrity check.
|
||||
if (existing === evidence) return ok("duplicate");
|
||||
return recordsAgree(existing, evidence)
|
||||
? ok("duplicate")
|
||||
: err(new EvidenceIntegrityError("Conflicting evidence record"));
|
||||
|
|
@ -97,8 +106,9 @@ export class EvidenceLedger {
|
|||
additions.set(evidence.evidence_id, evidence);
|
||||
}
|
||||
for (const [id, evidence] of replacements)
|
||||
this.#records.set(id, evidence);
|
||||
for (const [id, evidence] of additions) this.#records.set(id, evidence);
|
||||
this.#records.set(id, immutableEvidence(evidence));
|
||||
for (const [id, evidence] of additions)
|
||||
this.#records.set(id, immutableEvidence(evidence));
|
||||
return ok({
|
||||
recordsAdded: additions.size,
|
||||
unknownsAdded: 0,
|
||||
|
|
@ -165,6 +175,14 @@ export class EvidenceLedger {
|
|||
);
|
||||
}
|
||||
|
||||
/** Borrow a sealed canonical bundle for serialization without copying its records. */
|
||||
forSerialization(): EvidenceBundle {
|
||||
return createImmutableEvidenceBundle(
|
||||
[...this.#records.values()],
|
||||
[...this.#unknownRevisions.values()],
|
||||
);
|
||||
}
|
||||
|
||||
/** Check whether one immutable Evidence ID is present in this ledger. */
|
||||
has(evidenceId: string): boolean {
|
||||
return this.#records.has(evidenceId);
|
||||
|
|
@ -176,6 +194,11 @@ export class EvidenceLedger {
|
|||
return evidence === undefined ? undefined : structuredClone(evidence);
|
||||
}
|
||||
|
||||
/** Borrow authenticated immutable Evidence for read-only analysis without a full clone. */
|
||||
forAnalysis(evidenceId: string): Evidence | undefined {
|
||||
return this.#records.get(evidenceId);
|
||||
}
|
||||
|
||||
/** Create one approved unknown and atomically append its mutation evidence. */
|
||||
recordUnknown(
|
||||
input: RecordUnknownInput,
|
||||
|
|
@ -380,7 +403,8 @@ export class EvidenceLedger {
|
|||
} catch (cause: unknown) {
|
||||
return err(new UnknownRegistryError("integrity", { cause }));
|
||||
}
|
||||
for (const [id, evidence] of additions) this.#records.set(id, evidence);
|
||||
for (const [id, evidence] of additions)
|
||||
this.#records.set(id, immutableEvidence(evidence));
|
||||
if (unknown !== undefined && unknownKey !== undefined) {
|
||||
this.#unknownRevisions.set(unknownKey, unknown);
|
||||
this.#unknownHeads.set(unknown.unknown_id, unknown);
|
||||
|
|
@ -407,7 +431,8 @@ export class EvidenceLedger {
|
|||
unknowns: ReadonlyMap<string, ResidualUnknown>,
|
||||
): void {
|
||||
this.#records.clear();
|
||||
for (const [id, evidence] of records) this.#records.set(id, evidence);
|
||||
for (const [id, evidence] of records)
|
||||
this.#records.set(id, immutableEvidence(evidence));
|
||||
this.#unknownRevisions.clear();
|
||||
this.#unknownHeads.clear();
|
||||
for (const [key, unknown] of unknowns) {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,10 @@ import type { Result } from "../../domain/result.js";
|
|||
export interface EvidenceReader {
|
||||
hasEvidence(evidenceId: string): boolean;
|
||||
evidenceById(evidenceId: string): Evidence | undefined;
|
||||
/** Optional immutable analysis read; older adapters can keep detached reads. */
|
||||
evidenceForAnalysis?(evidenceId: string): Evidence | undefined;
|
||||
/** Optional immutable bundle snapshot for complete serialization without graph copies. */
|
||||
evidenceBundleForSerialization?(): EvidenceBundle;
|
||||
exportEvidenceBundle(): EvidenceBundle;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -51,11 +51,21 @@ export class InvestigationRecords
|
|||
return this.#evidence.get(evidenceId);
|
||||
}
|
||||
|
||||
/** Borrow an immutable authenticated record for a read-only application workflow. */
|
||||
evidenceForAnalysis(evidenceId: string): Evidence | undefined {
|
||||
return this.#evidence.forAnalysis(evidenceId);
|
||||
}
|
||||
|
||||
/** Export a detached deterministic bundle of Evidence and Unknown revisions. */
|
||||
exportEvidenceBundle(): EvidenceBundle {
|
||||
return this.#evidence.export();
|
||||
}
|
||||
|
||||
/** Borrow a sealed bundle snapshot for complete JSON serialization. */
|
||||
evidenceBundleForSerialization(): EvidenceBundle {
|
||||
return this.#evidence.forSerialization();
|
||||
}
|
||||
|
||||
/** Atomically merge a bundle and report changes for the owner's post-commit observers. */
|
||||
mergeEvidenceBundle(
|
||||
bundle: unknown,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,18 @@ import type {
|
|||
JavaScriptApplicationAnalysisResult,
|
||||
} from "../../domain/javascript/javascriptApplicationAnalysis.js";
|
||||
import { JAVASCRIPT_APPLICATION_PROVIDER } from "../InvestigationProviders.js";
|
||||
import { freezeJsonSnapshot } from "../../domain/immutableJson.js";
|
||||
import { rememberOwnedApplicationGraphEvidence } from "./JavaScriptApplicationEvidenceGraph.js";
|
||||
|
||||
/** Transfer an already validated, application-owned result into immutable analysis Evidence. */
|
||||
export const createOwnedJavaScriptApplicationEvidence = (
|
||||
input: AnalyzeJavaScriptApplicationInput,
|
||||
result: JavaScriptApplicationAnalysisResult,
|
||||
): Evidence =>
|
||||
rememberOwnedApplicationGraphEvidence(
|
||||
createJavaScriptApplicationEvidence(input, freezeJsonSnapshot(result)),
|
||||
result,
|
||||
);
|
||||
|
||||
/** Create Evidence for one deterministic local JavaScript application graph. */
|
||||
export const createJavaScriptApplicationEvidence = (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,47 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE } from "../../contracts/javascript/javascriptRuntimeReconciliationExample.js";
|
||||
import { parseEvidence } from "../../domain/evidence.js";
|
||||
import {
|
||||
analyzeJavaScriptApplicationInputSchema,
|
||||
javascriptApplicationAnalysisResultSchema,
|
||||
} from "../../domain/javascript/javascriptApplicationAnalysis.js";
|
||||
import {
|
||||
createJavaScriptApplicationEvidence,
|
||||
createOwnedJavaScriptApplicationEvidence,
|
||||
} from "./JavaScriptApplicationEvidence.js";
|
||||
import {
|
||||
parseApplicationGraphEvidence,
|
||||
rememberOwnedApplicationGraphEvidence,
|
||||
} from "./JavaScriptApplicationEvidenceGraph.js";
|
||||
|
||||
describe("owned application graph Evidence", () => {
|
||||
it("reuses the exact authenticated graph and preserves ordinary wire identity", () => {
|
||||
const result = javascriptApplicationAnalysisResultSchema.parse(
|
||||
JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE.normalized_result,
|
||||
);
|
||||
const input = analyzeJavaScriptApplicationInputSchema.parse({
|
||||
input_path: result.input_path,
|
||||
...JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE.parameters,
|
||||
});
|
||||
const ordinary = createJavaScriptApplicationEvidence(input, result);
|
||||
const owned = createOwnedJavaScriptApplicationEvidence(input, result);
|
||||
expect(owned.evidence_id).toBe(ordinary.evidence_id);
|
||||
expect(JSON.stringify(owned)).toBe(JSON.stringify(ordinary));
|
||||
const parsed = parseApplicationGraphEvidence(owned);
|
||||
expect(parsed.evidence).toBe(owned);
|
||||
expect(parsed.graph).toBe(result.graph);
|
||||
expect(parsed.semanticGraph).toBe(result.semantic_graph);
|
||||
expect(Object.isFrozen(parsed.graph.nodes)).toBe(true);
|
||||
expect(parseApplicationGraphEvidence(owned)).toBe(parsed);
|
||||
const wire: unknown = JSON.parse(JSON.stringify(owned));
|
||||
expect(parseApplicationGraphEvidence(wire)).toEqual(parsed);
|
||||
expect(parseApplicationGraphEvidence(wire).graph).not.toBe(parsed.graph);
|
||||
expect(() =>
|
||||
rememberOwnedApplicationGraphEvidence(ordinary, result),
|
||||
).toThrow("exact validated immutable result");
|
||||
const tampered = { ...owned, normalized_result: {} };
|
||||
expect(() => parseEvidence(Object.freeze(tampered))).toThrow();
|
||||
expect(() => parseApplicationGraphEvidence(tampered)).toThrow();
|
||||
});
|
||||
});
|
||||
|
|
@ -3,7 +3,11 @@ import {
|
|||
JAVASCRIPT_RUNTIME_RECONCILIATION_PROVIDER,
|
||||
MANAGED_WORKFLOW_PROVIDER,
|
||||
} from "../InvestigationProviders.js";
|
||||
import { parseEvidence, type Evidence } from "../../domain/evidence.js";
|
||||
import {
|
||||
isImmutableEvidence,
|
||||
parseEvidence,
|
||||
type Evidence,
|
||||
} from "../../domain/evidence.js";
|
||||
import {
|
||||
analyzeJavaScriptApplicationInputSchema,
|
||||
javascriptApplicationAnalysisResultSchema,
|
||||
|
|
@ -26,11 +30,39 @@ export interface ApplicationGraphEvidenceSource {
|
|||
readonly semanticGraph: JavaScriptSemanticGraph | null;
|
||||
}
|
||||
|
||||
const ownedApplicationSources = new WeakMap<
|
||||
Evidence,
|
||||
ApplicationGraphEvidenceSource
|
||||
>();
|
||||
|
||||
/** Retain the producer-validated meaning of its exact immutable result snapshot. */
|
||||
export const rememberOwnedApplicationGraphEvidence = (
|
||||
evidence: Evidence,
|
||||
result: JavaScriptApplicationAnalysisResult,
|
||||
): Evidence => {
|
||||
if (
|
||||
!isImmutableEvidence(evidence) ||
|
||||
evidence.normalized_result !== result ||
|
||||
evidence.operation !== "analyze_javascript_application" ||
|
||||
!providerMatches(evidence, JAVASCRIPT_APPLICATION_PROVIDER)
|
||||
)
|
||||
throw new TypeError(
|
||||
"Owned application Evidence must bind its exact validated immutable result",
|
||||
);
|
||||
ownedApplicationSources.set(
|
||||
evidence,
|
||||
Object.freeze(staticApplicationSourceForResult(evidence, result)),
|
||||
);
|
||||
return evidence;
|
||||
};
|
||||
|
||||
/** Parse and authenticate one REA-produced JavaScript Application Graph Evidence. */
|
||||
export const parseApplicationGraphEvidence = (
|
||||
input: unknown,
|
||||
): ApplicationGraphEvidenceSource => {
|
||||
const evidence = parseEvidence(input);
|
||||
const owned = ownedApplicationSources.get(evidence);
|
||||
if (owned !== undefined) return owned;
|
||||
if (
|
||||
evidence.operation === "analyze_javascript_application" &&
|
||||
evidence.predicate_type === "rea.javascript-application-analysis" &&
|
||||
|
|
@ -56,6 +88,16 @@ export const parseApplicationGraphEvidence = (
|
|||
|
||||
const staticApplicationSource = (
|
||||
evidence: Evidence,
|
||||
): ApplicationGraphEvidenceSource => {
|
||||
const result = javascriptApplicationAnalysisResultSchema.parse(
|
||||
evidence.normalized_result,
|
||||
);
|
||||
return staticApplicationSourceForResult(evidence, result);
|
||||
};
|
||||
|
||||
const staticApplicationSourceForResult = (
|
||||
evidence: Evidence,
|
||||
result: JavaScriptApplicationAnalysisResult,
|
||||
): ApplicationGraphEvidenceSource => {
|
||||
if (
|
||||
evidence.authority !== "shipped-artifact" ||
|
||||
|
|
@ -64,9 +106,6 @@ const staticApplicationSource = (
|
|||
throw new TypeError(
|
||||
"JavaScript application Evidence authority or confidence is invalid",
|
||||
);
|
||||
const result = javascriptApplicationAnalysisResultSchema.parse(
|
||||
evidence.normalized_result,
|
||||
);
|
||||
if (evidence.predicate_type !== "rea.javascript-application-analysis")
|
||||
throw new TypeError(
|
||||
"JavaScript application Evidence predicate does not match its result shape",
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import { projectInputIssues } from "../../domain/inputIssueProjection.js";
|
|||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
|
||||
import type { ExecutionOptions } from "../AnalysisProvider.js";
|
||||
import { createJavaScriptApplicationEvidence } from "./JavaScriptApplicationEvidence.js";
|
||||
import { createOwnedJavaScriptApplicationEvidence } from "./JavaScriptApplicationEvidence.js";
|
||||
import { reconstructJavaScriptArtifact } from "./JavaScriptArtifactReconstruction.js";
|
||||
import { JAVASCRIPT_APPLICATION_PROVIDER } from "../InvestigationProviders.js";
|
||||
|
||||
|
|
@ -77,7 +77,7 @@ export const analyzeJavaScriptApplicationValidated = async (
|
|||
total: 1,
|
||||
message: "Creating and hashing application analysis Evidence",
|
||||
});
|
||||
const evidence = createJavaScriptApplicationEvidence(input, result);
|
||||
const evidence = createOwnedJavaScriptApplicationEvidence(input, result);
|
||||
await options.progress?.report({
|
||||
phase: "analyze_javascript_application",
|
||||
completed: 1,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import type { Writable } from "node:stream";
|
||||
|
||||
import { bufferedJsonParts, jsonParts } from "../domain/jsonSerialization.js";
|
||||
|
||||
type JsonOutputFormat = "json" | "jsonl";
|
||||
|
||||
/** Write ordinary JSON without constructing a document-sized string. */
|
||||
|
|
@ -8,7 +10,7 @@ export const writeJsonOutput = async (
|
|||
destination: Writable,
|
||||
format: JsonOutputFormat = "json",
|
||||
): Promise<void> => {
|
||||
for (const part of bufferedParts(documentParts(value, format)))
|
||||
for (const part of bufferedJsonParts(documentParts(value, format)))
|
||||
await writePart(destination, part);
|
||||
};
|
||||
|
||||
|
|
@ -39,115 +41,11 @@ function* documentParts(
|
|||
): Generator<string> {
|
||||
if (format === "jsonl" && Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
yield* encodeValue(item, false, 0, new Set());
|
||||
yield* jsonParts(item);
|
||||
yield "\n";
|
||||
}
|
||||
return;
|
||||
}
|
||||
yield* encodeValue(value, format === "json", 0, new Set());
|
||||
yield* jsonParts(value, format === "json");
|
||||
yield "\n";
|
||||
}
|
||||
|
||||
function* bufferedParts(parts: Iterable<string>): Generator<string> {
|
||||
let buffer = "";
|
||||
for (const part of parts) {
|
||||
if (buffer.length + part.length > 64 * 1024 && buffer !== "") {
|
||||
yield buffer;
|
||||
buffer = "";
|
||||
}
|
||||
buffer += part;
|
||||
}
|
||||
if (buffer !== "") yield buffer;
|
||||
}
|
||||
|
||||
function* encodeString(value: string): Generator<string> {
|
||||
if (value.length <= 8192) {
|
||||
yield JSON.stringify(value);
|
||||
return;
|
||||
}
|
||||
yield '"';
|
||||
for (let start = 0; start < value.length;) {
|
||||
let end = Math.min(start + 8192, value.length);
|
||||
const last = value.charCodeAt(end - 1);
|
||||
const next = value.charCodeAt(end);
|
||||
// Keep a surrogate pair in the same JSON.stringify and UTF-8 write.
|
||||
if (
|
||||
end < value.length &&
|
||||
last >= 0xd800 &&
|
||||
last <= 0xdbff &&
|
||||
next >= 0xdc00 &&
|
||||
next <= 0xdfff
|
||||
)
|
||||
end -= 1;
|
||||
yield JSON.stringify(value.slice(start, end)).slice(1, -1);
|
||||
start = end;
|
||||
}
|
||||
yield '"';
|
||||
}
|
||||
|
||||
function* encodeValue(
|
||||
value: unknown,
|
||||
pretty: boolean,
|
||||
depth: number,
|
||||
ancestors: Set<object>,
|
||||
): Generator<string> {
|
||||
if (typeof value === "string") {
|
||||
yield* encodeString(value);
|
||||
return;
|
||||
}
|
||||
if (value === null || typeof value === "boolean") {
|
||||
yield value === null ? "null" : String(value);
|
||||
return;
|
||||
}
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
yield String(value);
|
||||
return;
|
||||
}
|
||||
if (typeof value !== "object" || value === null)
|
||||
throw new TypeError("CLI JSON output requires ordinary JSON values");
|
||||
if (ancestors.has(value))
|
||||
throw new TypeError("CLI JSON output contains a circular reference");
|
||||
if (
|
||||
!Array.isArray(value) &&
|
||||
Object.prototype.toString.call(value) !== "[object Object]"
|
||||
)
|
||||
throw new TypeError("CLI JSON output requires ordinary JSON objects");
|
||||
ancestors.add(value);
|
||||
try {
|
||||
const array = Array.isArray(value);
|
||||
const keys = array
|
||||
? undefined
|
||||
: Object.keys(value).filter((key) => {
|
||||
const item: unknown = Reflect.get(value, key);
|
||||
// Incur's filter can select inherited methods. Match native JSON's
|
||||
// omission of fields that have no JSON representation.
|
||||
return (
|
||||
item !== undefined &&
|
||||
typeof item !== "function" &&
|
||||
typeof item !== "symbol"
|
||||
);
|
||||
});
|
||||
const count = array ? value.length : (keys?.length ?? 0);
|
||||
yield array ? "[" : "{";
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
if (index > 0) yield ",";
|
||||
if (pretty) yield `\n${" ".repeat(depth + 1)}`;
|
||||
let item: unknown;
|
||||
if (array) {
|
||||
item = value[index];
|
||||
} else {
|
||||
const key = keys?.[index];
|
||||
if (key === undefined)
|
||||
throw new TypeError("CLI JSON object changed during serialization");
|
||||
yield* encodeString(key);
|
||||
yield pretty ? ": " : ":";
|
||||
item = Reflect.get(value, key);
|
||||
}
|
||||
yield* encodeValue(item, pretty, depth + 1, ancestors);
|
||||
}
|
||||
if (pretty && count > 0) yield `\n${" ".repeat(depth)}`;
|
||||
yield array ? "]" : "}";
|
||||
} finally {
|
||||
ancestors.delete(value);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,9 +4,11 @@ import { isAbsolute } from "node:path";
|
|||
import { ConfigurationError } from "../domain/configurationErrors.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { analysisProviderSelectorSchema } from "../contracts/providerSelection.js";
|
||||
import { mcpResponseBudgetSchema } from "./mcpResponseBudget.js";
|
||||
|
||||
const environmentSchema = z.object({
|
||||
REA_ANALYSIS_PROVIDER: analysisProviderSelectorSchema.default("auto"),
|
||||
REA_MCP_MAX_RESPONSE_BYTES: mcpResponseBudgetSchema.optional(),
|
||||
REA_IDA_MCP_CONFIG: z
|
||||
.string()
|
||||
.min(1)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
import { z } from "zod";
|
||||
|
||||
import { ConfigurationError } from "../domain/configurationErrors.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
|
||||
// This override supports clients with larger frames. Keep the configuration
|
||||
// parser independent of the SDK's eager server imports; conformance verifies
|
||||
// this floor against the pinned SDK's actual default receive-buffer size.
|
||||
const MINIMUM_RESPONSE_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
/** Optional stdio response budget, including room for the protocol envelope. */
|
||||
export const mcpResponseBudgetSchema = z
|
||||
.string()
|
||||
.regex(/^[1-9]\d*$/u)
|
||||
.transform(Number)
|
||||
.pipe(z.number().int().safe().min(MINIMUM_RESPONSE_BYTES));
|
||||
|
||||
/** Parse only the MCP-specific budget without loading unrelated provider settings. */
|
||||
export const parseMcpResponseBudget = (
|
||||
value: string | undefined,
|
||||
): Result<number | undefined, ConfigurationError> => {
|
||||
if (value === undefined) return ok(undefined);
|
||||
const parsed = mcpResponseBudgetSchema.safeParse(value);
|
||||
return parsed.success
|
||||
? ok(parsed.data)
|
||||
: err(
|
||||
new ConfigurationError(
|
||||
`REA_MCP_MAX_RESPONSE_BYTES must be a decimal safe integer at least ${String(MINIMUM_RESPONSE_BYTES)} bytes, the default stdio receive-buffer size.`,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
|
@ -17,13 +17,13 @@ export class AnalysisUnsupportedTargetError extends AnalysisError {
|
|||
}
|
||||
}
|
||||
|
||||
/** Provider reported a resource failure, distinct from malformed input or unsupported coverage. */
|
||||
/** An analysis or transport resource failure, distinct from malformed input or unsupported coverage. */
|
||||
export class AnalysisResourceConstraintError extends AnalysisError {
|
||||
readonly _tag = "AnalysisResourceConstraintError";
|
||||
|
||||
constructor(
|
||||
readonly operation: string,
|
||||
readonly resource: "memory" | "cpu" | "file-size",
|
||||
readonly resource: "memory" | "cpu" | "file-size" | "transport",
|
||||
readonly reason: string,
|
||||
readonly reportedLimits: Readonly<Record<string, JsonValue>> | null,
|
||||
options?: AnalysisErrorOptions,
|
||||
|
|
|
|||
|
|
@ -37,11 +37,13 @@ export const analysisErrorRemediationAction = (
|
|||
if (error instanceof AnalysisUnsupportedTargetError)
|
||||
return "Select a target supported by this operation or choose an operation supporting the reported target format.";
|
||||
if (error instanceof AnalysisResourceConstraintError)
|
||||
return error.resource === "cpu"
|
||||
? "Review the reported worker CPU limits and observed signal. Retry with sufficient CPU time or a smaller artifact; REA retains tighter inherited limits."
|
||||
: error.resource === "file-size"
|
||||
? "Review the reported worker file-size limits and write failure. Retry with a sufficient file-size allowance for the evidence reply; REA retains tighter inherited limits."
|
||||
: "Review the reported worker memory limits and available host memory. Retry with sufficient memory or a smaller artifact; REA retains tighter inherited limits.";
|
||||
return error.resource === "transport"
|
||||
? "Use the reported Evidence reference with a focused analysis tool when retained in this session, or export the session through export_evidence_bundle to a caller-selected path. Complete CLI JSON output is also available. The connection remains usable."
|
||||
: error.resource === "cpu"
|
||||
? "Review the reported worker CPU limits and observed signal. Retry with sufficient CPU time or a smaller artifact; REA retains tighter inherited limits."
|
||||
: error.resource === "file-size"
|
||||
? "Review the reported worker file-size limits and write failure. Retry with a sufficient file-size allowance for the evidence reply; REA retains tighter inherited limits."
|
||||
: "Review the reported worker memory limits and available host memory. Retry with sufficient memory or a smaller artifact; REA retains tighter inherited limits.";
|
||||
if (error instanceof HopperTimeoutError)
|
||||
return error.providerState === "busy"
|
||||
? "Check binary_session.analysis_activity, wait for the active Hopper request to finish, then retry."
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ it.each([
|
|||
["cpu", { cpu_seconds: 1 }],
|
||||
["file-size", null],
|
||||
["file-size", { file_size_bytes: 1024 }],
|
||||
["transport", { result_budget_bytes: 10485760 }],
|
||||
] as const)(
|
||||
"projects reported %s constraints with observed or unknown limits: %j",
|
||||
(resource, limits) => {
|
||||
|
|
@ -89,9 +90,11 @@ it.each([
|
|||
expect(projected.remediation.action).toContain(
|
||||
resource === "cpu"
|
||||
? "CPU"
|
||||
: resource === "file-size"
|
||||
? "file-size"
|
||||
: "memory",
|
||||
: resource === "transport"
|
||||
? "Evidence reference"
|
||||
: resource === "file-size"
|
||||
? "file-size"
|
||||
: "memory",
|
||||
);
|
||||
expect(projected.remediation.action).not.toContain("doctor");
|
||||
expect(analysisErrorProjectionSchema.safeParse(projected).success).toBe(
|
||||
|
|
|
|||
|
|
@ -3,9 +3,15 @@ import { describe, expect } from "vitest";
|
|||
|
||||
import { createAnalysisProfile } from "./analysisProfile.js";
|
||||
import type { BinaryTarget } from "./binaryTarget.js";
|
||||
import { createEvidence, evidenceSchema, parseEvidence } from "./evidence.js";
|
||||
import { MAX_JSON_DEPTH } from "./jsonValue.js";
|
||||
import {
|
||||
createEvidence,
|
||||
evidenceRecordSchema,
|
||||
evidenceSchema,
|
||||
parseEvidence,
|
||||
} from "./evidence.js";
|
||||
import { MAX_JSON_DEPTH, type JsonValue } from "./jsonValue.js";
|
||||
import { createEvidenceBundle } from "./evidenceBundle.js";
|
||||
import { freezeJsonSnapshot } from "./immutableJson.js";
|
||||
|
||||
const TARGET: BinaryTarget = {
|
||||
path: "/tmp/fixture",
|
||||
|
|
@ -18,6 +24,90 @@ const TARGET: BinaryTarget = {
|
|||
const PROVIDER = { id: "fixture", name: "Fixture provider", version: "1" };
|
||||
const PROFILE = createAnalysisProfile(PROVIDER, { loader: "default" });
|
||||
|
||||
it("preserves identity and bytes when reusing an owned immutable result", () => {
|
||||
const value = { nested: { values: ["observed", 1] } };
|
||||
const mutable = createEvidence(TARGET, PROVIDER, {
|
||||
operation: "inspect",
|
||||
parameters: {},
|
||||
result: value,
|
||||
});
|
||||
const frozen = createEvidence(TARGET, PROVIDER, {
|
||||
operation: "inspect",
|
||||
parameters: {},
|
||||
result: freezeJsonSnapshot(value),
|
||||
});
|
||||
expect(JSON.stringify(frozen)).toBe(JSON.stringify(mutable));
|
||||
expect(frozen.normalized_result).toBe(value);
|
||||
expect(Object.isFrozen(frozen)).toBe(true);
|
||||
expect(parseEvidence(frozen)).toEqual(mutable);
|
||||
const wire: unknown = JSON.parse(JSON.stringify(frozen));
|
||||
expect(parseEvidence(wire)).toEqual(mutable);
|
||||
expect(() =>
|
||||
parseEvidence({ ...frozen, normalized_result: { changed: true } }),
|
||||
).toThrow("semantic identifier");
|
||||
});
|
||||
|
||||
it("applies the JSON depth boundary to owned immutable results", () => {
|
||||
let result: JsonValue = 1;
|
||||
for (let index = 0; index <= MAX_JSON_DEPTH; index += 1)
|
||||
result = { nested: result };
|
||||
const observe = () =>
|
||||
createEvidence(TARGET, PROVIDER, {
|
||||
operation: "inspect",
|
||||
parameters: {},
|
||||
result,
|
||||
});
|
||||
expect(observe).toThrow("maximum nesting depth");
|
||||
freezeJsonSnapshot(result);
|
||||
expect(observe).toThrow("maximum nesting depth");
|
||||
});
|
||||
|
||||
it("preserves prototype-named own members when reusing an owned result", () => {
|
||||
const result = { ["__proto__"]: { preserved: 7 }, constructor: "ordinary" };
|
||||
const observation = { operation: "inspect", parameters: {}, result };
|
||||
const ordinary = createEvidence(TARGET, PROVIDER, observation);
|
||||
const owned = createEvidence(TARGET, PROVIDER, {
|
||||
...observation,
|
||||
result: freezeJsonSnapshot(result),
|
||||
});
|
||||
expect(owned.normalized_result).toBe(result);
|
||||
expect(owned).toEqual(ordinary);
|
||||
expect(parseEvidence(JSON.parse(JSON.stringify(owned)))).toEqual(owned);
|
||||
expect(Object.getPrototypeOf(owned.normalized_result)).toBe(Object.prototype);
|
||||
expect(Reflect.get(Object.prototype, "preserved")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves producer envelope admission and profile validation", () => {
|
||||
const legacy = createEvidence(TARGET, PROVIDER, {
|
||||
operation: "inspect",
|
||||
parameters: {},
|
||||
result: { observed: [1, 2, 3] },
|
||||
});
|
||||
const profiled = createEvidence(TARGET, PROVIDER, {
|
||||
operation: "inspect",
|
||||
parameters: {},
|
||||
result: { observed: [1, 2, 3] },
|
||||
analysisProfile: PROFILE,
|
||||
});
|
||||
const candidates: unknown[] = [
|
||||
legacy,
|
||||
profiled,
|
||||
{ ...legacy, analysis_profile: undefined },
|
||||
{ ...legacy, analysis_profile: null },
|
||||
{
|
||||
...profiled,
|
||||
analysis_profile: { ...PROFILE, provider: { ...PROVIDER, id: "other" } },
|
||||
},
|
||||
{ ...legacy, unexpected: true },
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
const canonical = evidenceRecordSchema.safeParse(candidate);
|
||||
if (canonical.success)
|
||||
expect(parseEvidence(candidate)).toEqual(canonical.data);
|
||||
else expect(() => parseEvidence(candidate)).toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it("snapshots caller-owned payloads before deriving their identity", () => {
|
||||
const result = { nested: { values: ["observed"] } };
|
||||
const rawResult = { entries: [{ value: "raw" }] };
|
||||
|
|
|
|||
|
|
@ -10,8 +10,13 @@ import type { BinaryTarget } from "./binaryTarget.js";
|
|||
import {
|
||||
jsonObjectSchema,
|
||||
jsonValueSchema,
|
||||
jsonValueValidationIssue,
|
||||
type JsonValue,
|
||||
} from "./jsonValue.js";
|
||||
import {
|
||||
freezeJsonSnapshot,
|
||||
isImmutableJsonSnapshot,
|
||||
} from "./immutableJson.js";
|
||||
import { digestSchema } from "./../domain/digests.js";
|
||||
import { prefixedDigestSchema } from "./../domain/digests.js";
|
||||
|
||||
|
|
@ -144,6 +149,14 @@ export const evidenceRecordSchema = z.union([
|
|||
]);
|
||||
|
||||
export type Evidence = z.infer<typeof evidenceRecordSchema>;
|
||||
const immutableEvidenceSnapshots = new WeakMap<object, Evidence>();
|
||||
const immutableResultSchema = z
|
||||
.custom<JsonValue>(isImmutableJsonSnapshot)
|
||||
.superRefine((value, context) => {
|
||||
const issue = jsonValueValidationIssue(value);
|
||||
if (issue !== undefined)
|
||||
context.addIssue({ code: "custom", message: issue });
|
||||
});
|
||||
export type EvidenceLocation = z.infer<typeof evidenceLocationSchema>;
|
||||
|
||||
/** Minimal immutable local artifact identity accepted by Evidence. */
|
||||
|
|
@ -216,7 +229,19 @@ const computeEvidenceId = (evidence: EvidenceWithoutId): string =>
|
|||
|
||||
/** Parse evidence and reject a syntactically valid but tampered semantic ID. */
|
||||
export const parseEvidence = (input: unknown): Evidence => {
|
||||
const evidence = evidenceRecordSchema.parse(input);
|
||||
const immutable =
|
||||
typeof input === "object" && input !== null
|
||||
? immutableEvidenceSnapshots.get(input)
|
||||
: undefined;
|
||||
if (immutable !== undefined) return immutable;
|
||||
// Select the producer's envelope before traversing its payload. A legacy
|
||||
// record cannot satisfy the required-profile branch, whose failed parse
|
||||
// would otherwise clone the entire normalized result before trying legacy.
|
||||
const schema =
|
||||
typeof input === "object" && input !== null && "analysis_profile" in input
|
||||
? profiledEvidenceSchema
|
||||
: evidenceBaseSchema;
|
||||
const evidence = schema.parse(input);
|
||||
const { evidence_id: evidenceId, ...withoutId } = evidence;
|
||||
if (computeEvidenceId(withoutId) !== evidenceId)
|
||||
throw new TypeError(
|
||||
|
|
@ -225,6 +250,20 @@ export const parseEvidence = (input: unknown): Evidence => {
|
|||
return evidence;
|
||||
};
|
||||
|
||||
/** Authenticate and seal a ledger-owned snapshot; external mutable values are copied first. */
|
||||
export const immutableEvidence = (input: unknown): Evidence =>
|
||||
rememberImmutableEvidence(parseEvidence(input));
|
||||
|
||||
/** Recognize authenticated Evidence whose complete reachable JSON data is immutable. */
|
||||
export const isImmutableEvidence = (evidence: Evidence): boolean =>
|
||||
immutableEvidenceSnapshots.has(evidence);
|
||||
|
||||
const rememberImmutableEvidence = (evidence: Evidence): Evidence => {
|
||||
freezeJsonSnapshot(evidence);
|
||||
immutableEvidenceSnapshots.set(evidence, evidence);
|
||||
return evidence;
|
||||
};
|
||||
|
||||
/** Build deterministic Evidence from an immutable artifact subject. */
|
||||
export const createEvidence = (
|
||||
target: EvidenceSubjectTarget | BinaryTarget | undefined,
|
||||
|
|
@ -285,7 +324,14 @@ export const createEvidence = (
|
|||
observation.analysisProfile === undefined
|
||||
? evidenceBaseSchema
|
||||
: profiledEvidenceSchema;
|
||||
const normalized = schema.parse({
|
||||
const sharedResult =
|
||||
typeof observation.result === "object" &&
|
||||
observation.result !== null &&
|
||||
isImmutableJsonSnapshot(observation.result);
|
||||
const selectedSchema = sharedResult
|
||||
? schema.safeExtend({ normalized_result: immutableResultSchema })
|
||||
: schema;
|
||||
const normalized = selectedSchema.parse({
|
||||
...semantic,
|
||||
evidence_id: `ev_${"0".repeat(64)}`,
|
||||
subject,
|
||||
|
|
@ -293,8 +339,9 @@ export const createEvidence = (
|
|||
// 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.
|
||||
return {
|
||||
const evidence = {
|
||||
...normalized,
|
||||
evidence_id: computeEvidenceId(normalized),
|
||||
};
|
||||
return sharedResult ? rememberImmutableEvidence(evidence) : evidence;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
|
|||
import { createEvidence } from "./evidence.js";
|
||||
import {
|
||||
createEvidenceBundle,
|
||||
createImmutableEvidenceBundle,
|
||||
evidenceBundleForTarget,
|
||||
parseEvidenceBundle,
|
||||
} from "./evidenceBundle.js";
|
||||
|
|
@ -118,3 +119,26 @@ describe("evidenceBundleForTarget", () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("immutable serialization bundles", () => {
|
||||
it("authenticates sealed records while revalidating detached and malformed input", () => {
|
||||
const evidence = createEvidence(undefined, provider, {
|
||||
operation: "health",
|
||||
parameters: {},
|
||||
result: { nested: [true] },
|
||||
});
|
||||
const bundle = createImmutableEvidenceBundle([evidence]);
|
||||
expect(parseEvidenceBundle(bundle)).toBe(bundle);
|
||||
expect(Object.isFrozen(bundle.records[0]?.normalized_result)).toBe(true);
|
||||
const detached: unknown = JSON.parse(JSON.stringify(bundle));
|
||||
expect(parseEvidenceBundle(detached)).toEqual(bundle);
|
||||
expect(parseEvidenceBundle(detached)).not.toBe(bundle);
|
||||
const tampered = {
|
||||
...bundle,
|
||||
records: [{ ...evidence, normalized_result: false }],
|
||||
};
|
||||
expect(() => parseEvidenceBundle(Object.freeze(tampered))).toThrow();
|
||||
expect(() => createImmutableEvidenceBundle(tampered.records)).toThrow();
|
||||
expect(() => createImmutableEvidenceBundle([evidence, evidence])).toThrow();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import canonicalize from "canonicalize";
|
|||
import {
|
||||
evidenceEnvelopeSchema,
|
||||
evidenceRecordSchema,
|
||||
immutableEvidence,
|
||||
parseEvidence,
|
||||
providerSchema,
|
||||
type Evidence,
|
||||
|
|
@ -12,6 +13,7 @@ import {
|
|||
residualUnknownSchema,
|
||||
type ResidualUnknown,
|
||||
} from "./residualUnknown.js";
|
||||
import { freezeJsonSnapshot } from "./immutableJson.js";
|
||||
|
||||
const artifactManifestSchema = evidenceEnvelopeSchema.shape.subject
|
||||
.unwrap()
|
||||
|
|
@ -44,6 +46,29 @@ export const evidenceBundleSchema = z.object({
|
|||
});
|
||||
|
||||
export type EvidenceBundle = z.infer<typeof evidenceBundleSchema>;
|
||||
const immutableBundles = new WeakMap<object, EvidenceBundle>();
|
||||
|
||||
/** Build and authenticate a sealed bundle while reusing immutable Evidence records. */
|
||||
export const createImmutableEvidenceBundle = (
|
||||
records: readonly Evidence[],
|
||||
unknowns: readonly ResidualUnknown[] = [],
|
||||
): EvidenceBundle => {
|
||||
const checkedRecords = records.map(immutableEvidence);
|
||||
if (
|
||||
new Set(checkedRecords.map(({ evidence_id }) => evidence_id)).size !==
|
||||
checkedRecords.length
|
||||
)
|
||||
throw new TypeError("Evidence bundle contains duplicate record IDs");
|
||||
const checkedUnknowns = unknowns.map((unknown) =>
|
||||
residualUnknownSchema.parse(unknown),
|
||||
);
|
||||
validateUnknownGraph(checkedUnknowns, checkedRecords);
|
||||
const bundle = freezeJsonSnapshot(
|
||||
createEvidenceBundle(checkedRecords, checkedUnknowns),
|
||||
);
|
||||
immutableBundles.set(bundle, bundle);
|
||||
return bundle;
|
||||
};
|
||||
|
||||
/** Project records into a deterministic bundle whose order has no semantics. */
|
||||
export const createEvidenceBundle = (
|
||||
|
|
@ -212,6 +237,10 @@ export const describeValidationFailure = (cause: unknown): string => {
|
|||
|
||||
/** Parse records, verify semantic IDs, and reject inconsistent manifests. */
|
||||
export const parseEvidenceBundle = (input: unknown): EvidenceBundle => {
|
||||
if (typeof input === "object" && input !== null) {
|
||||
const owned = immutableBundles.get(input);
|
||||
if (owned !== undefined) return owned;
|
||||
}
|
||||
const parsed = evidenceBundleSchema.parse(input);
|
||||
const recordIds = parsed.records.map(({ evidence_id: id }) => id);
|
||||
if (new Set(recordIds).size !== recordIds.length)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,64 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { JsonValue } from "./jsonValue.js";
|
||||
import {
|
||||
freezeJsonSnapshot,
|
||||
isImmutableJsonSnapshot,
|
||||
} from "./immutableJson.js";
|
||||
|
||||
describe("owned immutable JSON snapshots", () => {
|
||||
it("seals reachable data and preserves repeated references", () => {
|
||||
const shared = { values: [1, "observed"] };
|
||||
const value = { left: shared, right: shared };
|
||||
const encoded = JSON.stringify(value);
|
||||
expect(freezeJsonSnapshot(value)).toBe(value);
|
||||
expect(value.left).toBe(value.right);
|
||||
expect(Object.isFrozen(value)).toBe(true);
|
||||
expect(Object.isFrozen(shared.values)).toBe(true);
|
||||
expect(Reflect.set(shared.values, 0, 9)).toBe(false);
|
||||
expect(JSON.stringify(value)).toBe(encoded);
|
||||
expect(isImmutableJsonSnapshot(value)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not mistake a shallow freeze for validated immutable data", () => {
|
||||
const value = Object.freeze({ nested: { value: 1 } });
|
||||
expect(isImmutableJsonSnapshot(value)).toBe(false);
|
||||
freezeJsonSnapshot(value);
|
||||
expect(Object.isFrozen(value.nested)).toBe(true);
|
||||
expect(isImmutableJsonSnapshot(value)).toBe(true);
|
||||
});
|
||||
|
||||
it("composes sealed roots without changing the nested data or authenticating a shallow freeze", () => {
|
||||
const nested = freezeJsonSnapshot({ values: [1, "observed"] });
|
||||
const outer = freezeJsonSnapshot({ nested });
|
||||
expect(outer.nested).toBe(nested);
|
||||
expect(Object.isFrozen(outer)).toBe(true);
|
||||
expect(Object.isFrozen(nested.values)).toBe(true);
|
||||
expect(isImmutableJsonSnapshot(outer)).toBe(true);
|
||||
expect(isImmutableJsonSnapshot(nested)).toBe(true);
|
||||
const unowned = Object.freeze({ nested: { value: 1 } });
|
||||
expect(isImmutableJsonSnapshot(unowned)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects cycles without registering or partially freezing the snapshot", () => {
|
||||
const value: Record<string, JsonValue> = {};
|
||||
value.self = value;
|
||||
expect(() => freezeJsonSnapshot(value)).toThrow("circular");
|
||||
expect(isImmutableJsonSnapshot(value)).toBe(false);
|
||||
expect(Object.isFrozen(value)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects mutable accessor behavior and sparse arrays", () => {
|
||||
const accessor = {
|
||||
get value() {
|
||||
return 1;
|
||||
},
|
||||
};
|
||||
expect(() => freezeJsonSnapshot(accessor)).toThrow("accessors");
|
||||
expect(isImmutableJsonSnapshot(accessor)).toBe(false);
|
||||
expect(() => freezeJsonSnapshot(new Array<number>(2))).toThrow("sparse");
|
||||
expect(() => freezeJsonSnapshot({ number: Number.NaN })).toThrow(
|
||||
"JSON values",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
import type { JsonValue } from "./jsonValue.js";
|
||||
|
||||
const immutableSnapshots = new WeakSet<object>();
|
||||
|
||||
/** Recognize an owned JSON snapshot sealed by this module, or an immutable scalar. */
|
||||
export const isImmutableJsonSnapshot = (value: unknown): value is JsonValue =>
|
||||
value === null ||
|
||||
typeof value === "string" ||
|
||||
typeof value === "boolean" ||
|
||||
(typeof value === "number" && Number.isFinite(value)) ||
|
||||
(typeof value === "object" && immutableSnapshots.has(value));
|
||||
|
||||
/** Seal an already parsed, owned JSON snapshot for immutable zero-copy reuse. */
|
||||
export const freezeJsonSnapshot = <T extends JsonValue>(value: T): T => {
|
||||
const pending: { value: unknown; exiting: boolean }[] = [
|
||||
{ value, exiting: false },
|
||||
];
|
||||
// These sets exist only during traversal, while the owned root already
|
||||
// retains every child. Strong sets avoid per-node ephemeron GC work.
|
||||
const visited = new Set<object>();
|
||||
const ancestors = new Set<object>();
|
||||
const objects: object[] = [];
|
||||
while (pending.length > 0) {
|
||||
const entry = pending.pop();
|
||||
if (entry === undefined) break;
|
||||
const item = entry.value;
|
||||
if (entry.exiting && typeof item === "object" && item !== null) {
|
||||
ancestors.delete(item);
|
||||
visited.add(item);
|
||||
objects.push(item);
|
||||
continue;
|
||||
}
|
||||
if (isImmutableJsonSnapshot(item)) continue;
|
||||
if (typeof item !== "object" || item === null)
|
||||
throw new TypeError("Immutable JSON snapshots require JSON values");
|
||||
const prototype: unknown = Object.getPrototypeOf(item);
|
||||
if (
|
||||
!Array.isArray(item) &&
|
||||
prototype !== Object.prototype &&
|
||||
prototype !== null
|
||||
)
|
||||
throw new TypeError("Immutable JSON snapshots require ordinary objects");
|
||||
if (ancestors.has(item))
|
||||
throw new TypeError(
|
||||
"Immutable JSON snapshots cannot contain circular references",
|
||||
);
|
||||
if (visited.has(item)) continue;
|
||||
ancestors.add(item);
|
||||
pending.push({ value: item, exiting: true });
|
||||
if (Array.isArray(item))
|
||||
for (let index = 0; index < item.length; index += 1)
|
||||
if (!Object.hasOwn(item, index))
|
||||
throw new TypeError(
|
||||
"Immutable JSON snapshots cannot contain sparse arrays",
|
||||
);
|
||||
for (const key of Object.getOwnPropertyNames(item)) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(item, key);
|
||||
if (descriptor === undefined || !Object.hasOwn(descriptor, "value"))
|
||||
throw new TypeError(
|
||||
"Immutable JSON snapshots cannot contain accessors",
|
||||
);
|
||||
const child: unknown = descriptor.value;
|
||||
pending.push({ value: child, exiting: false });
|
||||
}
|
||||
}
|
||||
// Register only after every value is checked and every object is frozen.
|
||||
// An arbitrary Object.freeze call cannot forge this snapshot identity.
|
||||
for (const item of objects) Object.freeze(item);
|
||||
// Register the requested root, rather than retaining weak entries for every
|
||||
// nested object. Sealing an Evidence or bundle skips previously sealed roots.
|
||||
if (typeof value === "object" && value !== null)
|
||||
immutableSnapshots.add(value);
|
||||
return value;
|
||||
};
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import canonicalize from "canonicalize";
|
||||
|
||||
import {
|
||||
bufferedJsonParts,
|
||||
canonicalJsonParts,
|
||||
jsonParts,
|
||||
} from "./jsonSerialization.js";
|
||||
|
||||
describe("streamed JSON representation", () => {
|
||||
it.each([
|
||||
null,
|
||||
true,
|
||||
-0,
|
||||
[1, false, null, 'quotes: " / slash: \\ / newline: \n'],
|
||||
{ nested: { value: "中文 😀 \ud800" }, items: [1, 2] },
|
||||
JSON.parse('{"__proto__":{"value":1},"constructor":2,"10":3,"2":4}'),
|
||||
])("matches native JSON for ordinary values: %j", (value: unknown) => {
|
||||
expect([...bufferedJsonParts(jsonParts(value))].join("")).toBe(
|
||||
JSON.stringify(value),
|
||||
);
|
||||
expect([...jsonParts(value, true)].join("")).toBe(
|
||||
JSON.stringify(value, null, 2),
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves surrogate pairs across string and buffer boundaries", () => {
|
||||
const value = { text: `${"x".repeat(8190)}😀${'中文\n"'.repeat(20000)}` };
|
||||
const parts = [...bufferedJsonParts(jsonParts(value))];
|
||||
expect(
|
||||
Buffer.concat(parts.map((part) => Buffer.from(part))).toString(),
|
||||
).toBe(JSON.stringify(value));
|
||||
expect(JSON.parse(parts.join(""))).toEqual(value);
|
||||
});
|
||||
|
||||
it("rejects a cycle while allowing repeated references", () => {
|
||||
const shared = { value: 1 };
|
||||
expect([...jsonParts({ left: shared, right: shared })].join("")).toBe(
|
||||
JSON.stringify({ left: shared, right: shared }),
|
||||
);
|
||||
const cycle: { self?: unknown } = {};
|
||||
cycle.self = cycle;
|
||||
expect(() => [...jsonParts(cycle)]).toThrow("circular reference");
|
||||
});
|
||||
|
||||
it("matches existing canonical bytes for numeric keys, Unicode, and shared values", () => {
|
||||
const shared = { z: -0, a: [1e-7, 1e30, "雪😀\ud800"] };
|
||||
const ownKeys: unknown = JSON.parse(
|
||||
'{"__proto__":1,"constructor":2,"10":3,"2":4}',
|
||||
);
|
||||
const value = {
|
||||
z: shared,
|
||||
a: shared,
|
||||
keys: ownKeys,
|
||||
long: '雪😀"'.repeat(20000),
|
||||
};
|
||||
expect([...bufferedJsonParts(canonicalJsonParts(value))].join("")).toBe(
|
||||
canonicalize(value),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
/** Encode ordinary JSON as bounded string parts without a document-sized allocation. */
|
||||
export function* jsonParts(value: unknown, pretty = false): Generator<string> {
|
||||
yield* encodeValue(value, pretty, 0, new Set(), false);
|
||||
}
|
||||
|
||||
/** Encode validated JSON with canonical key ordering without allocating the whole document. */
|
||||
export function* canonicalJsonParts(value: unknown): Generator<string> {
|
||||
yield* encodeValue(value, false, 0, new Set(), true);
|
||||
}
|
||||
|
||||
/** Buffer JSON parts while preserving complete UTF-16 surrogate pairs. */
|
||||
export function* bufferedJsonParts(parts: Iterable<string>): Generator<string> {
|
||||
let buffer = "";
|
||||
for (const part of parts) {
|
||||
if (buffer.length + part.length > 64 * 1024 && buffer !== "") {
|
||||
yield buffer;
|
||||
buffer = "";
|
||||
}
|
||||
buffer += part;
|
||||
}
|
||||
if (buffer !== "") yield buffer;
|
||||
}
|
||||
|
||||
function* encodeString(value: string): Generator<string> {
|
||||
if (value.length <= 8192) {
|
||||
yield JSON.stringify(value);
|
||||
return;
|
||||
}
|
||||
yield '"';
|
||||
for (let start = 0; start < value.length;) {
|
||||
let end = Math.min(start + 8192, value.length);
|
||||
const last = value.charCodeAt(end - 1);
|
||||
const next = value.charCodeAt(end);
|
||||
// Keep a surrogate pair in the same JSON.stringify and UTF-8 write.
|
||||
if (
|
||||
end < value.length &&
|
||||
last >= 0xd800 &&
|
||||
last <= 0xdbff &&
|
||||
next >= 0xdc00 &&
|
||||
next <= 0xdfff
|
||||
)
|
||||
end -= 1;
|
||||
yield JSON.stringify(value.slice(start, end)).slice(1, -1);
|
||||
start = end;
|
||||
}
|
||||
yield '"';
|
||||
}
|
||||
|
||||
function* encodeValue(
|
||||
value: unknown,
|
||||
pretty: boolean,
|
||||
depth: number,
|
||||
ancestors: Set<object>,
|
||||
canonical: boolean,
|
||||
): Generator<string> {
|
||||
if (typeof value === "string") {
|
||||
yield* encodeString(value);
|
||||
return;
|
||||
}
|
||||
if (value === null || typeof value === "boolean") {
|
||||
yield value === null ? "null" : String(value);
|
||||
return;
|
||||
}
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
yield String(value);
|
||||
return;
|
||||
}
|
||||
if (typeof value !== "object" || value === null)
|
||||
throw new TypeError("JSON output requires ordinary JSON values");
|
||||
if (ancestors.has(value))
|
||||
throw new TypeError("JSON output contains a circular reference");
|
||||
if (
|
||||
!Array.isArray(value) &&
|
||||
Object.prototype.toString.call(value) !== "[object Object]"
|
||||
)
|
||||
throw new TypeError("JSON output requires ordinary JSON objects");
|
||||
ancestors.add(value);
|
||||
try {
|
||||
const array = Array.isArray(value);
|
||||
const keys = array ? undefined : Object.keys(value);
|
||||
if (canonical) keys?.sort();
|
||||
const count = array ? value.length : (keys?.length ?? 0);
|
||||
let written = 0;
|
||||
yield array ? "[" : "{";
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
let item: unknown;
|
||||
let key: string | undefined;
|
||||
if (array) {
|
||||
item = value[index];
|
||||
} else {
|
||||
key = keys?.[index];
|
||||
if (key === undefined)
|
||||
throw new TypeError("JSON object changed during serialization");
|
||||
item = Reflect.get(value, key);
|
||||
// Incur's filter can select inherited methods. Match native JSON's
|
||||
// omission without eagerly reading the rest of a bounded result.
|
||||
if (
|
||||
item === undefined ||
|
||||
typeof item === "function" ||
|
||||
typeof item === "symbol"
|
||||
)
|
||||
continue;
|
||||
}
|
||||
if (written > 0) yield ",";
|
||||
if (pretty) yield `\n${" ".repeat(depth + 1)}`;
|
||||
if (key !== undefined) {
|
||||
yield* encodeString(key);
|
||||
yield pretty ? ": " : ":";
|
||||
}
|
||||
yield* encodeValue(item, pretty, depth + 1, ancestors, canonical);
|
||||
written += 1;
|
||||
}
|
||||
if (pretty && written > 0) yield `\n${" ".repeat(depth)}`;
|
||||
yield array ? "]" : "}";
|
||||
} finally {
|
||||
ancestors.delete(value);
|
||||
}
|
||||
}
|
||||
|
|
@ -16,7 +16,8 @@ const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
const recordShape = z.record(z.string(), z.unknown());
|
||||
|
||||
const jsonValidationIssue = (root: unknown): string | undefined => {
|
||||
/** Validate JSON meaning and nesting without cloning a trusted immutable value. */
|
||||
export const jsonValueValidationIssue = (root: unknown): string | undefined => {
|
||||
const pending: { value: unknown; depth: number }[] = [
|
||||
{ value: root, depth: 0 },
|
||||
];
|
||||
|
|
@ -59,9 +60,10 @@ const preserveJsonValue = (value: JsonValue): JsonValue => {
|
|||
};
|
||||
|
||||
const validatedJsonValue = z.custom<JsonValue>(
|
||||
(value) => jsonValidationIssue(value) === undefined,
|
||||
(value) => jsonValueValidationIssue(value) === undefined,
|
||||
{
|
||||
error: (issue) => jsonValidationIssue(issue.input) ?? "Invalid JSON value",
|
||||
error: (issue) =>
|
||||
jsonValueValidationIssue(issue.input) ?? "Invalid JSON value",
|
||||
},
|
||||
);
|
||||
// Keep both stages nonrecursive in public and Standard Schema projections.
|
||||
|
|
|
|||
|
|
@ -294,7 +294,9 @@ const createSessionRecorders = (
|
|||
evidenceById:
|
||||
session === undefined
|
||||
? undefined
|
||||
: (evidenceId: string) => session.evidenceById(evidenceId),
|
||||
: (evidenceId: string) =>
|
||||
session.evidenceForAnalysis?.(evidenceId) ??
|
||||
session.evidenceById(evidenceId),
|
||||
activeTarget:
|
||||
session === undefined ? undefined : () => session.activeTarget(),
|
||||
recordEvidence:
|
||||
|
|
|
|||
|
|
@ -21,7 +21,13 @@ export const recordResult = (
|
|||
);
|
||||
if (recorded !== undefined && !recorded.ok)
|
||||
return toCallToolResult(recorded, contract);
|
||||
return toCallToolResult({ ok: true, value: evidence }, contract);
|
||||
return toCallToolResult(
|
||||
{ ok: true, value: evidence },
|
||||
contract,
|
||||
recorded === undefined
|
||||
? undefined
|
||||
: { retainedEvidenceId: evidence.evidence_id },
|
||||
);
|
||||
};
|
||||
|
||||
const unknownRegistration = (
|
||||
|
|
|
|||
|
|
@ -208,5 +208,11 @@ const evidenceResult = (
|
|||
const recorded = options.recordEvidence?.(evidence);
|
||||
return recorded !== undefined && !recorded.ok
|
||||
? toCallToolResult(recorded, contract)
|
||||
: toCallToolResult({ ok: true, value: evidence }, contract);
|
||||
: toCallToolResult(
|
||||
{ ok: true, value: evidence },
|
||||
contract,
|
||||
recorded === undefined
|
||||
? undefined
|
||||
: { retainedEvidenceId: evidence.evidence_id },
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -168,5 +168,11 @@ const evidenceResult = (
|
|||
const recorded = options.recordEvidence?.(evidence);
|
||||
return recorded !== undefined && !recorded.ok
|
||||
? toCallToolResult(recorded, contract)
|
||||
: toCallToolResult({ ok: true, value: evidence }, contract);
|
||||
: toCallToolResult(
|
||||
{ ok: true, value: evidence },
|
||||
contract,
|
||||
recorded === undefined
|
||||
? undefined
|
||||
: { retainedEvidenceId: evidence.evidence_id },
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ const registerExportEvidenceTool = ({
|
|||
exportContract.name,
|
||||
toolRegistrationOptions(exportContract),
|
||||
async (input) => {
|
||||
const bundle = session.exportEvidenceBundle();
|
||||
const bundle = bundleForSerialization(session);
|
||||
const written = await writeEvidenceBundle(
|
||||
bundle,
|
||||
input.path,
|
||||
|
|
@ -73,7 +73,7 @@ const registerSnapshotEvidenceTool = ({
|
|||
snapshotContract.name,
|
||||
toolRegistrationOptions(snapshotContract),
|
||||
() =>
|
||||
toCallToolResult(ok(session.exportEvidenceBundle()), snapshotContract),
|
||||
toCallToolResult(ok(bundleForSerialization(session)), snapshotContract),
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -90,9 +90,9 @@ const registerImportEvidenceTool = ({
|
|||
const loaded = await readEvidenceBundle(path);
|
||||
if (!loaded.ok) return toCallToolResult(loaded, importContract);
|
||||
const retainedUnknownRevisions = new Set(
|
||||
session
|
||||
.exportEvidenceBundle()
|
||||
.unknowns.map((unknown) => unknownRevisionKey(unknown)),
|
||||
bundleForSerialization(session).unknowns.map((unknown) =>
|
||||
unknownRevisionKey(unknown),
|
||||
),
|
||||
);
|
||||
const imported = session.importEvidenceBundle(loaded.value);
|
||||
return imported.ok
|
||||
|
|
@ -103,7 +103,7 @@ const registerImportEvidenceTool = ({
|
|||
(unknown) =>
|
||||
!retainedUnknownRevisions.has(unknownRevisionKey(unknown)),
|
||||
).length,
|
||||
total: session.exportEvidenceBundle().records.length,
|
||||
total: bundleForSerialization(session).records.length,
|
||||
}),
|
||||
importContract,
|
||||
)
|
||||
|
|
@ -112,6 +112,9 @@ const registerImportEvidenceTool = ({
|
|||
);
|
||||
};
|
||||
|
||||
const bundleForSerialization = (session: BinarySessionPort): EvidenceBundle =>
|
||||
session.evidenceBundleForSerialization?.() ?? session.exportEvidenceBundle();
|
||||
|
||||
const unknownRevisionKey = (
|
||||
unknown: EvidenceBundle["unknowns"][number],
|
||||
): string => `${unknown.unknown_id}:${String(unknown.revision)}`;
|
||||
|
|
|
|||
|
|
@ -1,10 +1,18 @@
|
|||
import type { CallToolResult } from "@modelcontextprotocol/server";
|
||||
import { STDIO_DEFAULT_MAX_BUFFER_SIZE } from "@modelcontextprotocol/server";
|
||||
|
||||
import type { ToolContract } from "../contracts/toolContracts.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import { type AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import type { Result } from "../domain/result.js";
|
||||
import { AnalysisResourceConstraintError } from "../domain/analysisErrorCore.js";
|
||||
import { parseMcpResponseBudget } from "../config/mcpResponseBudget.js";
|
||||
import {
|
||||
encodeToolResult,
|
||||
MCP_RESULT_BUDGET_BYTES,
|
||||
MCP_RESULT_STRING_LIMIT,
|
||||
} from "./toolResultEncoding.js";
|
||||
|
||||
/**
|
||||
* Serialize an application result as MCP text and structured content.
|
||||
|
|
@ -14,8 +22,11 @@ import type { Result } from "../domain/result.js";
|
|||
export const toCallToolResult = (
|
||||
result: Result<JsonValue, AnalysisError>,
|
||||
contract: ToolContract,
|
||||
context?: { readonly retainedEvidenceId: string },
|
||||
): CallToolResult =>
|
||||
result.ok ? successResult(result.value, contract) : errorResult(result.error);
|
||||
result.ok
|
||||
? successResult(result.value, contract, context)
|
||||
: errorResult(result.error);
|
||||
|
||||
const errorResult = (error: AnalysisError): CallToolResult => {
|
||||
const projected = projectAnalysisError(error);
|
||||
|
|
@ -35,21 +46,73 @@ const errorResult = (error: AnalysisError): CallToolResult => {
|
|||
const successResult = (
|
||||
value: JsonValue,
|
||||
contract: ToolContract,
|
||||
context: { readonly retainedEvidenceId: string } | undefined,
|
||||
): CallToolResult => {
|
||||
const candidate =
|
||||
projectEvidence(value) ??
|
||||
(contract.kind === "session" ? { result: value } : value);
|
||||
const configured = parseMcpResponseBudget(
|
||||
process.env.REA_MCP_MAX_RESPONSE_BYTES,
|
||||
);
|
||||
if (!configured.ok) return errorResult(configured.error);
|
||||
const budget =
|
||||
configured.value === undefined
|
||||
? MCP_RESULT_BUDGET_BYTES
|
||||
: configured.value - 1024;
|
||||
const encoded = encodeToolResult(candidate, budget);
|
||||
if (!encoded.ok)
|
||||
return errorResult(
|
||||
new AnalysisResourceConstraintError(
|
||||
contract.name,
|
||||
"transport",
|
||||
encoded.constraint === "string-length"
|
||||
? "The complete MCP response exceeds Node's single-string representation limit. Use a focused retained-Evidence query or export to consume the complete analysis."
|
||||
: "The complete MCP response exceeds the stdio response budget. The analysis result was not truncated; use a focused retained-Evidence query or export to consume it.",
|
||||
{
|
||||
boundary: "mcp-response",
|
||||
default_receive_buffer_bytes: STDIO_DEFAULT_MAX_BUFFER_SIZE,
|
||||
result_budget_bytes: budget,
|
||||
max_string_code_units: MCP_RESULT_STRING_LIMIT,
|
||||
response_bytes_at_least: encoded.bytesAtLeast,
|
||||
response_code_units_at_least: encoded.codeUnitsAtLeast,
|
||||
constraint: encoded.constraint,
|
||||
...evidenceRecovery(value, context?.retainedEvidenceId),
|
||||
},
|
||||
),
|
||||
);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify(candidate),
|
||||
text: encoded.text,
|
||||
},
|
||||
],
|
||||
structuredContent: candidate,
|
||||
};
|
||||
};
|
||||
|
||||
const evidenceRecovery = (
|
||||
value: JsonValue,
|
||||
retainedEvidenceId: string | undefined,
|
||||
): Record<string, JsonValue> => {
|
||||
const projected = projectEvidence(value);
|
||||
if (
|
||||
projected === undefined ||
|
||||
typeof projected !== "object" ||
|
||||
projected === null ||
|
||||
Array.isArray(projected)
|
||||
)
|
||||
return {};
|
||||
if (projected.evidence_id !== retainedEvidenceId)
|
||||
return { evidence_id: projected.evidence_id ?? null };
|
||||
return {
|
||||
evidence_reference: {
|
||||
kind: "retained-evidence",
|
||||
evidence_id: projected.evidence_id ?? null,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const projectEvidence = (value: JsonValue): JsonValue | undefined => {
|
||||
if (
|
||||
typeof value !== "object" ||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,55 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import { encodeToolResult } from "./toolResultEncoding.js";
|
||||
|
||||
const projectedResult = (candidate: JsonValue) => ({
|
||||
content: [{ type: "text", text: JSON.stringify(candidate) }],
|
||||
structuredContent: candidate,
|
||||
});
|
||||
|
||||
describe("MCP result encoding budget", () => {
|
||||
it.each([
|
||||
{ result: { value: 1 } },
|
||||
{ result: ["中文", "😀", "\ud800", '"\\\n'] },
|
||||
{ result: "x".repeat(8191) + "😀" + '中文\n"'.repeat(20000) },
|
||||
])("counts actual UTF-8 structured and escaped text bytes", (candidate) => {
|
||||
const encoded = encodeToolResult(candidate);
|
||||
expect(encoded.ok).toBe(true);
|
||||
if (!encoded.ok) throw new Error("Expected complete encoding");
|
||||
expect(encoded.text).toBe(JSON.stringify(candidate));
|
||||
expect(encoded.bytes).toBe(
|
||||
Buffer.byteLength(JSON.stringify(projectedResult(candidate))),
|
||||
);
|
||||
});
|
||||
|
||||
it("accounts for repeated Evidence representations", () => {
|
||||
const normalized = { value: '中文"'.repeat(30000) };
|
||||
const candidate = {
|
||||
result: normalized,
|
||||
evidence: { normalized_result: normalized },
|
||||
};
|
||||
const completeBytes = Buffer.byteLength(
|
||||
JSON.stringify(projectedResult(candidate)),
|
||||
);
|
||||
expect(encodeToolResult(candidate, completeBytes).ok).toBe(true);
|
||||
expect(encodeToolResult(candidate, completeBytes - 1)).toMatchObject({
|
||||
ok: false,
|
||||
bytesAtLeast: completeBytes,
|
||||
});
|
||||
});
|
||||
|
||||
it("stops before reading the remainder of an oversized result", () => {
|
||||
const candidate: JsonValue = { text: "x".repeat(1000000) };
|
||||
Object.defineProperty(candidate, "remainder", {
|
||||
enumerable: true,
|
||||
get() {
|
||||
throw new Error("The remainder must not be read");
|
||||
},
|
||||
});
|
||||
// Only an already-known first property is visited before exhausting the
|
||||
// budget. The serializer's property discovery must not eagerly read values.
|
||||
expect(() => encodeToolResult(candidate, 1000)).not.toThrow();
|
||||
expect(encodeToolResult(candidate, 1000).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
import { STDIO_DEFAULT_MAX_BUFFER_SIZE } from "@modelcontextprotocol/server";
|
||||
import { constants as bufferConstants } from "node:buffer";
|
||||
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import { bufferedJsonParts, jsonParts } from "../domain/jsonSerialization.js";
|
||||
|
||||
// Leave room for the SDK's JSON-RPC envelope and protocol metadata. This budget
|
||||
// follows the pinned client's actual default framing limit, not a result-row cap.
|
||||
export const MCP_RESULT_BUDGET_BYTES = STDIO_DEFAULT_MAX_BUFFER_SIZE - 1024;
|
||||
export const MCP_RESULT_STRING_LIMIT = bufferConstants.MAX_STRING_LENGTH - 1024;
|
||||
|
||||
const RESULT_ENVELOPE_BYTES =
|
||||
Buffer.byteLength(
|
||||
JSON.stringify({
|
||||
content: [{ type: "text", text: "" }],
|
||||
structuredContent: null,
|
||||
}),
|
||||
) - 4;
|
||||
|
||||
/** Result of encoding the complete repeated MCP representations within a wire budget. */
|
||||
export type ToolResultEncoding =
|
||||
| { readonly ok: true; readonly text: string; readonly bytes: number }
|
||||
| {
|
||||
readonly ok: false;
|
||||
readonly bytesAtLeast: number;
|
||||
readonly codeUnitsAtLeast: number;
|
||||
readonly constraint: "receive-buffer" | "string-length";
|
||||
};
|
||||
|
||||
/** Account for structured JSON and escaped text before allocating the complete text. */
|
||||
export const encodeToolResult = (
|
||||
value: JsonValue,
|
||||
budgetBytes = MCP_RESULT_BUDGET_BYTES,
|
||||
): ToolResultEncoding => {
|
||||
const parts: string[] = [];
|
||||
let bytes = RESULT_ENVELOPE_BYTES;
|
||||
let codeUnits = RESULT_ENVELOPE_BYTES;
|
||||
for (const part of bufferedJsonParts(jsonParts(value))) {
|
||||
const escapedPart = JSON.stringify(part);
|
||||
bytes += Buffer.byteLength(part) + Buffer.byteLength(escapedPart) - 2;
|
||||
codeUnits += part.length + escapedPart.length - 2;
|
||||
if (bytes > budgetBytes || codeUnits > MCP_RESULT_STRING_LIMIT)
|
||||
return {
|
||||
ok: false,
|
||||
bytesAtLeast: bytes,
|
||||
codeUnitsAtLeast: codeUnits,
|
||||
constraint:
|
||||
codeUnits > MCP_RESULT_STRING_LIMIT
|
||||
? "string-length"
|
||||
: "receive-buffer",
|
||||
};
|
||||
parts.push(part);
|
||||
}
|
||||
return { ok: true, text: parts.join(""), bytes };
|
||||
};
|
||||
|
|
@ -1,4 +1,11 @@
|
|||
import { mkdir, readdir, readFile, symlink, writeFile } from "node:fs/promises";
|
||||
import {
|
||||
mkdir,
|
||||
readdir,
|
||||
readFile,
|
||||
stat,
|
||||
symlink,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
|
@ -12,6 +19,7 @@ import {
|
|||
import { compareEvidenceBundlesCommand } from "../../../src/application/EvidenceBundleCommands.js";
|
||||
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
|
||||
import { createEvidence } from "../../../src/domain/evidence.js";
|
||||
import { writeTextParts } from "../../../src/application/JsonFiles.js";
|
||||
import {
|
||||
createEvidenceBundle,
|
||||
serializeEvidenceBundle,
|
||||
|
|
@ -27,6 +35,39 @@ const bundle = (result = true) =>
|
|||
]);
|
||||
|
||||
describe("evidence bundle publication", () => {
|
||||
it.each([false, true])(
|
||||
"never publishes a partial streamed file (overwrite: %s)",
|
||||
async (overwrite) => {
|
||||
const root = await createTestTempDirectory("rea-stream-publication-");
|
||||
const path = join(root, "bundle.json");
|
||||
if (overwrite) await writeFile(path, "original");
|
||||
function* brokenParts() {
|
||||
yield "partial";
|
||||
throw new Error("Source stream failed");
|
||||
}
|
||||
expect(
|
||||
await writeTextParts(brokenParts(), path, overwrite),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "EvidenceFileError", reason: "io" },
|
||||
});
|
||||
expect(await readdir(root)).toEqual(overwrite ? ["bundle.json"] : []);
|
||||
if (overwrite) expect(await readFile(path, "utf8")).toBe("original");
|
||||
},
|
||||
);
|
||||
|
||||
it("counts complete Unicode bytes and publishes a private file", async () => {
|
||||
const root = await createTestTempDirectory("rea-stream-bytes-");
|
||||
const path = join(root, "bundle.json");
|
||||
const parts = ["雪", "😀", "\n"];
|
||||
expect(await writeTextParts(parts, path, false)).toEqual({
|
||||
ok: true,
|
||||
value: { path, bytes: Buffer.byteLength(parts.join("")) },
|
||||
});
|
||||
expect(await readFile(path, "utf8")).toBe(parts.join(""));
|
||||
if (process.platform !== "win32")
|
||||
expect((await stat(path)).mode & 0o777).toBe(0o600);
|
||||
});
|
||||
it("allows only one simultaneous export without overwrite approval", async () => {
|
||||
const root = await createTestTempDirectory("rea-evidence-exclusive-");
|
||||
const path = join(root, "bundle.json");
|
||||
|
|
|
|||
|
|
@ -0,0 +1,69 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { STDIO_DEFAULT_MAX_BUFFER_SIZE } from "@modelcontextprotocol/server";
|
||||
|
||||
import { toolContract } from "../../../src/contracts/toolContracts.js";
|
||||
import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js";
|
||||
import { ok } from "../../../src/domain/result.js";
|
||||
import { toCallToolResult } from "../../../src/server/toolResult.js";
|
||||
import { EvidenceLedger } from "../../../src/application/investigation/EvidenceLedger.js";
|
||||
|
||||
describe("large complete Evidence MCP delivery", () => {
|
||||
it("preserves retained content and returns an actionable framing constraint", () => {
|
||||
const normalized = {
|
||||
payload: "x".repeat(Math.ceil(STDIO_DEFAULT_MAX_BUFFER_SIZE / 3)),
|
||||
};
|
||||
const evidence = createEvidence(
|
||||
undefined,
|
||||
{ id: "fixture", name: "Fixture", version: "1" },
|
||||
{
|
||||
operation: "analyze_javascript_application",
|
||||
parameters: {},
|
||||
result: normalized,
|
||||
},
|
||||
);
|
||||
const ledger = new EvidenceLedger();
|
||||
expect(ledger.record(evidence).ok).toBe(true);
|
||||
const result = toCallToolResult(
|
||||
ok(evidence),
|
||||
toolContract("analyze_javascript_application"),
|
||||
{ retainedEvidenceId: evidence.evidence_id },
|
||||
);
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.structuredContent).toMatchObject({
|
||||
error: {
|
||||
code: "resource_constraint",
|
||||
category: "resource_constraint",
|
||||
details: {
|
||||
resource: "transport",
|
||||
reported_limits: {
|
||||
boundary: "mcp-response",
|
||||
constraint: "receive-buffer",
|
||||
evidence_reference: {
|
||||
kind: "retained-evidence",
|
||||
evidence_id: evidence.evidence_id,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(Buffer.byteLength(JSON.stringify(result))).toBeLessThan(
|
||||
STDIO_DEFAULT_MAX_BUFFER_SIZE,
|
||||
);
|
||||
expect(result.content).toEqual([
|
||||
{ type: "text", text: JSON.stringify(result.structuredContent) },
|
||||
]);
|
||||
const retained = ledger.get(evidence.evidence_id);
|
||||
expect(parseEvidence(retained)).toEqual(evidence);
|
||||
expect(retained?.normalized_result).toEqual(normalized);
|
||||
const detached = toCallToolResult(
|
||||
ok(evidence),
|
||||
toolContract("analyze_javascript_application"),
|
||||
);
|
||||
expect(detached.structuredContent).toMatchObject({
|
||||
error: {
|
||||
details: { reported_limits: { evidence_id: evidence.evidence_id } },
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(detached)).not.toContain("retained-evidence");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { STDIO_DEFAULT_MAX_BUFFER_SIZE } from "@modelcontextprotocol/server";
|
||||
|
||||
import { parseMcpResponseBudget } from "../../../src/config/mcpResponseBudget.js";
|
||||
|
||||
describe("MCP response budget configuration", () => {
|
||||
it("leaves the default policy to the MCP adapter", () => {
|
||||
expect(parseMcpResponseBudget(undefined)).toEqual({
|
||||
ok: true,
|
||||
value: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("requires the override to accommodate the pinned transport's default frames", () => {
|
||||
expect(
|
||||
parseMcpResponseBudget(String(STDIO_DEFAULT_MAX_BUFFER_SIZE)).ok,
|
||||
).toBe(true);
|
||||
expect(
|
||||
parseMcpResponseBudget(String(STDIO_DEFAULT_MAX_BUFFER_SIZE - 1)).ok,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it.each(["10485760", "268435456", "1073741824"])(
|
||||
"accepts an explicit complete-response budget: %s",
|
||||
(value) => {
|
||||
expect(parseMcpResponseBudget(value)).toEqual({
|
||||
ok: true,
|
||||
value: Number(value),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
"",
|
||||
"0",
|
||||
"-1",
|
||||
"1024",
|
||||
"1.5",
|
||||
"1e8",
|
||||
" 10485760",
|
||||
"Infinity",
|
||||
"9007199254740992",
|
||||
])("rejects malformed or unusable budgets: %j", (value) => {
|
||||
const parsed = parseMcpResponseBudget(value);
|
||||
expect(parsed.ok).toBe(false);
|
||||
if (parsed.ok) throw new Error("Expected invalid budget");
|
||||
expect(parsed.error.message).toContain("REA_MCP_MAX_RESPONSE_BYTES");
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue