fix(artifacts): retain bounded desktop omission evidence

This commit is contained in:
gensokyo-dev committed 2026-10-19 13:09:16 +00:00
1 parent 5a1df7b725
commit 0c8ba30706
10 files changed
+323 -32

No files matched your search

@@ -4,9 +4,9 @@
When an installation directory is supplied and its runtime is unknown, use
`inspect_desktop_application` with its absolute path. Default MCP summary
delivery retains complete Evidence. Read `files`, `candidates`, `components` or
delivery retains complete Evidence. Read `files`, `omissions`, `candidates`, `components` or
`next_probes` with `inspect_analysis_view` kind `desktop-application`, selecting
exact paths or families and only needed fields and pages. Preserve source
exact paths, batched file/omission IDs or families and only needed fields and pages. Preserve source
coverage, read-range identity and unknowns; directory snapshot identity is not
a digest of every file's contents. Component path markers do not establish
runtime presence. For compact CLI output, use
+9 -3
View File
@@ -39,8 +39,12 @@ Read exact files in one request:
}
```
Collections are `files`, `candidates`, `components` and `next_probes`.
`candidates` accepts exact `families`; `files` accepts exact recorded `paths`.
Collections are `files`, `omissions`, `candidates`, `components` and `next_probes`.
Files and omissions accept exact `paths` and batched `file_ids` / `omission_ids`.
When both are supplied, all filters apply; an empty selection returns no rows.
Omission records retain the observed path, kind and stable `omission_id`; their
payloads are not read, and other special-entry metadata remains unavailable.
`candidates` accepts exact `families`.
Omitted selectors match all retained rows; an empty selector matches none.
Every collection supports `fields`, `offset` and `limit`; its stable row ID is
always included. Selection changes delivery, not source inspection. Pages keep
@@ -87,7 +91,9 @@ and SHA-256 digests; typed facts expose CLI RVAs, metadata/pack versions and
assembly tokens. Directory and symlink identities unavailable from traversal
remain null.
The directory's `snapshot_sha256` commits to examined metadata and range digests.
The directory's `snapshot_sha256` commits to retained paths, omission kinds,
available metadata and range digests. Omission records share the entry and
representation budgets and can be fetched independently through their view.
**It is not a digest of all file contents.** These are sequential stable entry
observations, not an atomic directory snapshot. Unread bytes remain unknown.
+2 -7
View File
@@ -4,6 +4,7 @@ import type { Readable } from "node:stream";
import type {
ArtifactCommand,
ArtifactInventoryCoverage,
ArtifactTraversalOmissionKind,
} from "../domain/artifactGraph.js";
import type { ZipPackageFormat } from "../domain/zipPackageFormat.js";
import type {
@@ -106,13 +107,7 @@ export const sameArtifactEntry = (
/** One entry or subtree deliberately excluded from a traversal. */
export interface ArtifactTraversalOmission {
readonly path: string;
readonly kind:
| "fifo"
| "socket"
| "block-device"
| "character-device"
| "unsupported"
| "directory-descendants";
readonly kind: ArtifactTraversalOmissionKind;
}
/** Facts from an exhausted traversal, separate from entry content certainty. */
+52 -13
View File
@@ -25,6 +25,7 @@ import type { ExecutionOptions } from "../application/AnalysisProvider.js";
import type {
DesktopApplication,
DesktopFile,
DesktopOmission,
InspectDesktopApplicationInput,
} from "../domain/desktopApplication.js";
import { projectDesktopApplication } from "../domain/desktopApplicationProjection.js";
@@ -36,6 +37,17 @@ import { AnalysisResourceConstraintError } from "../domain/analysisErrorCore.js"
const RETAINED_METADATA_BYTES = 32 * 1024 * 1024;
/** Stop traversal without discarding the preceding bounded observations. */
class DesktopRepresentationLimit extends ArtifactReaderFailure {
constructor() {
super(
"limit",
"Directory metadata reached the 32 MiB representation budget; remaining entry and omission facts are unavailable.",
{ boundary: "memory" },
);
}
}
/** Existing artifact ownership with bounded metadata, serial probes and no payload cache. */
export class DesktopApplicationReader implements DesktopApplicationPort {
readonly #resources = new ArtifactResourceScope();
@@ -173,6 +185,7 @@ const scanDirectory = async (
};
const namespaceId = `dsr_${digestCanonicalValue(root, "Admitted desktop directory namespace")}`;
const files: DesktopFile[] = [];
const omissions: DesktopOmission[] = [];
const unityWitnessRoots = new Set<string>();
const budget: DesktopReadBudget = {
bytes: 0,
@@ -180,6 +193,11 @@ const scanDirectory = async (
retainedBytes: 1024 + (input.path.length + root.canonical_path.length) * 24,
maximumRetainedBytes: RETAINED_METADATA_BYTES,
};
const reserve = (bytes: number) => {
if (bytes > budget.maximumRetainedBytes - budget.retainedBytes)
throw new DesktopRepresentationLimit();
budget.retainedBytes += bytes;
};
let entries = 0;
let probed = 0;
let symlinks = 0;
@@ -188,7 +206,8 @@ const scanDirectory = async (
let failure: { readonly cause: unknown } | undefined;
const limitations = [
"Runtime candidates and component markers do not establish loaded code, execution, or build correspondence.",
"The directory SHA-256 commits only to examined paths, filesystem metadata and read-range digests, not to every file's contents. Unread bytes are unknown.",
"The directory SHA-256 commits only to retained paths, omission kinds, available filesystem metadata and read-range digests, not to every file's contents. Unread bytes are unknown.",
"Special-entry payloads are not read. Their paths and kinds are retained; other special-entry metadata is unavailable in this report.",
"Child symlinks are not followed. Executable section-embedded PCKs without a trailer and encrypted contents remain unexamined.",
"This is a sequence of stable entry observations, not an atomic snapshot of the entire directory.",
"Unity reference decoding stops after a witness in each Data/Managed directory; other assemblies' references are unexamined. Header facts remain per-file.",
@@ -196,10 +215,23 @@ const scanDirectory = async (
try {
for await (const entry of reader.entries(signal, {
maximumEntries: input.max_entries,
onOmission: () => {
special++;
entries++;
onOmission: (omission) => {
if (omission.kind !== "directory-descendants") {
special++;
entries++;
}
partial = true;
reserve(512 + omission.path.length * 24);
const semantic = {
root_namespace_id: namespaceId,
path: omission.path,
kind: omission.kind,
};
omissions.push({
omission_id: `dso_${digestCanonicalValue(semantic, "Desktop traversal omission")}`,
path: omission.path,
kind: omission.kind,
});
},
})) {
entries++;
@@ -207,14 +239,7 @@ const scanDirectory = async (
// Reserve a conservative representation estimate before retaining path-dependent objects.
const reservation =
1024 + (entry.path.length + entry.adapterKey.length) * 24;
if (reservation > RETAINED_METADATA_BYTES - budget.retainedBytes) {
partial = true;
limitations.push(
"Directory metadata reached the 32 MiB representation budget; remaining entries are unknown.",
);
break;
}
budget.retainedBytes += reservation;
reserve(reservation);
const row = fileRow(entry, namespaceId);
files.push(row);
if (entry.kind === "symlink") {
@@ -287,6 +312,8 @@ const scanDirectory = async (
limitations.push(
"Directory traversal reached max_entries; unvisited paths and candidate families remain unknown.",
);
} else if (cause instanceof DesktopRepresentationLimit) {
limitations.push(cause.message);
} else {
failure = { cause };
limitations.push(
@@ -295,6 +322,17 @@ const scanDirectory = async (
}
}
files.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
omissions.sort((a, b) =>
a.path < b.path
? -1
: a.path > b.path
? 1
: a.kind < b.kind
? -1
: a.kind > b.kind
? 1
: 0,
);
const coverage: DesktopApplication["coverage"] = {
status: partial ? "partial" : "complete-within-scan",
entries_examined: entries,
@@ -308,7 +346,7 @@ const scanDirectory = async (
},
};
const snapshot = digestCanonicalValue(
{ root, files, coverage },
{ root, files, omissions, coverage },
"Examined desktop directory facts",
);
const report: DesktopApplication = {
@@ -320,6 +358,7 @@ const scanDirectory = async (
identity_scope: "examined-metadata-and-read-ranges",
},
files,
omissions,
...projectDesktopApplication(files),
coverage,
limitations,
+1 -1
View File
@@ -17,7 +17,7 @@ export const ANALYSIS_VIEW_TOOL_CONTRACTS = [
...toolContractMetadata("inspect_analysis_view"),
kind: "application",
description:
'Inspect selected views of completed binary-layout, JavaScript application, native analyze_function, inspect_artifact, inventory_artifact, inspect_wasm_artifact, inspect_macho, decode_interface_builder, inspect_evm_interface, inspect_managed_artifact, inspect_managed_members, inspect_android_package, inspect_dart_aot or inspect_web_network_capture Evidence without repeating analysis. Historical capture summaries return identity and counts without bodies or archive inventories. Capture views page retained records: view {kind: "capture", detail: "preview" | "complete", offset: 0, limit: 20}; preview selects recorded headline fields and binary descriptors, complete returns exact records with all sidecars. Offsets follow the selected parent record order, preserving original producer ordinals. Paging never reopens the source or reruns the decoder. For inspect_pcap_capture parents, kind pcap pages endpoints, dns-queries, tls-sni, protocols, http-uris or unattributed-addresses. Select an exact query for dns-addresses, or a tshark/capinfos stream for UTF-16 diagnostics text. Page totals count retained facts; source coverage remains explicit. Qlog summaries keep profile/scope and counts; kind qlog pages traces, streams, loss-declarations, incomplete-records, contributions, ranges or final-sizes. Except traces, select trace_index; stream-specific facets also require exact stream_id and direction. Facts retain all-recorded-events-before-selection scope, and interval overlap or endpoint loss declarations do not prove retransmission or network loss. The api-surface view groups retained HAR requests by method, origin and format-rule path template; templated segments are inferences. It reads no headers or bodies and reports no query values. The environment-manifest view projects the same HAR requests on the primary origin (most JSON-like records) into a replayable tool surface: view {kind: "environment-manifest", template_rules: ["after-uid-literal", "opaque-token", "sibling-variance"], include_query_parameter_names: true}. Each tool has a deterministic name, a path template and parameters from the four format rules plus the selected opt-in rules, observed query parameter names, a value-free body shape for POST/PUT/PATCH, status and content-type sets and record ordinals; records are pointers into the parent, excluded groups carry a reason and three fixed unknowns are always stated. Templates, parameters and names are inferences; the view copies no header, cookie or body values and refuses parents inspected with sensitive values. For a retained record use source: {kind: "retained-evidence", evidence_id: "<parent_evidence_id>"}. Use normalized_result.parent_evidence_id from a summary receipt. Source kind is required. Portable source uses {kind: "inline", evidence: <complete Evidence>}. For layout, JavaScript, inventory, WASM, Mach-O, Interface Builder, EVM, managed artifact, Android package and Dart AOT sources, request a summary with view: {kind: "summary"}; summary is a view kind and has no facet. The WASM summary gives validation, counts, WAT digest/length, tool profile and execution/network states without reading WAT text. Select a layout mitigations/linkage facet, one section/symbol/module, or a stable page. Module pages include assets, bundled and source modules; use node_id for ambiguous or unavailable paths. Typed module references use view {kind: "javascript", collection: "modules", projection: "references", where: {path: "main.js", kind: "javascript-module"}, offset: 0, limit: 64}; where matches exact retained strings (null selects unavailable paths). Rows include canonical identity, retained source-range count and observation support states. Source coverage and selection remain explicit; exhausting matching rows does not prove runtime loading or complete analysis. For exact file/native facts and processing gaps, use view {kind: "javascript-facts", where: {paths: ["package.json"]}, property_keys: ["name", "operation", "analysis_status"]}. Rows page original observations with canonical identities and Evidence; missing properties remain omitted. Paths match recorded artifact-path/source-range locations; optional kinds filter nodes. To discover field names without values, opt into include_available_property_keys: true with property_keys: []. Canonical identity and Evidence are separate from properties; keep each node_id/observation_id pair together when composing results. Inventory uses view: {kind: "inventory", collection: "occurrences", logical_paths: ["package.json"], offset: 0, limit: 64}. Inventory views page nodes, occurrences, edges or integrity contradictions; occurrence logical_paths matches exact recorded strings (omitted means all available, [] means none). source_complete distinguishes complete inventories from partial source records; page exhaustion does not prove absence in a partial source. WASM views use kind wasm and facet sections/imports/exports/candidates/glue/wat/headers/details/tool_profiLine truncated
'Inspect selected views of completed binary-layout, JavaScript application, native analyze_function, inspect_artifact, inventory_artifact, inspect_wasm_artifact, inspect_macho, decode_interface_builder, inspect_evm_interface, inspect_managed_artifact, inspect_managed_members, inspect_android_package, inspect_dart_aot or inspect_web_network_capture Evidence without repeating analysis. Historical capture summaries return identity and counts without bodies or archive inventories. Capture views page retained records: view {kind: "capture", detail: "preview" | "complete", offset: 0, limit: 20}; preview selects recorded headline fields and binary descriptors, complete returns exact records with all sidecars. Offsets follow the selected parent record order, preserving original producer ordinals. Paging never reopens the source or reruns the decoder. For inspect_pcap_capture parents, kind pcap pages endpoints, dns-queries, tls-sni, protocols, http-uris or unattributed-addresses. Select an exact query for dns-addresses, or a tshark/capinfos stream for UTF-16 diagnostics text. Page totals count retained facts; source coverage remains explicit. Qlog summaries keep profile/scope and counts; kind qlog pages traces, streams, loss-declarations, incomplete-records, contributions, ranges or final-sizes. Except traces, select trace_index; stream-specific facets also require exact stream_id and direction. Facts retain all-recorded-events-before-selection scope, and interval overlap or endpoint loss declarations do not prove retransmission or network loss. The api-surface view groups retained HAR requests by method, origin and format-rule path template; templated segments are inferences. It reads no headers or bodies and reports no query values. The environment-manifest view projects the same HAR requests on the primary origin (most JSON-like records) into a replayable tool surface: view {kind: "environment-manifest", template_rules: ["after-uid-literal", "opaque-token", "sibling-variance"], include_query_parameter_names: true}. Each tool has a deterministic name, a path template and parameters from the four format rules plus the selected opt-in rules, observed query parameter names, a value-free body shape for POST/PUT/PATCH, status and content-type sets and record ordinals; records are pointers into the parent, excluded groups carry a reason and three fixed unknowns are always stated. Templates, parameters and names are inferences; the view copies no header, cookie or body values and refuses parents inspected with sensitive values. For a retained record use source: {kind: "retained-evidence", evidence_id: "<parent_evidence_id>"}. Use normalized_result.parent_evidence_id from a summary receipt. Source kind is required. Portable source uses {kind: "inline", evidence: <complete Evidence>}. For layout, JavaScript, inventory, WASM, Mach-O, Interface Builder, EVM, managed artifact, Android package and Dart AOT sources, request a summary with view: {kind: "summary"}; summary is a view kind and has no facet. The WASM summary gives validation, counts, WAT digest/length, tool profile and execution/network states without reading WAT text. Select a layout mitigations/linkage facet, one section/symbol/module, or a stable page. Module pages include assets, bundled and source modules; use node_id for ambiguous or unavailable paths. Typed module references use view {kind: "javascript", collection: "modules", projection: "references", where: {path: "main.js", kind: "javascript-module"}, offset: 0, limit: 64}; where matches exact retained strings (null selects unavailable paths). Rows include canonical identity, retained source-range count and observation support states. Source coverage and selection remain explicit; exhausting matching rows does not prove runtime loading or complete analysis. For exact file/native facts and processing gaps, use view {kind: "javascript-facts", where: {paths: ["package.json"]}, property_keys: ["name", "operation", "analysis_status"]}. Rows page original observations with canonical identities and Evidence; missing properties remain omitted. Paths match recorded artifact-path/source-range locations; optional kinds filter nodes. To discover field names without values, opt into include_available_property_keys: true with property_keys: []. Canonical identity and Evidence are separate from properties; keep each node_id/observation_id pair together when composing results. Inventory uses view: {kind: "inventory", collection: "occurrences", logical_paths: ["package.json"], offset: 0, limit: 64}. Inventory views page nodes, occurrences, edges or integrity contradictions; occurrence logical_paths matches exact recorded strings (omitted means all available, [] means none). source_complete distinguishes complete inventories from partial source records; page exhaustion does not prove absence in a partial source. WASM views use kind wasm and facet sections/imports/exports/candidates/glue/wat/headers/details/tool_profiLine truncated
inputSchema: inspectAnalysisViewInputSchema,
outputSchema: evidenceResultOf(analysisViewResultSchema),
examples: [
@@ -2,6 +2,7 @@ import { z } from "zod";
import {
desktopApplicationSchema,
desktopFileSchema,
desktopOmissionSchema,
desktopCandidateSchema,
desktopComponentSchema,
desktopProbeSchema,
@@ -33,6 +34,7 @@ export const desktopViewRequestSchema = z.discriminatedUnion("collection", [
...page,
collection: z.literal("files"),
paths: z.array(z.string()).optional(),
file_ids: z.array(desktopFileSchema.shape.file_id).optional(),
fields: z.array(desktopFileSchema.keyof()).optional(),
}),
z.strictObject({
@@ -41,6 +43,13 @@ export const desktopViewRequestSchema = z.discriminatedUnion("collection", [
families: z.array(desktopFamilySchema).optional(),
fields: z.array(desktopCandidateSchema.keyof()).optional(),
}),
z.strictObject({
...page,
collection: z.literal("omissions"),
paths: z.array(z.string()).optional(),
omission_ids: z.array(desktopOmissionSchema.shape.omission_id).optional(),
fields: z.array(desktopOmissionSchema.keyof()).optional(),
}),
z.strictObject({
...page,
collection: z.literal("components"),
@@ -55,6 +64,7 @@ export const desktopViewRequestSchema = z.discriminatedUnion("collection", [
export const desktopViewItemsSchema = z.array(
z.union([
desktopFileSchema.partial(),
desktopOmissionSchema.partial(),
desktopCandidateSchema.partial(),
desktopComponentSchema.partial(),
desktopProbeSchema.partial(),
@@ -65,6 +75,7 @@ export const desktopSummarySchema = z.strictObject({
source_coverage: desktopCoverageSchema,
available_counts: z.strictObject({
files: z.number().int().nonnegative(),
omissions: z.number().int().nonnegative(),
candidates: z.number().int().nonnegative(),
components: z.number().int().nonnegative(),
next_probes: z.number().int().nonnegative(),
@@ -90,6 +101,9 @@ export const desktopSummarySchema = z.strictObject({
),
});
type DesktopCollection = z.infer<typeof desktopViewRequestSchema>["collection"];
type DesktopViewRow = DesktopApplication[DesktopCollection][number];
// Only immutable authenticated parent objects are reused. They are not live filesystem caches.
const validatedParents = new WeakSet<object>();
export const projectDesktopApplicationView = (
@@ -199,6 +213,7 @@ export const projectDesktopApplicationView = (
source_coverage: report.coverage,
available_counts: {
files: report.files.length,
omissions: report.omissions.length,
candidates: report.candidates.length,
components: report.components.length,
next_probes: report.next_probes.length,
@@ -213,10 +228,18 @@ export const projectDesktopApplicationView = (
),
});
}
let rows = report[view.collection];
if (view.collection === "files" && view.paths !== undefined) {
const selected = new Set(view.paths);
rows = report.files.filter((file) => selected.has(file.path));
let rows: readonly DesktopViewRow[] = report[view.collection];
if (view.collection === "files" || view.collection === "omissions") {
const paths = view.paths === undefined ? undefined : new Set(view.paths);
const ids = view.collection === "files" ? view.file_ids : view.omission_ids;
const selected = ids === undefined ? undefined : new Set(ids);
if (paths !== undefined || selected !== undefined)
rows = report[view.collection].filter(
(row) =>
(paths === undefined || paths.has(row.path)) &&
(selected === undefined ||
selected.has("file_id" in row ? row.file_id : row.omission_id)),
);
}
if (view.collection === "candidates" && view.families !== undefined) {
const selected = new Set(view.families);
@@ -224,6 +247,7 @@ export const projectDesktopApplicationView = (
}
const idField = {
files: "file_id",
omissions: "omission_id",
candidates: "candidate_id",
components: "component_id",
next_probes: "probe_id",
+13
View File
@@ -1,6 +1,19 @@
import { z } from "zod";
import { prefixedDigestSchema } from "./../domain/digests.js";
/** Entry kinds or subtrees deliberately excluded by an artifact traversal. */
export const artifactTraversalOmissionKindSchema = z.enum([
"fifo",
"socket",
"block-device",
"character-device",
"unsupported",
"directory-descendants",
]);
export type ArtifactTraversalOmissionKind = z.infer<
typeof artifactTraversalOmissionKindSchema
>;
const sha256Schema = z.string().regex(/^[a-f0-9]{64}$/u);
const artifactIdSchema = prefixedDigestSchema("art");
const occurrenceIdSchema = prefixedDigestSchema("occ");
+8
View File
@@ -1,6 +1,7 @@
import { z } from "zod";
import { digestSchema, prefixedDigestSchema } from "./digests.js";
import { localPathStringSchema } from "./localPath.js";
import { artifactTraversalOmissionKindSchema } from "./artifactGraph.js";
export const desktopFamilySchema = z.enum([
"dotnet",
@@ -121,6 +122,12 @@ export const desktopFileSchema = z.strictObject({
issues: z.array(z.string()),
});
export type DesktopFile = z.infer<typeof desktopFileSchema>;
export const desktopOmissionSchema = z.strictObject({
omission_id: prefixedDigestSchema("dso"),
path: z.string(),
kind: artifactTraversalOmissionKindSchema,
});
export type DesktopOmission = z.infer<typeof desktopOmissionSchema>;
export const desktopCandidateSchema = z.strictObject({
candidate_id: prefixedDigestSchema("dsc"),
family: desktopFamilySchema,
@@ -173,6 +180,7 @@ export const desktopApplicationSchema = z.strictObject({
identity_scope: z.literal("examined-metadata-and-read-ranges"),
}),
files: z.array(desktopFileSchema),
omissions: z.array(desktopOmissionSchema),
candidates: z.array(desktopCandidateSchema),
components: z.array(desktopComponentSchema),
next_probes: z.array(desktopProbeSchema),
@@ -8,7 +8,9 @@ import {
unlink,
} from "node:fs/promises";
import { join } from "node:path";
import { expect, it } from "vitest";
import { execFileSync } from "node:child_process";
import { createServer as createNetServer } from "node:net";
import { expect, it, onTestFinished } from "vitest";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { createDesktopApplicationService } from "../../../src/composition/desktop.js";
import { desktopApplicationSchema } from "../../../src/domain/desktopApplication.js";
@@ -442,6 +444,116 @@ it("keeps directory-only marker identities separate across installation roots",
expect(a.report.root.snapshot_sha256).not.toBe(b.report.root.snapshot_sha256);
});
it.skipIf(process.platform === "win32").each(["path", "kind"] as const)(
"commits omitted special-entry %s changes and exposes selected omission facts",
async (change) => {
const root = await createTestTempDirectory("rea-desktop-omissions-");
const original = join(root, "special");
execFileSync("mkfifo", [original], { timeout: 10_000 });
const before = await inspect(root);
let selected = "special";
if (change === "path") {
selected = "renamed";
await rename(original, join(root, selected));
} else {
await unlink(original);
const server = createNetServer();
onTestFinished(async () => {
if (!server.listening) return;
await new Promise<void>((resolve, reject) =>
server.close((cause) => (cause ? reject(cause) : resolve())),
);
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(original, resolve);
});
}
const after = await inspect(root);
expect(after.report.root.snapshot_sha256).not.toBe(
before.report.root.snapshot_sha256,
);
expect(before.report.omissions).toEqual([
{
omission_id: expect.stringMatching(/^dso_[a-f0-9]{64}$/u),
path: "special",
kind: "fifo",
},
]);
expect(after.report.omissions[0]?.omission_id).not.toBe(
before.report.omissions[0]?.omission_id,
);
expect(after.report.coverage).toMatchObject({
status: "partial",
entries_examined: 1,
special_entries_omitted: 1,
files_probed: 0,
bytes_read: 0,
});
const page = inspectAnalysisView({
source: { kind: "inline", evidence: after.evidence },
view: {
kind: "desktop-application",
collection: "omissions",
paths: [selected],
fields: ["kind"],
offset: 0,
limit: 1,
},
});
if (!page.ok) throw page.error;
expect(page.value.evidence_links).toEqual([after.evidence.evidence_id]);
expect(page.value.normalized_result).toHaveProperty("items", [
{
omission_id: after.report.omissions[0]?.omission_id,
kind: change === "path" ? "fifo" : "socket",
},
]);
expect(page.value.normalized_result).toMatchObject({
coverage: { total: 1, exhausted: true },
source_coverage: { status: "partial" },
});
},
);
it("bounds omitted-entry retention before allocating rows or serializing identities", async () => {
const root = await createTestTempDirectory("rea-desktop-omission-capacity-");
let examined = 0;
// Simulate bounded parsed paths without creating hundreds of deep FIFO trees.
const prefix = Array.from({ length: 15 }, () => "d".repeat(255)).join("/");
const service = new DesktopApplicationService(
new DesktopApplicationReader((path) => {
const reader = new DirectoryArtifactReader(path);
reader.entries = async function* (_signal, options) {
if (typeof options === "function")
throw new Error("Expected traversal options");
for (let index = 0; index < 400; index++) {
examined++;
options?.onOmission?.({
path: `${prefix}/${index}/entry`,
kind: "fifo",
});
}
};
return reader;
}),
);
onTestFinished(() => service.close());
const result = await service.inspect({ path: root });
if (!result.ok) throw result.error;
const report = desktopApplicationSchema.parse(result.value.normalized_result);
expect(examined).toBeLessThan(400);
expect(report.omissions).toHaveLength(examined - 1);
expect(report.coverage).toMatchObject({
status: "partial",
entries_examined: examined,
special_entries_omitted: examined,
});
expect(report.limitations).toContainEqual(
expect.stringContaining("representation budget"),
);
});
it("preserves the selected root alias and directs follow-ups to the admitted physical root", async () => {
const root = await createTestTempDirectory("rea-desktop-root-alias-");
const admitted = join(root, "admitted");
@@ -1,6 +1,7 @@
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { execFileSync } from "node:child_process";
import { expect, it, onTestFinished } from "vitest";
import { createServer } from "../../../src/server/createServer.js";
import { createTestBinarySession } from "../../fixtures/binarySession.js";
@@ -9,6 +10,7 @@ import { advertisedMcpToolSchemas } from "../../fixtures/advertisedMcpTool.js";
import { parseEvidence } from "../../../src/domain/evidence.js";
import { desktopApplicationSchema } from "../../../src/domain/desktopApplication.js";
import { analysisViewResultSchema } from "../../../src/domain/analysisView/analysisView.js";
import { createApplicationMcpHarness } from "../../fixtures/applicationMcpHarness.js";
it("advertises exact schemas and retains a compact directory answer with typed selected pages", async () => {
const root = await createTestTempDirectory("rea-desktop-mcp-");
@@ -55,6 +57,9 @@ it("advertises exact schemas and retains a compact directory answer with typed s
expect(full.candidates.map((candidate) => candidate.family)).toEqual([
"electron",
]);
const archive = full.files.find((file) => file.path === "resources/app.asar");
if (archive === undefined)
throw new Error("Missing retained ASAR convention");
const selected = await client.callTool({
name: "inspect_analysis_view",
arguments: {
@@ -65,7 +70,7 @@ it("advertises exact schemas and retains a compact directory answer with typed s
view: {
kind: "desktop-application",
collection: "files",
paths: ["resources/app.asar"],
file_ids: [archive.file_id],
fields: ["path", "read_status"],
limit: 1,
},
@@ -98,3 +103,92 @@ it("advertises exact schemas and retains a compact directory answer with typed s
view.parent_evidence_id,
);
});
it.skipIf(process.platform === "win32")(
"retains special-entry facts and advertises batched omission selections through MCP",
async () => {
const root = await createTestTempDirectory("rea-desktop-omissions-mcp-");
execFileSync("mkfifo", [join(root, "b"), join(root, "a")], {
timeout: 10_000,
});
const harness = await createApplicationMcpHarness();
onTestFinished(harness.close);
const schemas = await advertisedMcpToolSchemas(
harness.client,
"inspect_analysis_view",
);
const inspected = await harness.client.callTool({
name: "inspect_desktop_application",
arguments: { path: root },
});
expect(inspected.isError).not.toBe(true);
const summary = analysisViewResultSchema.parse(
parseEvidence(inspected.structuredContent).normalized_result,
);
expect(summary).toMatchObject({
summary: { available_counts: { omissions: 2 } },
});
const source = {
kind: "retained-evidence",
evidence_id: summary.parent_evidence_id,
};
const listed = await harness.client.callTool({
name: "inspect_analysis_view",
arguments: {
source,
view: {
kind: "desktop-application",
collection: "omissions",
limit: 2,
},
},
});
expect(listed.isError).not.toBe(true);
expect(
schemas.ajv.validate(schemas.outputSchema, listed.structuredContent),
).toBe(true);
const page = analysisViewResultSchema.parse(
parseEvidence(listed.structuredContent).normalized_result,
);
if (page.kind !== "desktop-application") throw new Error("Expected rows");
const ids = page.items.map((row) => {
if (!("omission_id" in row) || typeof row.omission_id !== "string")
throw new Error("Missing stable omission identity");
return row.omission_id;
});
expect(page.items).toMatchObject([
{ path: "a", kind: "fifo" },
{ path: "b", kind: "fifo" },
]);
for (const selection of [ids, []]) {
const args = {
source,
view: {
kind: "desktop-application",
collection: "omissions",
omission_ids: selection,
fields: ["kind"],
limit: 2,
},
};
expect(schemas.ajv.validate(schemas.inputSchema, args)).toBe(true);
const selected = await harness.client.callTool({
name: "inspect_analysis_view",
arguments: args,
});
expect(selected.isError).not.toBe(true);
expect(
schemas.ajv.validate(schemas.outputSchema, selected.structuredContent),
).toBe(true);
const evidence = parseEvidence(selected.structuredContent);
expect(evidence.evidence_links).toEqual([summary.parent_evidence_id]);
expect(evidence.normalized_result).toHaveProperty(
"items",
selection.map((omission_id) => ({ omission_id, kind: "fifo" })),
);
expect(evidence.normalized_result).toMatchObject({
source_coverage: { status: "partial" },
});
}
},
);