Compare commits

...
49 changed files with 2141 additions and 413 deletions

No files matched your search

+11
View File
@@ -31,6 +31,11 @@ Use `--expected-revision N` when an external coordinator needs an explicit
compare-and-swap guard. Traversal, byte, page, and comparison limits are also compare-and-swap guard. Traversal, byte, page, and comparison limits are also
available as CLI options. available as CLI options.
After a completed run returns its stable run ID, `--replay-run-id RUN_ID`
selects that exact result without reading or rescanning either version input.
The workspace still requires read authority. REA rejects a selector that is
missing, incomplete, or inconsistent with the requested paths and controls.
## MCP ## MCP
`find_changed_behavior` retains its existing aggregation mode. Its additive `find_changed_behavior` retains its existing aggregation mode. Its additive
@@ -75,6 +80,12 @@ report checkpoints. A process interrupted after a checkpoint can resume from
the last complete stage. A completed run is reused when both graph commitments the last complete stage. A completed run is reused when both graph commitments
and every bounded option are identical. and every bounded option are identical.
By default, REA rescans the version inputs so changed content creates a new
run. For an explicit cache-only replay, add the returned `run_id` as
`investigation_run.replay_run_id`. REA first validates the workspace revision,
the complete run, its inventory paths and controls, and its linked Evidence;
only then may it return without requesting input-read authority.
The run ID is a SHA-256 commitment to both root and graph digests plus the The run ID is a SHA-256 commitment to both root and graph digests plus the
normalized options. Local paths and timestamps do not affect that identity. normalized options. Local paths and timestamps do not affect that identity.
Changing either artifact or a budget creates a new run in the same workspace; Changing either artifact or a budget creates a new run in the same workspace;
+8 -14
View File
@@ -6,21 +6,15 @@ import { relative, resolve } from "node:path";
const root = resolve(import.meta.dirname, ".."); const root = resolve(import.meta.dirname, "..");
// Collect all TS, MJS, and Python source files, excluding generated and third-party dirs. // Native globSync is available throughout the Node versions in package.engines.
const sourceFiles = [ // Its exclusion option is `exclude`; `ignore` and `nodir` belong to npm glob.
...globSync("src/**/*.ts", { const sourceFiles = globSync(
["src/**/*.ts", "tests/**/*.ts", "scripts/**/*.mjs", "bridge/**/*.py"],
{
cwd: root, cwd: root,
nodir: true, exclude: (path) => path.split(/[\\/]/u).includes("node_modules"),
ignore: ["dist/**", "node_modules/**"], },
}), );
...globSync("tests/**/*.ts", {
cwd: root,
nodir: true,
ignore: ["node_modules/**"],
}),
...globSync("scripts/**/*.mjs", { cwd: root, nodir: true }),
...globSync("bridge/**/*.py", { cwd: root, nodir: true }),
];
const patterns = [ const patterns = [
{ regex: /TODO(?::\s*(.*))?$/gm, label: "TODO" }, { regex: /TODO(?::\s*(.*))?$/gm, label: "TODO" },
+126 -97
View File
@@ -170,72 +170,88 @@ try {
throw new Error("packaged artifact inventory CLI failed"); throw new Error("packaged artifact inventory CLI failed");
// prettier-ignore // prettier-ignore
await verifyPackagedInvestigation({ cli, workspace, evidenceRoot, artifactArchive, environment }); await verifyPackagedInvestigation({ cli, workspace, evidenceRoot, artifactArchive, environment });
const overview = json( if (process.platform === "linux") {
await run(cli, ["analyze", process.execPath, "--json"], environment), const unsupportedExecution = await runWithStatus(
); cli,
if ( ["analyze", process.execPath, "--json"],
overview.operation !== "binary_overview" || environment,
overview.normalized_result?.procedure_count < 1
)
throw new Error(
`packaged Hopper-backed analyze CLI failed: ${JSON.stringify(overview)}`,
); );
const inspected = json( const unsupported = json(unsupportedExecution.stdout);
await run( if (
cli, unsupportedExecution.status !== 1 ||
[ unsupported.details?.failure_code !== "unsupported_hopper_build"
"inspect", )
process.execPath, throw new Error(
"--detail", `packaged Linux Hopper verification did not fail closed: ${JSON.stringify(unsupported)}`,
"detailed", );
"--limit", } else {
"1", const overview = json(
"--json", await run(cli, ["analyze", process.execPath, "--json"], environment),
],
environment,
),
);
if (
inspected.operation !== "binary_overview" ||
inspected.normalized_result?.detail !== "detailed"
)
throw new Error("packaged inspect CLI failed");
const functionResult = json(
await run(
cli,
["function", process.execPath, "0x1000", "--json"],
environment,
),
);
if (
functionResult.operation !== "analyze_function" ||
functionResult.provider?.id !== "hopper" ||
functionResult.normalized_result?.procedure?.address !== "0x1000"
)
throw new Error(
`packaged function CLI failed: ${JSON.stringify(functionResult)}`,
); );
const xrefs = json( if (
await run( overview.operation !== "binary_overview" ||
cli, overview.normalized_result?.procedure_count < 1
["xrefs", process.execPath, "0x1000", "--json"], )
environment, throw new Error(
), `packaged Hopper-backed analyze CLI failed: ${JSON.stringify(overview)}`,
); );
if ( const inspected = json(
xrefs.operation !== "xrefs" || await run(
JSON.stringify(xrefs.normalized_result) !== JSON.stringify(["0x1000"]) cli,
) [
throw new Error(`packaged xrefs CLI failed: ${JSON.stringify(xrefs)}`); "inspect",
const trace = json( process.execPath,
await run( "--detail",
cli, "detailed",
["trace", process.execPath, "fixture", "--json"], "--limit",
environment, "1",
), "--json",
); ],
if (trace.operation !== "trace_feature") environment,
throw new Error(`packaged trace CLI failed: ${JSON.stringify(trace)}`); ),
);
if (
inspected.operation !== "binary_overview" ||
inspected.normalized_result?.detail !== "detailed"
)
throw new Error("packaged inspect CLI failed");
const functionResult = json(
await run(
cli,
["function", process.execPath, "0x1000", "--json"],
environment,
),
);
if (
functionResult.operation !== "analyze_function" ||
functionResult.provider?.id !== "hopper" ||
functionResult.normalized_result?.procedure?.address !== "0x1000"
)
throw new Error(
`packaged function CLI failed: ${JSON.stringify(functionResult)}`,
);
const xrefs = json(
await run(
cli,
["xrefs", process.execPath, "0x1000", "--json"],
environment,
),
);
if (
xrefs.operation !== "xrefs" ||
JSON.stringify(xrefs.normalized_result) !== JSON.stringify(["0x1000"])
)
throw new Error(`packaged xrefs CLI failed: ${JSON.stringify(xrefs)}`);
const trace = json(
await run(
cli,
["trace", process.execPath, "fixture", "--json"],
environment,
),
);
if (trace.operation !== "trace_feature")
throw new Error(`packaged trace CLI failed: ${JSON.stringify(trace)}`);
}
const capabilities = json( const capabilities = json(
await run(cli, ["capabilities", "--json"], environment), await run(cli, ["capabilities", "--json"], environment),
); );
@@ -247,20 +263,22 @@ try {
providers.providers.some(({ id }) => typeof id !== "string") providers.providers.some(({ id }) => typeof id !== "string")
) )
throw new Error("packaged providers CLI failed"); throw new Error("packaged providers CLI failed");
const searchResult = json( if (process.platform !== "linux") {
await run( const searchResult = json(
cli, await run(
["search", process.execPath, "fixture", "--json"], cli,
environment, ["search", process.execPath, "fixture", "--json"],
), environment,
); ),
if (
searchResult.operation !== "search_strings" ||
searchResult.normalized_result?.items?.length !== 1
)
throw new Error(
`packaged search CLI failed: ${JSON.stringify(searchResult)}`,
); );
if (
searchResult.operation !== "search_strings" ||
searchResult.normalized_result?.items?.length !== 1
)
throw new Error(
`packaged search CLI failed: ${JSON.stringify(searchResult)}`,
);
}
const referenceImport = json( const referenceImport = json(
await run( await run(
cli, cli,
@@ -478,30 +496,41 @@ try {
); );
if (opened.isError === true) if (opened.isError === true)
throw new Error("packaged MCP could not open a binary"); throw new Error("packaged MCP could not open a binary");
await prompts.verifyPromptCompletion(client, mcpOptions, true); await prompts.verifyPromptCompletion(
client,
mcpOptions,
process.platform !== "linux",
);
const current = await client.callTool( const current = await client.callTool(
{ name: "current_document", arguments: {} }, { name: "current_document", arguments: {} },
mcpOptions, mcpOptions,
); );
if ( if (process.platform === "linux") {
current.isError === true || if (
json(prompts.mcpText(current)).normalized_result !== "fixture" current.isError !== true ||
) !prompts
throw new Error("packaged MCP bridge call failed"); .mcpText(current)
const batch = await client.callTool( .includes("not supported for unattended Linux analysis")
{ name: "batch_decompile", arguments: { addresses: ["0x1000"] } }, )
mcpOptions, throw new Error("packaged Linux MCP did not reject an unpinned Hopper");
); } else {
const batchResult = json(prompts.mcpText(batch)).normalized_result; if (json(prompts.mcpText(current)).normalized_result !== "fixture")
if ( throw new Error("packaged MCP bridge call failed");
batch.isError === true || const batch = await client.callTool(
batchResult?.total !== 1 || { name: "batch_decompile", arguments: { addresses: ["0x1000"] } },
batchResult?.succeeded !== 1 || mcpOptions,
batchResult?.failed !== 0 || );
batchResult?.items?.[0]?.status !== "ok" || const batchResult = json(prompts.mcpText(batch)).normalized_result;
batchResult?.items?.[0]?.pseudocode !== "return 0;" if (
) batch.isError === true ||
throw new Error("packaged MCP structured batch result failed"); batchResult?.total !== 1 ||
batchResult?.succeeded !== 1 ||
batchResult?.failed !== 0 ||
batchResult?.items?.[0]?.status !== "ok" ||
batchResult?.items?.[0]?.pseudocode !== "return 0;"
)
throw new Error("packaged MCP structured batch result failed");
}
const closed = await client.callTool( const closed = await client.callTool(
{ name: "close_binary", arguments: {} }, { name: "close_binary", arguments: {} },
mcpOptions, mcpOptions,
@@ -45,6 +45,7 @@ import {
ARTIFACT_COMPARISON_PROVIDER, ARTIFACT_COMPARISON_PROVIDER,
CHANGED_BEHAVIOR_PROVIDER, CHANGED_BEHAVIOR_PROVIDER,
} from "./InvestigationProviders.js"; } from "./InvestigationProviders.js";
import { selectCompletedInvestigationReplay } from "./CrossVersionInvestigationReplay.js";
export interface CrossVersionInvestigationOutcome { export interface CrossVersionInvestigationOutcome {
readonly evidence: Evidence; readonly evidence: Evidence;
@@ -59,6 +60,7 @@ export interface CrossVersionInvestigationExecution {
readonly signal?: AbortSignal; readonly signal?: AbortSignal;
readonly progress?: ProgressReporter; readonly progress?: ProgressReporter;
readonly integrityContinueEnabled?: boolean; readonly integrityContinueEnabled?: boolean;
readonly authorizeInputRead?: () => Promise<Result<null, AnalysisError>>;
readonly authorizeWorkspaceWrite?: () => Promise<Result<null, AnalysisError>>; readonly authorizeWorkspaceWrite?: () => Promise<Result<null, AnalysisError>>;
} }
@@ -84,6 +86,23 @@ export const runCrossVersionInvestigation = async (
if (!preflight.ok) return preflight; if (!preflight.ok) return preflight;
if (isCancelled(execution.signal)) if (isCancelled(execution.signal))
return err(new AnalysisCancelledError("find_changed_behavior")); return err(new AnalysisCancelledError("find_changed_behavior"));
if (parsed.data.replay_run_id !== undefined) {
const replay = selectCompletedInvestigationReplay(
initial.value,
parsed.data,
);
if (!replay.ok) return replay;
return completeOutcome(
replay.value.workspace,
replay.value.run,
true,
execution.session,
);
}
if (execution.authorizeInputRead !== undefined) {
const authorized = await execution.authorizeInputRead();
if (!authorized.ok) return authorized;
}
await execution.progress?.report({ await execution.progress?.report({
phase: "scan_versions", phase: "scan_versions",
completed: 0, completed: 0,
@@ -0,0 +1,372 @@
import {
changedBehaviorResultSchema,
type ChangedBehaviorResult,
} from "../domain/changedBehavior.js";
import { artifactInventoryResultSchema } from "../domain/artifactGraph.js";
import { parseArtifactInventoryEvidence } from "../domain/artifactInventoryEvidence.js";
import type { Evidence } from "../domain/evidence.js";
import {
EvidenceIntegrityError,
InvestigationWorkspaceError,
} from "../domain/errors.js";
import type {
CrossVersionInvestigationInput,
InvestigationRun,
InvestigationWorkspace,
} from "../domain/investigationWorkspace.js";
import { err, ok, type Result } from "../domain/result.js";
import {
ARTIFACT_COMPARISON_PROVIDER,
ARTIFACT_GRAPH_PROVIDER,
CHANGED_BEHAVIOR_PROVIDER,
} from "./InvestigationProviders.js";
/** Validated workspace state returned by an explicit replay selector. */
export interface CompletedInvestigationReplay {
readonly workspace: InvestigationWorkspace;
readonly run: InvestigationRun;
}
/** Select and verify one caller-identified complete run without input access. */
export const selectCompletedInvestigationReplay = (
workspace: InvestigationWorkspace | null,
input: CrossVersionInvestigationInput,
): Result<
CompletedInvestigationReplay,
InvestigationWorkspaceError | EvidenceIntegrityError
> => {
const run = workspace?.runs.find(
({ run_id: runId }) => runId === input.replay_run_id,
);
if (workspace === null || run === undefined || run.status !== "complete")
return err(new InvestigationWorkspaceError("read", "revision-conflict"));
if (!replayRequestMatches(workspace, run, input))
return err(new InvestigationWorkspaceError("read", "revision-conflict"));
const evidence = validateCompletedReplayEvidence(workspace, run);
return evidence.ok ? ok({ workspace, run }) : evidence;
};
const replayRequestMatches = (
workspace: InvestigationWorkspace,
run: InvestigationRun,
input: CrossVersionInvestigationInput,
): boolean =>
JSON.stringify(run.options) === JSON.stringify(input.options) &&
run.integrity_policy === input.integrity_policy &&
run.integrity_continue_approved === input.integrity_continue_approved &&
run.max_integrity_mismatches === input.max_integrity_mismatches &&
inventoryEvidenceMatches(workspace, run.left_inventory_evidence_ids, {
path: input.left_path,
target: run.left,
input,
}) &&
inventoryEvidenceMatches(workspace, run.right_inventory_evidence_ids, {
path: input.right_path,
target: run.right,
input,
});
interface InventoryReplayExpectation {
readonly path: string;
readonly target: InvestigationRun["left"];
readonly input: CrossVersionInvestigationInput;
}
const inventoryEvidenceMatches = (
workspace: InvestigationWorkspace,
evidenceIds: readonly string[],
expectation: InventoryReplayExpectation,
): boolean => {
const byId = new Map(
workspace.bundle.records.map((record) => [record.evidence_id, record]),
);
const evidence = evidenceIds.flatMap((evidenceId) => {
const record = byId.get(evidenceId);
return record === undefined ? [] : [record];
});
if (
evidence.length !== evidenceIds.length ||
!evidence.every((record, index) =>
inventoryPageMatches(record, index, expectation),
)
)
return false;
try {
const inventory = parseArtifactInventoryEvidence(evidence).inventory;
return (
inventory.complete &&
evidence.length ===
expectedInventoryPageCount(
inventory.manifest.node_count,
inventory.manifest.occurrence_count,
inventory.manifest.edge_count,
expectation.input.options.page_size,
) &&
inventory.manifest.root_sha256 === expectation.target.root_sha256 &&
inventory.manifest.graph_sha256 === expectation.target.graph_sha256 &&
inventory.manifest.manifest_id === expectation.target.manifest_id &&
inventory.manifest.root_format === expectation.target.format
);
} catch {
return false;
}
};
const inventoryPageMatches = (
evidence: Evidence,
index: number,
expectation: InventoryReplayExpectation,
): boolean => {
const page = artifactInventoryResultSchema.safeParse(
evidence.normalized_result,
);
if (!page.success) return false;
const offset = index * expectation.input.options.page_size;
return (
inventoryEvidenceMetadataMatches(evidence, expectation) &&
inventoryParametersMatch(evidence.parameters, offset, expectation.input) &&
inventoryResultPageMatches(page.data, offset, expectation.input)
);
};
const inventoryEvidenceMetadataMatches = (
evidence: Evidence,
expectation: InventoryReplayExpectation,
): boolean =>
evidence.operation === "inventory_artifact" &&
evidence.predicate_type === "rea.analysis/v2" &&
providerMatches(evidence.provider, ARTIFACT_GRAPH_PROVIDER) &&
evidence.subject?.local_path === expectation.path &&
evidence.subject.digest.sha256 === expectation.target.root_sha256 &&
evidence.subject.format === expectation.target.format &&
evidence.confidence === "observed" &&
evidence.authority === "shipped-artifact" &&
evidence.raw_result === null &&
evidence.environment === null &&
evidence.evidence_links.length === 0;
const inventoryResultPageMatches = (
page: ReturnType<typeof artifactInventoryResultSchema.parse>,
offset: number,
input: CrossVersionInvestigationInput,
): boolean =>
collectionPageMatches(
page.nodes,
offset,
input.options.page_size,
page.manifest.node_count,
) &&
collectionPageMatches(
page.occurrences,
offset,
input.options.page_size,
page.manifest.occurrence_count,
) &&
collectionPageMatches(
page.edges,
offset,
input.options.page_size,
page.manifest.edge_count,
) &&
valuesMatch(page.limits, traversalLimits(input));
const collectionPageMatches = (
page: {
readonly items: readonly unknown[];
readonly offset: number;
readonly limit: number;
readonly total: number;
readonly next_offset: number | null;
},
offset: number,
limit: number,
total: number,
): boolean => {
const expectedItems = Math.max(0, Math.min(limit, total - offset));
const nextOffset = offset + limit < total ? offset + limit : null;
return (
page.offset === offset &&
page.limit === limit &&
page.total === total &&
page.items.length === expectedItems &&
page.next_offset === nextOffset
);
};
const expectedInventoryPageCount = (
nodeCount: number,
occurrenceCount: number,
edgeCount: number,
pageSize: number,
): number =>
Math.max(
1,
Math.ceil(nodeCount / pageSize),
Math.ceil(occurrenceCount / pageSize),
Math.ceil(edgeCount / pageSize),
);
const inventoryParametersMatch = (
parameters: Readonly<Record<string, unknown>>,
offset: number,
input: CrossVersionInvestigationInput,
): boolean =>
Object.keys(parameters).length === 15 &&
parameters.node_offset === offset &&
parameters.node_limit === input.options.page_size &&
parameters.occurrence_offset === offset &&
parameters.occurrence_limit === input.options.page_size &&
parameters.edge_offset === offset &&
parameters.edge_limit === input.options.page_size &&
parameters.max_entries === input.options.max_entries &&
parameters.max_total_bytes === input.options.max_total_bytes &&
parameters.max_entry_bytes === input.options.max_entry_bytes &&
parameters.max_compression_ratio === input.options.max_compression_ratio &&
parameters.max_depth === input.options.max_depth &&
parameters.max_path_bytes === input.options.max_path_bytes &&
parameters.integrity_policy === input.integrity_policy &&
parameters.integrity_continue_approved ===
input.integrity_continue_approved &&
parameters.max_integrity_mismatches === input.max_integrity_mismatches;
const traversalLimits = (input: CrossVersionInvestigationInput) => ({
max_entries: input.options.max_entries,
max_total_bytes: input.options.max_total_bytes,
max_entry_bytes: input.options.max_entry_bytes,
max_compression_ratio: input.options.max_compression_ratio,
max_depth: input.options.max_depth,
max_path_bytes: input.options.max_path_bytes,
});
const validateCompletedReplayEvidence = (
workspace: InvestigationWorkspace,
run: InvestigationRun,
): Result<null, EvidenceIntegrityError> => {
const inventoryIds = [
...run.left_inventory_evidence_ids,
...run.right_inventory_evidence_ids,
];
const comparison = workspace.bundle.records.find(
({ evidence_id: evidenceId }) => evidenceId === run.comparison_evidence_id,
);
if (!comparisonEvidenceMatches(comparison, inventoryIds, run))
return inconsistentReplayEvidence();
const resultLinks = [
...new Set([comparison.evidence_id, ...inventoryIds]),
].sort((left, right) => left.localeCompare(right, "en"));
const result = workspace.bundle.records.find(
({ evidence_id: evidenceId }) => evidenceId === run.result_evidence_id,
);
if (!resultEvidenceMatches(result, resultLinks, workspace, run))
return inconsistentReplayEvidence();
const parsedResult = changedBehaviorResultSchema.safeParse(
result.normalized_result,
);
if (
!parsedResult.success ||
!resultPayloadMatches(parsedResult.data, resultLinks, workspace, run)
)
return inconsistentReplayEvidence();
return ok(null);
};
const comparisonEvidenceMatches = (
comparison: Evidence | undefined,
inventoryIds: readonly string[],
run: InvestigationRun,
): comparison is Evidence =>
comparison?.operation === "compare_artifacts" &&
comparison.predicate_type === "rea.artifact-comparison/v1" &&
providerMatches(comparison.provider, ARTIFACT_COMPARISON_PROVIDER) &&
comparison.subject === null &&
comparison.confidence === "derived" &&
comparison.authority === "analyst-inference" &&
valuesMatch(comparison.evidence_links, inventoryIds) &&
comparisonParametersMatch(comparison.parameters, run);
const resultEvidenceMatches = (
result: Evidence | undefined,
resultLinks: readonly string[],
workspace: InvestigationWorkspace,
run: InvestigationRun,
): result is Evidence =>
result?.operation === "find_changed_behavior" &&
result.predicate_type === "rea.changed-behavior/v1" &&
providerMatches(result.provider, CHANGED_BEHAVIOR_PROVIDER) &&
result.subject === null &&
result.confidence === "derived" &&
result.authority === "analyst-inference" &&
valuesMatch(result.evidence_links, resultLinks) &&
resultParametersMatch(result.parameters, workspace, run);
const resultPayloadMatches = (
result: ChangedBehaviorResult,
resultLinks: readonly string[],
workspace: InvestigationWorkspace,
run: InvestigationRun,
): boolean => {
const summary = result.investigation_run;
return (
summary?.workspace_id === workspace.workspace_id &&
summary.run_id === run.run_id &&
summary.left_manifest_id === run.left.manifest_id &&
summary.right_manifest_id === run.right.manifest_id &&
summary.inventory_evidence_count ===
run.left_inventory_evidence_ids.length +
run.right_inventory_evidence_ids.length &&
summary.comparison_evidence_id === run.comparison_evidence_id &&
valuesMatch(summary.limitations, run.limitations) &&
valuesMatch(result.evidence_links, resultLinks)
);
};
const inconsistentReplayEvidence = (): Result<never, EvidenceIntegrityError> =>
err(
new EvidenceIntegrityError(
"Completed investigation replay Evidence is inconsistent",
),
);
const comparisonParametersMatch = (
parameters: Readonly<Record<string, unknown>>,
run: InvestigationRun,
): boolean =>
Object.keys(parameters).length === 4 &&
JSON.stringify(parameters.left_evidence_ids) ===
JSON.stringify(run.left_inventory_evidence_ids) &&
JSON.stringify(parameters.right_evidence_ids) ===
JSON.stringify(run.right_inventory_evidence_ids) &&
parameters.offset === 0 &&
parameters.limit === run.options.change_limit;
const resultParametersMatch = (
parameters: Readonly<Record<string, unknown>>,
workspace: InvestigationWorkspace,
run: InvestigationRun,
): boolean =>
Object.keys(parameters).length === 5 &&
parameters.workspace_id === workspace.workspace_id &&
parameters.run_id === run.run_id &&
JSON.stringify(parameters.comparison_evidence_ids) ===
JSON.stringify([run.comparison_evidence_id]) &&
parameters.offset === 0 &&
parameters.limit === Math.min(run.options.change_limit, 100);
const providerMatches = (
actual: {
readonly id: string;
readonly name: string;
readonly version: string | null;
},
expected: {
readonly id: string;
readonly name: string;
readonly version: string | null;
},
): boolean =>
actual.id === expected.id &&
actual.name === expected.name &&
actual.version === expected.version;
const valuesMatch = (left: unknown, right: unknown): boolean =>
JSON.stringify(left) === JSON.stringify(right);
+6 -5
View File
@@ -243,12 +243,13 @@ const startCaptureRuntime = async (options: {
renderer, renderer,
started, started,
}); });
const stopSampler = startProcessSampler( const stopSampler = startProcessSampler({
terminal.pid, rootPid: terminal.pid,
runId: options.runId,
started, started,
scenario.limits.processes, limit: scenario.limits.processes,
options.samples, samples: options.samples,
); });
return { return {
replay, replay,
renderer, renderer,
+50 -21
View File
@@ -19,6 +19,7 @@ interface ProcessGroupMember {
readonly pid: number; readonly pid: number;
readonly parentPid: number; readonly parentPid: number;
readonly processGroupId: number; readonly processGroupId: number;
readonly state: string;
readonly command: string; readonly command: string;
} }
@@ -30,9 +31,19 @@ export interface ProcessOwnershipHost {
} }
/** Cleanup succeeds only when the group is absent or every member is owned. */ /** Cleanup succeeds only when the group is absent or every member is owned. */
export interface ProcessOwnershipValidationFailure {
readonly pid: number;
readonly reason: "environment-unreadable" | "run-token-mismatch";
}
/** Cleanup outcome with per-member diagnostics when ownership is uncertain. */
export type ProcessCleanupResult = export type ProcessCleanupResult =
| { readonly cleaned: true; readonly signaled: boolean } | { readonly cleaned: true; readonly signaled: boolean }
| { readonly cleaned: false; readonly reason: string }; | {
readonly cleaned: false;
readonly reason: string;
readonly failures?: readonly ProcessOwnershipValidationFailure[];
};
/** Token-verified liveness result used by post-root-exit settlement. */ /** Token-verified liveness result used by post-root-exit settlement. */
export type ProcessGroupObservation = export type ProcessGroupObservation =
@@ -59,17 +70,18 @@ const systemHost: ProcessOwnershipHost = {
if (process.platform === "win32") return []; if (process.platform === "win32") return [];
const { stdout } = await execFileAsync("ps", [ const { stdout } = await execFileAsync("ps", [
"-axo", "-axo",
"pid=,ppid=,pgid=,command=", "pid=,ppid=,pgid=,stat=,command=",
]); ]);
return stdout return stdout
.split("\n") .split("\n")
.map((line) => /\s*(\d+)\s+(\d+)\s+(\d+)\s+(.*)/u.exec(line)) .map((line) => /\s*(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(.*)/u.exec(line))
.filter((match): match is RegExpExecArray => match !== null) .filter((match): match is RegExpExecArray => match !== null)
.map((match) => ({ .map((match) => ({
pid: Number(match[1]), pid: Number(match[1]),
parentPid: Number(match[2]), parentPid: Number(match[2]),
processGroupId: Number(match[3]), processGroupId: Number(match[3]),
command: match[4] ?? "", state: match[4] ?? "",
command: match[5] ?? "",
})) }))
.filter((member) => member.processGroupId === processGroupId); .filter((member) => member.processGroupId === processGroupId);
}, },
@@ -92,6 +104,13 @@ const systemHost: ProcessOwnershipHost = {
}, },
}; };
/** Read the capture run token currently exposed by one live process. */
export const readProcessRunId = async (
pid: number,
host: ProcessOwnershipHost = systemHost,
): Promise<string | undefined> =>
(await host.environment(pid)).REA_PROCESS_RUN_ID;
/** /**
* Verify run-token ownership before signaling a POSIX process group. * Verify run-token ownership before signaling a POSIX process group.
* *
@@ -109,8 +128,11 @@ export const cleanupOwnedProcessGroup = async (
} catch { } catch {
return { cleaned: false, reason: "process group could not be inspected" }; return { cleaned: false, reason: "process group could not be inspected" };
} }
if (members.length === 0) return { cleaned: true, signaled: false }; const liveMembers = liveProcessGroupMembers(members);
const leader = members.find((member) => member.pid === ownership.leaderPid); if (liveMembers.length === 0) return { cleaned: true, signaled: false };
const leader = liveMembers.find(
(member) => member.pid === ownership.leaderPid,
);
if (leader !== undefined) { if (leader !== undefined) {
if ( if (
ownership.expectedParentPid !== undefined && ownership.expectedParentPid !== undefined &&
@@ -129,23 +151,24 @@ export const cleanupOwnedProcessGroup = async (
reason: "owned launcher command identity did not match", reason: "owned launcher command identity did not match",
}; };
} }
for (const member of members) { const failures: ProcessOwnershipValidationFailure[] = [];
let environment: Readonly<Record<string, string>>; for (const member of liveMembers) {
try { try {
environment = await host.environment(member.pid); const environment = await host.environment(member.pid);
if (environment.REA_PROCESS_RUN_ID !== ownership.runId)
failures.push({ pid: member.pid, reason: "run-token-mismatch" });
} catch { } catch {
return { failures.push({ pid: member.pid, reason: "environment-unreadable" });
cleaned: false,
reason: "process ownership could not be revalidated",
};
}
if (environment.REA_PROCESS_RUN_ID !== ownership.runId) {
return {
cleaned: false,
reason: "process group contains an unowned or PID-reused process",
};
} }
} }
if (failures.length > 0)
return {
cleaned: false,
reason: failures.some(({ reason }) => reason === "run-token-mismatch")
? "process group contains an unowned or PID-reused process"
: "process ownership could not be revalidated",
failures,
};
try { try {
host.signalGroup(ownership.processGroupId, "SIGKILL"); host.signalGroup(ownership.processGroupId, "SIGKILL");
} catch (cause: unknown) { } catch (cause: unknown) {
@@ -171,8 +194,9 @@ export const observeOwnedProcessGroup = async (
reason: "process group could not be inspected", reason: "process group could not be inspected",
}; };
} }
if (members.length === 0) return { state: "empty" }; const liveMembers = liveProcessGroupMembers(members);
for (const member of members) { if (liveMembers.length === 0) return { state: "empty" };
for (const member of liveMembers) {
try { try {
if ( if (
(await host.environment(member.pid)).REA_PROCESS_RUN_ID !== (await host.environment(member.pid)).REA_PROCESS_RUN_ID !==
@@ -192,6 +216,11 @@ export const observeOwnedProcessGroup = async (
return { state: "alive" }; return { state: "alive" };
}; };
const liveProcessGroupMembers = (
members: readonly ProcessGroupMember[],
): readonly ProcessGroupMember[] =>
members.filter(({ state }) => !state.startsWith("Z"));
const commandMatches = (actual: string, expected: string): boolean => { const commandMatches = (actual: string, expected: string): boolean => {
const normalizedActual = actual.trim(); const normalizedActual = actual.trim();
const normalizedExpected = expected.trim(); const normalizedExpected = expected.trim();
+94 -13
View File
@@ -2,6 +2,7 @@ import { execFile } from "node:child_process";
import { readFile, readdir } from "node:fs/promises"; import { readFile, readdir } from "node:fs/promises";
import { promisify } from "node:util"; import { promisify } from "node:util";
import type { ProcessSample } from "../domain/processCapture.js"; import type { ProcessSample } from "../domain/processCapture.js";
import { readProcessRunId } from "./ProcessOwnership.js";
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
const PROC_READ_CONCURRENCY = 64; const PROC_READ_CONCURRENCY = 64;
@@ -27,6 +28,29 @@ interface SampleProcessContext {
readonly signal: AbortSignal; readonly signal: AbortSignal;
} }
type ProcessIdentitySnapshot = Pick<
ProcessRow,
"pid" | "parent_pid" | "process_group_id" | "session_id" | "startTime"
>;
/** Prove that forkpty completed session setup and installed the run token. */
export const isInitializedPtyRoot = (options: {
readonly rootPid: number;
readonly expectedRunId: string;
readonly before: ProcessIdentitySnapshot;
readonly observedRunId: string | undefined;
readonly after: ProcessIdentitySnapshot;
}): boolean =>
options.before.pid === options.rootPid &&
options.before.process_group_id === options.rootPid &&
options.before.session_id === options.rootPid &&
options.after.pid === options.rootPid &&
options.after.parent_pid === options.before.parent_pid &&
options.after.process_group_id === options.rootPid &&
options.after.session_id === options.rootPid &&
options.after.startTime === options.before.startTime &&
options.observedRunId === options.expectedRunId;
const isExpectedProcReadFailure = (cause: unknown): boolean => const isExpectedProcReadFailure = (cause: unknown): boolean =>
cause instanceof Error && cause instanceof Error &&
"code" in cause && "code" in cause &&
@@ -305,6 +329,47 @@ const readPsRows = async (
.sort((left, right) => left.pid - right.pid); .sort((left, right) => left.pid - right.pid);
}; };
const readRootIdentity = async (
rootPid: number,
signal: AbortSignal,
): Promise<ProcessRow | undefined> =>
process.platform === "linux"
? readProcessStat(rootPid, signal)
: (await readPsRows(signal)).find(({ pid }) => pid === rootPid);
const inspectInitializedPtyRoot = async (
rootPid: number,
runId: string,
signal: AbortSignal,
): Promise<{ readonly startTime: string | undefined } | undefined> => {
const before = await readRootIdentity(rootPid, signal);
if (
before === undefined ||
before.process_group_id !== rootPid ||
before.session_id !== rootPid
)
return undefined;
let observedRunId: string | undefined;
try {
observedRunId = await readProcessRunId(rootPid);
} catch {
return undefined;
}
const after = await readRootIdentity(rootPid, signal);
if (
after === undefined ||
!isInitializedPtyRoot({
rootPid,
expectedRunId: runId,
before,
observedRunId,
after,
})
)
return undefined;
return { startTime: after.startTime };
};
const samplePs = async ( const samplePs = async (
context: SampleProcessContext, context: SampleProcessContext,
): Promise<readonly ProcessRow[]> => { ): Promise<readonly ProcessRow[]> => {
@@ -375,31 +440,47 @@ const sampleProcesses = async (
}; };
/** Start bounded process-tree sampling and expose an awaited stop. */ /** Start bounded process-tree sampling and expose an awaited stop. */
export const startProcessSampler = ( export const startProcessSampler = (options: {
rootPid: number, readonly rootPid: number;
started: number, readonly runId: string;
limit: number, readonly started: number;
samples: ProcessSample[], readonly limit: number;
): (() => Promise<{ readonly partial: boolean }>) => { readonly samples: ProcessSample[];
}): (() => Promise<{ readonly partial: boolean }>) => {
const { rootPid, runId, started, limit, samples } = options;
const identities = new Map<number, string>(); const identities = new Map<number, string>();
const lastObservations = new Map<number, string>(); const lastObservations = new Map<number, string>();
let pending: Promise<void> | undefined; let pending: Promise<void> | undefined;
let stopped = false; let stopped = false;
let abortCurrent: (() => void) | undefined; let abortCurrent: (() => void) | undefined;
let partial = false; let partial = false;
let rootInitialized = false;
const sample = (): void => { const sample = (): void => {
if (stopped || pending !== undefined) return; if (stopped || pending !== undefined) return;
const controller = new AbortController(); const controller = new AbortController();
abortCurrent = () => controller.abort(); abortCurrent = () => controller.abort();
pending = sampleProcesses({ pending = (async () => {
rootPid, if (!rootInitialized) {
elapsedMs: Date.now() - started, const identity = await inspectInitializedPtyRoot(
limit: limit + 1, rootPid,
identities, runId,
signal: controller.signal, controller.signal,
}) );
if (identity === undefined) return [];
if (identity.startTime !== undefined)
identities.set(rootPid, identity.startTime);
rootInitialized = true;
}
return sampleProcesses({
rootPid,
elapsedMs: Date.now() - started,
limit: limit + 1,
identities,
signal: controller.signal,
});
})()
.then((values) => { .then((values) => {
for (const value of values) { for (const value of values) {
const observation = JSON.stringify({ const observation = JSON.stringify({
+14 -12
View File
@@ -1,6 +1,7 @@
import { isSecretLikePath } from "../domain/referenceSourceClassification.js"; import { isSecretLikePath } from "../domain/referenceSourceClassification.js";
import { import {
createHistoricalSourceGraph, createHistoricalSourceGraph,
referenceSourceParseFailureKey,
type HistoricalSourceGraph, type HistoricalSourceGraph,
type HistoricalSourceGraphInput, type HistoricalSourceGraphInput,
} from "../domain/referenceSourceGraph.js"; } from "../domain/referenceSourceGraph.js";
@@ -204,18 +205,19 @@ export const importReferenceSource = async (
if (isAborted(options.signal)) return err(cancelled()); if (isAborted(options.signal)) return err(cancelled());
const uniqueRelationships = deduplicateRelationships(relationships); const uniqueRelationships = deduplicateRelationships(relationships);
const uniqueFailures = [...parseFailures] const uniqueFailures = [
.sort((left, right) => { ...new Map(
const byPath = byCodePoint(left.path, right.path); parseFailures.map((parseFailure) => [
if (byPath !== 0) return byPath; referenceSourceParseFailureKey(parseFailure),
return byCodePoint(left.parser, right.parser); parseFailure,
}) ]),
.filter((value, index, array) => { ).values(),
if (index === 0) return true; ].sort((left, right) =>
const previous = array[index - 1]; byCodePoint(
if (previous === undefined) return true; referenceSourceParseFailureKey(left),
return value.path !== previous.path || value.parser !== previous.parser; referenceSourceParseFailureKey(right),
}); ),
);
const sortedEntries = [...entries].sort((left, right) => const sortedEntries = [...entries].sort((left, right) =>
byCodePoint(left.path, right.path), byCodePoint(left.path, right.path),
+21 -8
View File
@@ -25,7 +25,10 @@ import { installMacHopper } from "./MacHopper.js";
import { configureDetectedClients } from "./SetupClients.js"; import { configureDetectedClients } from "./SetupClients.js";
import { supportedClients, type SetupClient } from "./SupportedClients.js"; import { supportedClients, type SetupClient } from "./SupportedClients.js";
export type { SetupClient } from "./SupportedClients.js"; export type { SetupClient } from "./SupportedClients.js";
import { installCanonicalSkill } from "./SetupSkill.js"; import {
canonicalSkillInstallationNeeded,
installCanonicalSkill,
} from "./SetupSkill.js";
import { setupPlan } from "./SetupPlan.js"; import { setupPlan } from "./SetupPlan.js";
import { import {
setupInstallFailure, setupInstallFailure,
@@ -69,6 +72,7 @@ export interface SetupHost {
hopperPath: string | undefined, hopperPath: string | undefined,
command: readonly string[], command: readonly string[],
): Promise<ClientConfigurationResult>; ): Promise<ClientConfigurationResult>;
skillInstallationNeeded(): Promise<boolean>;
installSkill(): Promise<"installed" | "unchanged" | "failed">; installSkill(): Promise<"installed" | "unchanged" | "failed">;
doctor(): Promise<Awaited<ReturnType<typeof runDoctor>>>; doctor(): Promise<Awaited<ReturnType<typeof runDoctor>>>;
} }
@@ -140,7 +144,13 @@ export const runSetup = async (
); );
const installHopper = hopperPath === undefined || linuxHopperRepairNeeded; const installHopper = hopperPath === undefined || linuxHopperRepairNeeded;
const detectedClients = await host.detectedClients(); const detectedClients = await host.detectedClients();
plannedActions = setupPlan(host.platform, installHopper, detectedClients); const installSkill = await host.skillInstallationNeeded();
plannedActions = setupPlan(
host.platform,
installHopper,
detectedClients,
installSkill,
);
let approved = options.approved; let approved = options.approved;
let interactiveApproval = false; let interactiveApproval = false;
if (!approved && confirm !== undefined && !options.structured) { if (!approved && confirm !== undefined && !options.structured) {
@@ -182,12 +192,14 @@ export const runSetup = async (
appliedActions, appliedActions,
}); });
if (clientFailure !== undefined) return fail(clientFailure); if (clientFailure !== undefined) return fail(clientFailure);
const skill = await host.installSkill(); if (installSkill) {
if (skill === "failed") const skill = await host.installSkill();
return fail( if (skill === "failed")
"REA analysis skill could not be installed or verified. Check permissions for `~/.agents/skills`, then rerun setup.", return fail(
); "REA analysis skill could not be installed or verified. Check permissions for `~/.agents/skills`, then rerun setup.",
if (skill === "installed") appliedActions.push("installed_skill"); );
if (skill === "installed") appliedActions.push("installed_skill");
}
const doctor = await host.doctor(); const doctor = await host.doctor();
const remediation = finalSetupRemediation( const remediation = finalSetupRemediation(
host.platform, host.platform,
@@ -261,6 +273,7 @@ const systemSetupHost = (): SetupHost => {
: client.format === "toml" : client.format === "toml"
? configureTomlClient(client, hopperPath, command) ? configureTomlClient(client, hopperPath, command)
: configureJsonClient(client, hopperPath, command), : configureJsonClient(client, hopperPath, command),
skillInstallationNeeded: () => canonicalSkillInstallationNeeded(homedir()),
installSkill: () => installCanonicalSkill(homedir()), installSkill: () => installCanonicalSkill(homedir()),
doctor: () => runDoctor(undefined, doctorHost), doctor: () => runDoctor(undefined, doctorHost),
}; };
+6 -4
View File
@@ -14,7 +14,7 @@ const failedConfigurationMessage = (
return "Agent configuration could not be verified after writing. Repair the configuration file or restore its `.rea.backup`, then rerun setup."; return "Agent configuration could not be verified after writing. Repair the configuration file or restore its `.rea.backup`, then rerun setup.";
}; };
/** Configure each detected agent, stopping after the first failed transaction. */ /** Configure every detected agent and report all failed transaction classes. */
export const configureDetectedClients = async (options: { export const configureDetectedClients = async (options: {
readonly host: SetupHost; readonly host: SetupHost;
readonly detectedClients: readonly SetupClient[]; readonly detectedClients: readonly SetupClient[];
@@ -23,6 +23,7 @@ export const configureDetectedClients = async (options: {
readonly clients: Record<string, ClientConfigurationResult>; readonly clients: Record<string, ClientConfigurationResult>;
readonly appliedActions: string[]; readonly appliedActions: string[];
}): Promise<string | undefined> => { }): Promise<string | undefined> => {
const failures = new Set<"backup" | "write" | "readback">();
for (const client of options.detectedClients) { for (const client of options.detectedClients) {
const result = await options.host.configureClient( const result = await options.host.configureClient(
client, client,
@@ -30,10 +31,11 @@ export const configureDetectedClients = async (options: {
options.command, options.command,
); );
options.clients[client.name] = result; options.clients[client.name] = result;
if (result.status === "failed") if (result.status === "failed") failures.add(result.reason);
return failedConfigurationMessage(result.reason);
if (result.status === "configured") if (result.status === "configured")
options.appliedActions.push(`configured_${client.name}`); options.appliedActions.push(`configured_${client.name}`);
} }
return undefined; return failures.size === 0
? undefined
: [...failures].map(failedConfigurationMessage).join(" ");
}; };
+11 -6
View File
@@ -5,6 +5,7 @@ export const setupPlan = (
platform: NodeJS.Platform, platform: NodeJS.Platform,
installHopper: boolean, installHopper: boolean,
clients: readonly SetupClient[], clients: readonly SetupClient[],
installSkill: boolean,
): readonly SetupAction[] => [ ): readonly SetupAction[] => [
...(installHopper ...(installHopper
? [ ? [
@@ -32,10 +33,14 @@ export const setupPlan = (
external: false, external: false,
}), }),
), ),
{ ...(installSkill
kind: "install_skill", ? [
target: "~/.agents/skills/rea-analysis/SKILL.md", {
detail: "Install or update the bundled REA analysis skill.", kind: "install_skill" as const,
external: false, target: "~/.agents/skills/rea-analysis/SKILL.md",
}, detail: "Install or update the bundled REA analysis skill.",
external: false,
},
]
: []),
]; ];
+30 -13
View File
@@ -4,26 +4,43 @@ import writeFileAtomic from "write-file-atomic";
import { PRODUCT_IDENTITY } from "../identity.js"; import { PRODUCT_IDENTITY } from "../identity.js";
const canonicalSkillDestination = (home: string): string =>
join(home, ".agents/skills", PRODUCT_IDENTITY.skillName, "SKILL.md");
const readCanonicalSkill = (): Promise<string> =>
readFile(
new URL(
`../../skills/${PRODUCT_IDENTITY.skillName}/SKILL.md`,
import.meta.url,
),
"utf8",
);
/** Determine whether setup would need to install or update the canonical skill. */
export const canonicalSkillInstallationNeeded = async (
home: string,
): Promise<boolean> => {
try {
const [canonical, installed] = await Promise.all([
readCanonicalSkill(),
readFile(canonicalSkillDestination(home), "utf8").catch(() => undefined),
]);
return installed !== canonical;
} catch {
// Disclose the action when setup cannot prove the installed copy is aligned.
return true;
}
};
/** Transactionally install or upgrade the versioned canonical REA skill. */ /** Transactionally install or upgrade the versioned canonical REA skill. */
export const installCanonicalSkill = async ( export const installCanonicalSkill = async (
home: string, home: string,
): Promise<"installed" | "unchanged" | "failed"> => { ): Promise<"installed" | "unchanged" | "failed"> => {
const destination = join( const destination = canonicalSkillDestination(home);
home,
".agents/skills",
PRODUCT_IDENTITY.skillName,
"SKILL.md",
);
const backup = `${destination}.rea.backup`; const backup = `${destination}.rea.backup`;
let original: string | undefined; let original: string | undefined;
try { try {
const content = await readFile( const content = await readCanonicalSkill();
new URL(
`../../skills/${PRODUCT_IDENTITY.skillName}/SKILL.md`,
import.meta.url,
),
"utf8",
);
original = await readFile(destination, "utf8").catch(() => undefined); original = await readFile(destination, "utf8").catch(() => undefined);
if (original === content) return "unchanged"; if (original === content) return "unchanged";
await mkdir(dirname(destination), { recursive: true }); await mkdir(dirname(destination), { recursive: true });
+2
View File
@@ -194,6 +194,8 @@ const runCommand = (
): Promise<boolean> => ): Promise<boolean> =>
new Promise((resolveResult) => { new Promise((resolveResult) => {
const child = spawn(command, [...arguments_], { const child = spawn(command, [...arguments_], {
// Reserve parent stdout for Incur's structured result. Array positions
// are child fds 0/1/2, so npm stdout and stderr both reach parent stderr.
stdio: stdio:
output === "human" ? "inherit" : ["inherit", process.stderr, "inherit"], output === "human" ? "inherit" : ["inherit", process.stderr, "inherit"],
}); });
+1
View File
@@ -328,6 +328,7 @@ export class CdpBrowserProvider implements BrowserObservationPort {
return await capturePage({ return await capturePage({
connection: targetSession.connection, connection: targetSession.connection,
sessionId: targetSession.sessionId, sessionId: targetSession.sessionId,
operation,
discovery, discovery,
target, target,
input, input,
+4 -3
View File
@@ -1,6 +1,7 @@
import { import {
AnalysisCancelledError, AnalysisCancelledError,
BrowserObservationError, BrowserObservationError,
type BrowserObservationOperation,
} from "../domain/errors.js"; } from "../domain/errors.js";
import { sanitizeBrowserUrl } from "../domain/browserObservation.js"; import { sanitizeBrowserUrl } from "../domain/browserObservation.js";
import { createWebTextArtifact } from "../domain/webContentArtifact.js"; import { createWebTextArtifact } from "../domain/webContentArtifact.js";
@@ -74,11 +75,11 @@ export const sourceExcluded = (
export const delayWithCancellation = async ( export const delayWithCancellation = async (
durationMs: number, durationMs: number,
operation: BrowserObservationOperation,
signal?: AbortSignal, signal?: AbortSignal,
): Promise<void> => { ): Promise<void> => {
if (durationMs === 0) return; if (durationMs === 0) return;
if (signal?.aborted === true) if (signal?.aborted === true) throw new AnalysisCancelledError(operation);
throw new AnalysisCancelledError("inspect_web_page");
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => { const timer = setTimeout(() => {
signal?.removeEventListener("abort", onAbort); signal?.removeEventListener("abort", onAbort);
@@ -87,7 +88,7 @@ export const delayWithCancellation = async (
const onAbort = (): void => { const onAbort = (): void => {
clearTimeout(timer); clearTimeout(timer);
signal?.removeEventListener("abort", onAbort); signal?.removeEventListener("abort", onAbort);
reject(new AnalysisCancelledError("inspect_web_page")); reject(new AnalysisCancelledError(operation));
}; };
signal?.addEventListener("abort", onAbort, { once: true }); signal?.addEventListener("abort", onAbort, { once: true });
if (signal?.aborted === true) onAbort(); if (signal?.aborted === true) onAbort();
+48 -28
View File
@@ -5,6 +5,7 @@ import {
AnalysisError, AnalysisError,
AnalysisTimeoutError, AnalysisTimeoutError,
BrowserObservationError, BrowserObservationError,
type BrowserObservationOperation,
} from "../domain/errors.js"; } from "../domain/errors.js";
export interface CdpEvent { export interface CdpEvent {
@@ -25,34 +26,38 @@ const COMMAND_TIMEOUT_MS = 5_000;
const MAX_PAYLOAD_BYTES = 16 * 1_024 * 1_024; const MAX_PAYLOAD_BYTES = 16 * 1_024 * 1_024;
const MAX_PENDING_COMMANDS = 128; const MAX_PENDING_COMMANDS = 128;
/** Correlated, bounded JSON-RPC transport for a browser-level CDP socket. */ /** Correlated, bounded JSON-RPC transport for one CDP target operation. */
export class CdpConnection { export class CdpConnection {
readonly #pending = new Map<number, PendingCommand>(); readonly #pending = new Map<number, PendingCommand>();
readonly #listeners = new Set<(event: CdpEvent) => void>(); readonly #listeners = new Set<(event: CdpEvent) => void>();
readonly #disconnectListeners = new Set<() => void>();
#nextId = 1; #nextId = 1;
#closed = false; #closed = false;
#protocolFailed = false; #protocolFailed = false;
private constructor(private readonly socket: WebSocket) { private constructor(
private readonly socket: WebSocket,
private readonly operation: BrowserObservationOperation,
) {
socket.on("message", (data) => this.#receive(data)); socket.on("message", (data) => this.#receive(data));
socket.on("close", () => this.#failPending("disconnected")); socket.on("close", () => this.#disconnect());
socket.on("error", () => this.#failPending("disconnected")); socket.on("error", () => this.#disconnect());
} }
/** Connect to one already-validated loopback browser WebSocket. */ /** Connect to one already-validated loopback CDP WebSocket. */
static async connect( static async connect(
url: string, url: string,
operation: BrowserObservationOperation,
signal?: AbortSignal, signal?: AbortSignal,
): Promise<CdpConnection> { ): Promise<CdpConnection> {
if (signal?.aborted === true) if (signal?.aborted === true) throw new AnalysisCancelledError(operation);
throw new AnalysisCancelledError("inspect_web_page");
const socket = new WebSocket(url, { const socket = new WebSocket(url, {
handshakeTimeout: CONNECT_TIMEOUT_MS, handshakeTimeout: CONNECT_TIMEOUT_MS,
maxPayload: MAX_PAYLOAD_BYTES, maxPayload: MAX_PAYLOAD_BYTES,
perMessageDeflate: false, perMessageDeflate: false,
}); });
await waitForOpen(socket, signal); await waitForOpen(socket, operation, signal);
return new CdpConnection(socket); return new CdpConnection(socket, operation);
} }
/** Subscribe to validated CDP event envelopes. */ /** Subscribe to validated CDP event envelopes. */
@@ -61,6 +66,16 @@ export class CdpConnection {
return () => this.#listeners.delete(listener); return () => this.#listeners.delete(listener);
} }
/** Subscribe to an unexpected transport loss while an operation is active. */
onDisconnect(listener: () => void): () => void {
if (this.#closed) {
listener();
return () => undefined;
}
this.#disconnectListeners.add(listener);
return () => this.#disconnectListeners.delete(listener);
}
/** Execute one bounded command, optionally within a flat target session. */ /** Execute one bounded command, optionally within a flat target session. */
async send( async send(
method: string, method: string,
@@ -69,13 +84,13 @@ export class CdpConnection {
signal?: AbortSignal, signal?: AbortSignal,
): Promise<unknown> { ): Promise<unknown> {
if (this.#protocolFailed) if (this.#protocolFailed)
throw new BrowserObservationError("inspect_web_page", "protocol_error"); throw new BrowserObservationError(this.operation, "protocol_error");
if (this.#closed || this.socket.readyState !== WebSocket.OPEN) if (this.#closed || this.socket.readyState !== WebSocket.OPEN)
throw new BrowserObservationError("inspect_web_page", "disconnected"); throw new BrowserObservationError(this.operation, "disconnected");
if (this.#pending.size >= MAX_PENDING_COMMANDS) if (this.#pending.size >= MAX_PENDING_COMMANDS)
throw new BrowserObservationError("inspect_web_page", "payload_limit"); throw new BrowserObservationError(this.operation, "payload_limit");
if (signal?.aborted === true) if (signal?.aborted === true)
throw new AnalysisCancelledError("inspect_web_page"); throw new AnalysisCancelledError(this.operation);
const id = this.#nextId; const id = this.#nextId;
this.#nextId += 1; this.#nextId += 1;
return await new Promise((resolve, reject) => { return await new Promise((resolve, reject) => {
@@ -85,15 +100,13 @@ export class CdpConnection {
clearTimeout(pending.timer); clearTimeout(pending.timer);
pending.removeAbort(); pending.removeAbort();
this.#pending.delete(id); this.#pending.delete(id);
reject(new AnalysisCancelledError("inspect_web_page")); reject(new AnalysisCancelledError(this.operation));
}; };
signal?.addEventListener("abort", onAbort, { once: true }); signal?.addEventListener("abort", onAbort, { once: true });
const timer = setTimeout(() => { const timer = setTimeout(() => {
signal?.removeEventListener("abort", onAbort); signal?.removeEventListener("abort", onAbort);
this.#pending.delete(id); this.#pending.delete(id);
reject( reject(new AnalysisTimeoutError(this.operation, COMMAND_TIMEOUT_MS));
new AnalysisTimeoutError("inspect_web_page", COMMAND_TIMEOUT_MS),
);
}, COMMAND_TIMEOUT_MS); }, COMMAND_TIMEOUT_MS);
this.#pending.set(id, { this.#pending.set(id, {
resolve, resolve,
@@ -114,7 +127,7 @@ export class CdpConnection {
if (pending === undefined) return; if (pending === undefined) return;
this.#complete(id, pending); this.#complete(id, pending);
reject( reject(
new BrowserObservationError("inspect_web_page", "disconnected", { new BrowserObservationError(this.operation, "disconnected", {
cause: error, cause: error,
}), }),
); );
@@ -191,7 +204,7 @@ export class CdpConnection {
this.#complete(id, pending); this.#complete(id, pending);
if ("error" in message) { if ("error" in message) {
pending.reject( pending.reject(
new BrowserObservationError("inspect_web_page", "protocol_error"), new BrowserObservationError(this.operation, "protocol_error"),
); );
return; return;
} }
@@ -209,9 +222,17 @@ export class CdpConnection {
else this.#protocolFailed = true; else this.#protocolFailed = true;
for (const [id, pending] of this.#pending) { for (const [id, pending] of this.#pending) {
this.#complete(id, pending); this.#complete(id, pending);
pending.reject(new BrowserObservationError("inspect_web_page", reason)); pending.reject(new BrowserObservationError(this.operation, reason));
} }
} }
#disconnect(): void {
const wasClosed = this.#closed;
this.#failPending("disconnected");
if (wasClosed) return;
for (const listener of this.#disconnectListeners) listener();
this.#disconnectListeners.clear();
}
} }
const rawText = (data: RawData): string => const rawText = (data: RawData): string =>
@@ -226,13 +247,14 @@ const isRecord = (value: unknown): value is Record<string, unknown> =>
const waitForOpen = async ( const waitForOpen = async (
socket: WebSocket, socket: WebSocket,
operation: BrowserObservationOperation,
signal?: AbortSignal, signal?: AbortSignal,
): Promise<void> => ): Promise<void> =>
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
const timer = setTimeout(() => { const timer = setTimeout(() => {
cleanup(); cleanup();
terminateSocket(socket); terminateSocket(socket);
reject(new AnalysisTimeoutError("inspect_web_page", CONNECT_TIMEOUT_MS)); reject(new AnalysisTimeoutError(operation, CONNECT_TIMEOUT_MS));
}, CONNECT_TIMEOUT_MS); }, CONNECT_TIMEOUT_MS);
const onOpen = (): void => { const onOpen = (): void => {
cleanup(); cleanup();
@@ -242,17 +264,15 @@ const waitForOpen = async (
cleanup(); cleanup();
reject( reject(
signal?.aborted === true signal?.aborted === true
? new AnalysisCancelledError("inspect_web_page") ? new AnalysisCancelledError(operation)
: new BrowserObservationError( : new BrowserObservationError(operation, "endpoint_unreachable", {
"inspect_web_page", cause,
"endpoint_unreachable", }),
{ cause },
),
); );
}; };
const onAbort = (): void => { const onAbort = (): void => {
terminateSocket(socket); terminateSocket(socket);
onFailure(new AnalysisCancelledError("inspect_web_page")); onFailure(new AnalysisCancelledError(operation));
}; };
const cleanup = (): void => { const cleanup = (): void => {
clearTimeout(timer); clearTimeout(timer);
+5 -1
View File
@@ -95,7 +95,11 @@ export const inspectCdpElectronPage = async (
context.sessionId, context.sessionId,
context.signal, context.signal,
); );
await delayWithCancellation(context.input.observation_ms, context.signal); await delayWithCancellation(
context.input.observation_ms,
"inspect_electron_page",
context.signal,
);
await report(context.progress, 2, "Capturing Electron structure"); await report(context.progress, 2, "Capturing Electron structure");
const resources = await captureResources(context, roots, completeness); const resources = await captureResources(context, roots, completeness);
const dom = await captureDom(context, roots, completeness); const dom = await captureDom(context, roots, completeness);
+1 -1
View File
@@ -54,7 +54,7 @@ export class CdpElectronProvider implements ElectronObservationPort {
const roots = await canonicalElectronRoots(input.allowed_file_roots); const roots = await canonicalElectronRoots(input.allowed_file_roots);
const discovery = await discoverCdpEndpoint( const discovery = await discoverCdpEndpoint(
input.cdp_endpoint, input.cdp_endpoint,
"list_browser_targets", "list_electron_targets",
options.signal, options.signal,
); );
const allowed = []; const allowed = [];
+31 -6
View File
@@ -3,7 +3,10 @@ import type {
ObserveWebSessionInput, ObserveWebSessionInput,
WebObservationSession, WebObservationSession,
} from "../domain/browserSession.js"; } from "../domain/browserSession.js";
import { BrowserObservationError } from "../domain/errors.js"; import {
AnalysisCancelledError,
BrowserObservationError,
} from "../domain/errors.js";
import type { CdpEndpointDiscovery, CdpEndpointTarget } from "./CdpEndpoint.js"; import type { CdpEndpointDiscovery, CdpEndpointTarget } from "./CdpEndpoint.js";
import { CdpConnection, type CdpEvent } from "./CdpConnection.js"; import { CdpConnection, type CdpEvent } from "./CdpConnection.js";
import { CdpCaptureCompleteness } from "./CdpCaptureCompleteness.js"; import { CdpCaptureCompleteness } from "./CdpCaptureCompleteness.js";
@@ -48,8 +51,12 @@ export const observeCdpSession = async (
initialUrl, initialUrl,
); );
const removeListener = context.connection.onEvent((event) => { const removeListener = context.connection.onEvent((event) => {
if (event.sessionId === context.sessionId) capture.ingest(event); if (observationEventMatches(event, context.sessionId))
capture.ingest(event);
}); });
const removeDisconnectListener = context.connection.onDisconnect(() =>
capture.targetTerminated(),
);
try { try {
await context.connection.send( await context.connection.send(
"Page.enable", "Page.enable",
@@ -115,6 +122,7 @@ export const observeCdpSession = async (
}; };
} finally { } finally {
removeListener(); removeListener();
removeDisconnectListener();
} }
}; };
@@ -162,8 +170,7 @@ class TimelineCapture {
break; break;
case "Inspector.targetCrashed": case "Inspector.targetCrashed":
case "Target.detachedFromTarget": case "Target.detachedFromTarget":
this.#add("target_terminated", params, null, null, "target_terminated"); this.targetTerminated();
this.end("target_terminated");
break; break;
} }
} }
@@ -181,6 +188,12 @@ class TimelineCapture {
this.#listeners.clear(); this.#listeners.clear();
} }
targetTerminated(): void {
if (this.#endReason !== undefined) return;
this.#add("target_terminated", {}, null, null, "target_terminated");
this.end("target_terminated");
}
setFinalUrl(rawUrl: string): void { setFinalUrl(rawUrl: string): void {
const destination = scopedUrl(rawUrl, this.allowedOrigins); const destination = scopedUrl(rawUrl, this.allowedOrigins);
this.finalUrl = destination.scope === "approved" ? destination.url : null; this.finalUrl = destination.scope === "approved" ? destination.url : null;
@@ -300,6 +313,18 @@ class TimelineCapture {
} }
} }
const observationEventMatches = (
event: CdpEvent,
sessionId: string | undefined,
): boolean => {
if (event.method !== "Target.detachedFromTarget")
return event.sessionId === sessionId;
return (
sessionId !== undefined &&
stringValue(recordValue(event.params)?.sessionId) === sessionId
);
};
const waitForWindow = async ( const waitForWindow = async (
durationMs: number, durationMs: number,
capture: TimelineCapture, capture: TimelineCapture,
@@ -321,7 +346,7 @@ const waitForWindow = async (
settled = true; settled = true;
clearTimeout(timer); clearTimeout(timer);
removeEnd(); removeEnd();
reject(signal?.reason ?? new Error("browser observation cancelled")); reject(new AnalysisCancelledError("observe_web_session"));
}; };
const timer = setTimeout(() => finish("window_elapsed"), durationMs); const timer = setTimeout(() => finish("window_elapsed"), durationMs);
removeEnd = capture.onEnd(finish); removeEnd = capture.onEnd(finish);
@@ -347,7 +372,7 @@ const authorizedFrame = async (
"inspect_web_page", "inspect_web_page",
"target_not_allowed", "target_not_allowed",
); );
await delayWithCancellation(25, context.signal); await delayWithCancellation(25, "observe_web_session", context.signal);
} }
throw new BrowserObservationError("inspect_web_page", "target_not_allowed"); throw new BrowserObservationError("inspect_web_page", "target_not_allowed");
}; };
+14 -3
View File
@@ -3,7 +3,10 @@ import type {
WebPageInspection, WebPageInspection,
} from "../domain/browserObservation.js"; } from "../domain/browserObservation.js";
import type { ProgressReporter } from "../application/ProgressReporter.js"; import type { ProgressReporter } from "../application/ProgressReporter.js";
import { BrowserObservationError } from "../domain/errors.js"; import {
BrowserObservationError,
type BrowserObservationOperation,
} from "../domain/errors.js";
import { import {
reconcileCapturedWebScript, reconcileCapturedWebScript,
stableWebResources, stableWebResources,
@@ -38,6 +41,10 @@ import type { WebSourceMapRequest } from "./WebSourceMapFetcher.js";
interface CaptureContext { interface CaptureContext {
readonly connection: CdpConnection; readonly connection: CdpConnection;
readonly sessionId: string | undefined; readonly sessionId: string | undefined;
readonly operation: Extract<
BrowserObservationOperation,
"inspect_web_page" | "analyze_web_bundle"
>;
readonly discovery: CdpEndpointDiscovery; readonly discovery: CdpEndpointDiscovery;
readonly target: CdpEndpointTarget; readonly target: CdpEndpointTarget;
readonly input: InspectWebPageInput; readonly input: InspectWebPageInput;
@@ -225,7 +232,11 @@ const authorizeObservationWindow = async (
events.beginAuthorizedFrame(mainFrame.frame_id); events.beginAuthorizedFrame(mainFrame.frame_id);
await enableObservationDomains(connection, sessionId, signal); await enableObservationDomains(connection, sessionId, signal);
await report(context.progress, 2, "Observing page events"); await report(context.progress, 2, "Observing page events");
await delayWithCancellation(context.input.observation_ms, signal); await delayWithCancellation(
context.input.observation_ms,
context.operation,
signal,
);
if (events.originViolation) if (events.originViolation)
throw new BrowserObservationError("inspect_web_page", "target_not_allowed"); throw new BrowserObservationError("inspect_web_page", "target_not_allowed");
}; };
@@ -323,7 +334,7 @@ const authorizedFrameTree = async (
"inspect_web_page", "inspect_web_page",
"target_not_allowed", "target_not_allowed",
); );
await delayWithCancellation(25, context.signal); await delayWithCancellation(25, context.operation, context.signal);
} }
throw new BrowserObservationError("inspect_web_page", "target_not_allowed"); throw new BrowserObservationError("inspect_web_page", "target_not_allowed");
}; };
+5 -1
View File
@@ -23,7 +23,11 @@ export const openCdpTargetSession = async (
signal?: AbortSignal, signal?: AbortSignal,
): Promise<CdpTargetSession> => { ): Promise<CdpTargetSession> => {
const webSocket = cdpTargetWebSocket(discovery, target, operation); const webSocket = cdpTargetWebSocket(discovery, target, operation);
const connection = await CdpConnection.connect(webSocket.url, signal); const connection = await CdpConnection.connect(
webSocket.url,
operation,
signal,
);
if (webSocket.scope === "page") return { connection, sessionId: undefined }; if (webSocket.scope === "page") return { connection, sessionId: undefined };
try { try {
const attached = await connection.send( const attached = await connection.send(
+1
View File
@@ -96,6 +96,7 @@ export const discoverWebMcp = async (
// CDP sends the enable response before the required toolsAdded replay. // CDP sends the enable response before the required toolsAdded replay.
await delayWithCancellation( await delayWithCancellation(
Math.max(context.input.observation_ms, 25), Math.max(context.input.observation_ms, 25),
"discover_webmcp_tools",
context.signal, context.signal,
); );
await assertStableAuthorizedFrame(context, initialUrl, origins); await assertStableAuthorizedFrame(context, initialUrl, origins);
+3 -1
View File
@@ -160,7 +160,9 @@ const registerSetupCommands = (
installHopper: z installHopper: z
.boolean() .boolean()
.default(false) .default(false)
.describe("Also approve Hopper installation with --yes"), .describe(
"Approve Hopper installation when missing or repair is needed (requires --yes)",
),
}), }),
alias: { yes: "y", installHopper: "install-hopper" }, alias: { yes: "y", installHopper: "install-hopper" },
run: ({ options, formatExplicit }) => run: ({ options, formatExplicit }) =>
+16 -7
View File
@@ -18,6 +18,11 @@ const investigationOptionsSchema = z.object({
.describe("Approve persistent workspace writes"), .describe("Approve persistent workspace writes"),
workspaceName: z.string().trim().min(1).max(200).default("default"), workspaceName: z.string().trim().min(1).max(200).default("default"),
expectedRevision: z.number().int().min(1).optional(), expectedRevision: z.number().int().min(1).optional(),
replayRunId: z
.string()
.regex(/^run_[a-f0-9]{64}$/u)
.optional()
.describe("Replay one verified complete workspace run without rescanning"),
maxEntries: z.number().int().min(1).max(50_000).default(10_000), maxEntries: z.number().int().min(1).max(50_000).default(10_000),
maxTotalBytes: z maxTotalBytes: z
.number() .number()
@@ -55,6 +60,7 @@ export const registerInvestigationCommands = (
alias: { alias: {
workspaceName: "workspace-name", workspaceName: "workspace-name",
expectedRevision: "expected-revision", expectedRevision: "expected-revision",
replayRunId: "replay-run-id",
maxEntries: "max-entries", maxEntries: "max-entries",
maxTotalBytes: "max-total-bytes", maxTotalBytes: "max-total-bytes",
maxEntryBytes: "max-entry-bytes", maxEntryBytes: "max-entry-bytes",
@@ -99,13 +105,6 @@ const runInvestigation = async (
}, },
); );
if (!workspaceAuthorization.ok) return cliError(workspaceAuthorization.error); if (!workspaceAuthorization.ok) return cliError(workspaceAuthorization.error);
const inputAuthorization = await authorizeRootPermission(authority.value, {
capability: "investigation_input",
roots: [args.leftPath, args.rightPath],
access: "read",
operation: "investigate_versions",
});
if (!inputAuthorization.ok) return cliError(inputAuthorization.error);
const investigated = await runCrossVersionInvestigation( const investigated = await runCrossVersionInvestigation(
{ {
approved: true, approved: true,
@@ -114,6 +113,9 @@ const runInvestigation = async (
...(options.expectedRevision === undefined ...(options.expectedRevision === undefined
? {} ? {}
: { expected_workspace_revision: options.expectedRevision }), : { expected_workspace_revision: options.expectedRevision }),
...(options.replayRunId === undefined
? {}
: { replay_run_id: options.replayRunId }),
left_path: args.leftPath, left_path: args.leftPath,
right_path: args.rightPath, right_path: args.rightPath,
integrity_policy: options.integrityPolicy, integrity_policy: options.integrityPolicy,
@@ -134,6 +136,13 @@ const runInvestigation = async (
{ {
inputRoots: config.value.investigationInputRoots, inputRoots: config.value.investigationInputRoots,
integrityContinueEnabled: config.value.artifactIntegrityContinueEnabled, integrityContinueEnabled: config.value.artifactIntegrityContinueEnabled,
authorizeInputRead: () =>
authorizeRootPermission(authority.value, {
capability: "investigation_input",
roots: [args.leftPath, args.rightPath],
access: "read",
operation: "investigate_versions",
}),
authorizeWorkspaceWrite: workspaceAuthorization.value.authorizeWrite, authorizeWorkspaceWrite: workspaceAuthorization.value.authorizeWrite,
}, },
); );
+40 -2
View File
@@ -1,4 +1,5 @@
import { Cli, z } from "incur"; import { Cli, z } from "incur";
import { createInterface } from "node:readline/promises";
import { loadConfiguredPermissionAuthority } from "./application/PermissionConfiguration.js"; import { loadConfiguredPermissionAuthority } from "./application/PermissionConfiguration.js";
import { import {
@@ -49,9 +50,13 @@ export const registerPolicyCommands = (
network: z.enum(["none", "loopback", "external"]).default("none"), network: z.enum(["none", "loopback", "external"]).default("none"),
mount: z.boolean().default(false), mount: z.boolean().default(false),
write: z.boolean().default(false), write: z.boolean().default(false),
yes: z
.boolean()
.default(false)
.describe("Confirm a destructive policy revocation without prompting"),
}), }),
alias: { environmentNames: "environment-names" }, alias: { environmentNames: "environment-names", yes: "y" },
run: ({ args, options }) => run: ({ args, options, formatExplicit }) =>
logCliCommand(logger, "policy", async () => { logCliCommand(logger, "policy", async () => {
const config = parseConfig(process.env); const config = parseConfig(process.env);
if (!config.ok) return failure(config.error); if (!config.ok) return failure(config.error);
@@ -76,6 +81,22 @@ export const registerPolicyCommands = (
permissionProjectStore === undefined permissionProjectStore === undefined
) )
return { error: "Project policy is not configured" }; return { error: "Project policy is not configured" };
if (!options.yes) {
if (formatExplicit || process.stdin.isTTY !== true)
return {
error: "ConfirmationRequired",
message: "Policy grant revocation requires confirmation",
grant_id: args.value,
remediation:
"Verify the grant ID, then rerun the same command with --yes.",
};
if (!(await confirmRevocation(args.value)))
return {
error: "Cancelled",
message: "Policy grant revocation was cancelled",
grant_id: args.value,
};
}
const revoked = await revokeProjectPermissionGrant( const revoked = await revokeProjectPermissionGrant(
permissionProjectStore, permissionProjectStore,
permissionProjectRoot, permissionProjectRoot,
@@ -100,6 +121,23 @@ export const registerPolicyCommands = (
}); });
}; };
const confirmRevocation = async (grantId: string): Promise<boolean> => {
const prompt = createInterface({
input: process.stdin,
output: process.stdout,
});
try {
const answer = (
await prompt.question(`Revoke policy grant "${grantId}"? [y/N] `)
)
.trim()
.toLowerCase();
return answer === "y" || answer === "yes";
} finally {
prompt.close();
}
};
const explain = async ( const explain = async (
config: AppConfig, config: AppConfig,
capability: PermissionCapability, capability: PermissionCapability,
+4
View File
@@ -59,6 +59,10 @@ export const crossVersionInvestigationInputSchema = z
workspace_path: z.string().min(1).max(4_096), workspace_path: z.string().min(1).max(4_096),
workspace_name: z.string().trim().min(1).max(200).default("default"), workspace_name: z.string().trim().min(1).max(200).default("default"),
expected_workspace_revision: z.number().int().min(1).optional(), expected_workspace_revision: z.number().int().min(1).optional(),
replay_run_id: z
.string()
.regex(/^run_[a-f0-9]{64}$/u)
.optional(),
left_path: z.string().min(1).max(4_096), left_path: z.string().min(1).max(4_096),
right_path: z.string().min(1).max(4_096), right_path: z.string().min(1).max(4_096),
integrity_policy: z.enum(["fail", "record-and-continue"]).default("fail"), integrity_policy: z.enum(["fail", "record-and-continue"]).default("fail"),
+6 -1
View File
@@ -128,6 +128,11 @@ const parseFailureSchema = z.strictObject({
reason: boundedTextSchema, reason: boundedTextSchema,
}); });
/** Stable identity for one distinct reference-source parse failure. */
export const referenceSourceParseFailureKey = (
failure: z.infer<typeof parseFailureSchema>,
): string => JSON.stringify([failure.path, failure.parser, failure.reason]);
const exclusionSchema = z.strictObject({ const exclusionSchema = z.strictObject({
path: relativePathSchema, path: relativePathSchema,
reason: z.enum([ reason: z.enum([
@@ -400,7 +405,7 @@ function checkGraphInvariants(
context, context,
); );
checkSortedUnique( checkSortedUnique(
graph.parse_failures.map(({ path, parser }) => `${path}\u0000${parser}`), graph.parse_failures.map(referenceSourceParseFailureKey),
"parse_failures", "parse_failures",
context, context,
); );
+19 -6
View File
@@ -40,15 +40,25 @@ export interface BridgeLauncher {
): Promise<Result<BridgeLaunch, HopperStartError | HopperCancelledError>>; ): Promise<Result<BridgeLaunch, HopperStartError | HopperCancelledError>>;
} }
export interface HopperApplicationLauncherOptions { interface SharedHopperApplicationLauncherOptions {
readonly launcherPath: string; readonly launcherPath: string;
readonly targetPath: string; readonly targetPath: string;
readonly targetKind: "executable" | "database"; readonly targetKind: "executable" | "database";
readonly loaderArgs: readonly string[]; readonly loaderArgs: readonly string[];
readonly bridgeScriptPath: string; readonly bridgeScriptPath: string;
readonly demoHelperPath?: string;
} }
/** Explicit launcher contract; the Linux adapter verifies its pinned Hopper build before execution. */
export type HopperApplicationLauncherOptions =
| (SharedHopperApplicationLauncherOptions & {
readonly launchMode: "native";
readonly demoHelperPath?: never;
})
| (SharedHopperApplicationLauncherOptions & {
readonly launchMode: "verified_linux_demo";
readonly demoHelperPath: string;
});
/** /**
* Launches Hopper through its documented CLI and injects only REA's owned bridge. * Launches Hopper through its documented CLI and injects only REA's owned bridge.
* *
@@ -186,10 +196,13 @@ const linuxDemoLaunch = (
}; };
}; };
const usesLinuxDemo = (options: HopperApplicationLauncherOptions): boolean => /** Select the version-pinned adapter from the caller's explicit launch mode. */
process.platform === "linux" && export const usesLinuxDemo = (
basename(options.launcherPath) === "Hopper" && options: HopperApplicationLauncherOptions,
options.demoHelperPath !== undefined; ): options is HopperApplicationLauncherOptions & {
readonly launchMode: "verified_linux_demo";
readonly demoHelperPath: string;
} => options.launchMode === "verified_linux_demo";
const spawnLauncher = async ( const spawnLauncher = async (
command: string, command: string,
+8 -3
View File
@@ -114,9 +114,14 @@ export class HopperProvider implements AnalysisProvider {
bridgeScriptPath: fileURLToPath( bridgeScriptPath: fileURLToPath(
new URL("../../bridge/hopper_bridge.py", import.meta.url), new URL("../../bridge/hopper_bridge.py", import.meta.url),
), ),
demoHelperPath: fileURLToPath( ...(process.platform === "linux"
new URL("../../scripts/hopper-demo-x11.py", import.meta.url), ? {
), launchMode: "verified_linux_demo" as const,
demoHelperPath: fileURLToPath(
new URL("../../scripts/hopper-demo-x11.py", import.meta.url),
),
}
: { launchMode: "native" as const }),
}), }),
logger: this.logger.child({ layer: "bridge" }), logger: this.logger.child({ layer: "bridge" }),
}); });
+14 -2
View File
@@ -33,7 +33,7 @@ interface RuntimeDependencies {
readonly serve: typeof serveStdio; readonly serve: typeof serveStdio;
readonly writeStderr: (text: string) => void; readonly writeStderr: (text: string) => void;
readonly setExitCode: (code: number) => void; readonly setExitCode: (code: number) => void;
readonly registerShutdown: (handler: () => void) => void; readonly registerShutdown: (handler: () => void) => () => void;
readonly registerReload?: (handler: () => void) => () => void; readonly registerReload?: (handler: () => void) => () => void;
} }
@@ -49,6 +49,15 @@ const runtimeDependencies = (): RuntimeDependencies => ({
process.once("SIGTERM", handler); process.once("SIGTERM", handler);
process.stdin.once("end", handler); process.stdin.once("end", handler);
process.stdin.once("close", handler); process.stdin.once("close", handler);
let registered = true;
return () => {
if (!registered) return;
registered = false;
process.off("SIGINT", handler);
process.off("SIGTERM", handler);
process.stdin.off("end", handler);
process.stdin.off("close", handler);
};
}, },
registerReload: (handler) => { registerReload: (handler) => {
process.on("SIGHUP", handler); process.on("SIGHUP", handler);
@@ -241,9 +250,11 @@ export const run = async (
})(); })();
}) ?? (() => undefined); }) ?? (() => undefined);
let unregisterShutdown = (): void => undefined;
let shutdownPromise: Promise<void> | undefined; let shutdownPromise: Promise<void> | undefined;
const shutdown = (): Promise<void> => { const shutdown = (): Promise<void> => {
shutdownPromise ??= (async () => { shutdownPromise ??= (async () => {
unregisterShutdown();
unregisterReload(); unregisterReload();
await handle.close(); await handle.close();
await session.close(); await session.close();
@@ -259,7 +270,8 @@ export const run = async (
}); });
}; };
dependencies.registerShutdown(requestShutdown); unregisterShutdown = dependencies.registerShutdown(requestShutdown);
if (shutdownPromise !== undefined) unregisterShutdown();
return 0; return 0;
}; };
+18 -16
View File
@@ -19,7 +19,10 @@ import {
} from "../domain/changedBehavior.js"; } from "../domain/changedBehavior.js";
import { createEvidence, type Evidence } from "../domain/evidence.js"; import { createEvidence, type Evidence } from "../domain/evidence.js";
import type { EvidenceFilePolicy } from "../domain/evidenceBundle.js"; import type { EvidenceFilePolicy } from "../domain/evidenceBundle.js";
import { EvidenceIntegrityError } from "../domain/errors.js"; import {
EvidenceIntegrityError,
type AnalysisError,
} from "../domain/errors.js";
import { jsonValueSchema } from "../domain/jsonValue.js"; import { jsonValueSchema } from "../domain/jsonValue.js";
import { err, ok, type Result } from "../domain/result.js"; import { err, ok, type Result } from "../domain/result.js";
import { import {
@@ -93,14 +96,19 @@ const registerChangedBehavior = (
contractOptions(contract), contractOptions(contract),
async (input, context) => { async (input, context) => {
const parsed = changedBehaviorInputSchema.parse(input); const parsed = changedBehaviorInputSchema.parse(input);
if (parsed.investigation_run !== undefined) { const investigationRun = parsed.investigation_run;
if (investigationRun !== undefined) {
const progress = mcpProgressReporter(context); const progress = mcpProgressReporter(context);
let workspaceAuthorization: DeferredFileWriteAuthorization | undefined; let workspaceAuthorization: DeferredFileWriteAuthorization | undefined;
let authorizeInputRead:
| (() => Promise<Result<null, AnalysisError>>)
| undefined;
if (policies.permissionAuthority !== undefined) { if (policies.permissionAuthority !== undefined) {
const authority = policies.permissionAuthority;
const authorizedWorkspace = await authorizeFileReadWithDeferredWrite( const authorizedWorkspace = await authorizeFileReadWithDeferredWrite(
policies.permissionAuthority, authority,
{ {
path: parsed.investigation_run.workspace_path, path: investigationRun.workspace_path,
readCapability: "investigation_workspace_read", readCapability: "investigation_workspace_read",
writeCapability: "investigation_workspace_write", writeCapability: "investigation_workspace_write",
operation: contract.name, operation: contract.name,
@@ -109,23 +117,16 @@ const registerChangedBehavior = (
if (!authorizedWorkspace.ok) if (!authorizedWorkspace.ok)
return toCallToolResult(authorizedWorkspace, contract); return toCallToolResult(authorizedWorkspace, contract);
workspaceAuthorization = authorizedWorkspace.value; workspaceAuthorization = authorizedWorkspace.value;
const authorizedInput = await authorizeRootPermission( authorizeInputRead = () =>
policies.permissionAuthority, authorizeRootPermission(authority, {
{
capability: "investigation_input", capability: "investigation_input",
roots: [ roots: [investigationRun.left_path, investigationRun.right_path],
parsed.investigation_run.left_path,
parsed.investigation_run.right_path,
],
access: "read", access: "read",
operation: contract.name, operation: contract.name,
}, });
);
if (!authorizedInput.ok)
return toCallToolResult(authorizedInput, contract);
} }
const investigated = await runCrossVersionInvestigation( const investigated = await runCrossVersionInvestigation(
parsed.investigation_run, investigationRun,
policies.evidenceFiles, policies.evidenceFiles,
{ {
...investigationExecution( ...investigationExecution(
@@ -141,6 +142,7 @@ const registerChangedBehavior = (
authorizeWorkspaceWrite: authorizeWorkspaceWrite:
workspaceAuthorization.authorizeWrite, workspaceAuthorization.authorizeWrite,
}), }),
...(authorizeInputRead === undefined ? {} : { authorizeInputRead }),
}, },
); );
if (!investigated.ok) return toCallToolResult(investigated, contract); if (!investigated.ok) return toCallToolResult(investigated, contract);
+85
View File
@@ -0,0 +1,85 @@
import { execFile } from "node:child_process";
import { access, chmod, mkdtemp, rm, writeFile } 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 { describe, expect, it } from "vitest";
import {
type HopperApplicationLauncherOptions,
usesLinuxDemo,
} from "../src/hopper/BridgeLauncher.js";
const execFileAsync = promisify(execFile);
const demoHelperPath = fileURLToPath(
new URL("../scripts/hopper-demo-x11.py", import.meta.url),
);
const options = (launcherPath: string): HopperApplicationLauncherOptions => ({
launcherPath,
targetPath: "/target",
targetKind: "executable",
loaderArgs: [],
bridgeScriptPath: "/rea/hopper_bridge.py",
launchMode: "verified_linux_demo",
demoHelperPath: "/rea/hopper-demo-x11.py",
});
describe("Hopper bridge launcher selection", () => {
it.each([
"/opt/hopper/bin/Hopper",
"/usr/local/bin/hopper",
"/workspace/bin/hopper-wrapper",
])(
"routes launcher spelling %s through the explicitly selected pinned adapter",
(path) => {
expect(usesLinuxDemo(options(path))).toBe(true);
},
);
it("does not infer demo behavior from a native launcher's basename", () => {
expect(
usesLinuxDemo({
launcherPath: "/opt/hopper/bin/Hopper",
targetPath: "/target",
targetKind: "executable",
loaderArgs: [],
bridgeScriptPath: "/rea/hopper_bridge.py",
launchMode: "native",
}),
).toBe(false);
});
it("rejects an unpinned wrapper before executing it", async () => {
const directory = await mkdtemp(join(tmpdir(), "rea-hopper-wrapper-"));
const wrapper = join(directory, "hopper-wrapper");
const marker = join(directory, "executed");
try {
await writeFile(wrapper, `#!/bin/sh\ntouch ${JSON.stringify(marker)}\n`);
await chmod(wrapper, 0o700);
await expect(
execFileAsync(
"python3",
[
demoHelperPath,
"--hopper",
wrapper,
"--socket",
join(directory, "bridge.sock"),
"--",
wrapper,
],
{ timeout: 3_000 },
),
).rejects.toMatchObject({
code: 72,
stderr: expect.stringContaining("unsupported Hopper binary"),
});
await expect(access(marker)).rejects.toMatchObject({ code: "ENOENT" });
} finally {
await rm(directory, { recursive: true, force: true });
}
});
});
+132 -1
View File
@@ -109,6 +109,82 @@ describe("CdpBrowserProvider", () => {
).toBe(true); ).toBe(true);
}); });
it("projects direct-session cancellation onto the requested operation", async () => {
const browser = await startFakeCdpBrowser({
pageScopedVersionWebSocket: true,
omitTargetWebSocket: true,
});
browsers.push(browser);
const controller = new AbortController();
const result = await new CdpBrowserProvider().observeSession(
observeWebSessionInputSchema.parse({
cdp_endpoint: browser.endpoint,
allowed_origins: [browser.allowedOrigin],
target_id: "allowed-page",
approved: true,
observation_ms: 1_000,
}),
{
signal: controller.signal,
progress: {
report(update) {
if (update.completed === 1) controller.abort();
return Promise.resolve();
},
},
},
);
expect(result).toMatchObject({
ok: false,
error: {
_tag: "AnalysisCancelledError",
operation: "observe_web_session",
},
});
const methods = browser.commands.map(({ method }) => method);
expect(methods).not.toContain("Target.attachToTarget");
expect(methods).not.toContain("Target.detachFromTarget");
expect(methods).not.toContain("Target.closeTarget");
expect(methods).not.toContain("Browser.close");
});
it("projects capture-window cancellation onto bundle analysis", async () => {
const browser = await startFakeCdpBrowser({
pageScopedVersionWebSocket: true,
omitTargetWebSocket: true,
});
browsers.push(browser);
const controller = new AbortController();
const result = await new CdpBrowserProvider().analyzeBundle(
analyzeWebBundleInputSchema.parse({
cdp_endpoint: browser.endpoint,
allowed_origins: [browser.allowedOrigin],
target_id: "allowed-page",
approved: true,
source_capture_approved: true,
observation_ms: 1_000,
}),
{
signal: controller.signal,
progress: {
report(update) {
if (update.completed === 2) controller.abort();
return Promise.resolve();
},
},
},
);
expect(result).toMatchObject({
ok: false,
error: {
_tag: "AnalysisCancelledError",
operation: "analyze_web_bundle",
},
});
});
it("rejects empty browser attachment session identifiers", async () => { it("rejects empty browser attachment session identifiers", async () => {
const browser = await startFakeCdpBrowser({ invalidAttachedSession: true }); const browser = await startFakeCdpBrowser({ invalidAttachedSession: true });
browsers.push(browser); browsers.push(browser);
@@ -644,6 +720,58 @@ describe("CdpBrowserProvider", () => {
); );
}); });
it("ends a browser session when its flat target session detaches", async () => {
const browser = await startFakeCdpBrowser({
sessionTimeline: "target_detached",
});
browsers.push(browser);
const result = await new CdpBrowserProvider().observeSession(
observeWebSessionInputSchema.parse({
cdp_endpoint: browser.endpoint,
allowed_origins: [browser.allowedOrigin],
target_id: "allowed-page",
approved: true,
observation_ms: 1_000,
}),
);
if (!result.ok) throw result.error;
expect(result.value.window.end_reason).toBe("target_terminated");
expect(result.value.timeline.at(-1)).toMatchObject({
type: "target_terminated",
detail: "target_terminated",
});
});
it("ends a direct page session when its transport disconnects", async () => {
const browser = await startFakeCdpBrowser({
pageScopedVersionWebSocket: true,
omitTargetWebSocket: true,
closeAfterMethod: "Page.setLifecycleEventsEnabled",
});
browsers.push(browser);
const result = await new CdpBrowserProvider().observeSession(
observeWebSessionInputSchema.parse({
cdp_endpoint: browser.endpoint,
allowed_origins: [browser.allowedOrigin],
target_id: "allowed-page",
approved: true,
observation_ms: 1_000,
}),
);
if (!result.ok) throw result.error;
expect(result.value.window.end_reason).toBe("target_terminated");
expect(result.value.timeline.at(-1)).toMatchObject({
type: "target_terminated",
detail: "target_terminated",
});
const methods = browser.commands.map(({ method }) => method);
expect(methods).not.toContain("Target.attachToTarget");
expect(methods).not.toContain("Target.detachFromTarget");
expect(methods).not.toContain("Target.closeTarget");
});
it("discovers untrusted WebMCP declarations without registering or invoking them", async () => { it("discovers untrusted WebMCP declarations without registering or invoking them", async () => {
const browser = await startFakeCdpBrowser({ webMcpTools: true }); const browser = await startFakeCdpBrowser({ webMcpTools: true });
browsers.push(browser); browsers.push(browser);
@@ -1198,7 +1326,10 @@ describe("CdpBrowserProvider", () => {
const result = await pending; const result = await pending;
expect(result).toMatchObject({ expect(result).toMatchObject({
ok: false, ok: false,
error: { _tag: "AnalysisCancelledError" }, error: {
_tag: "AnalysisCancelledError",
operation: "inspect_web_page",
},
}); });
const methods = browser.commands.map(({ method }) => method); const methods = browser.commands.map(({ method }) => method);
expect(methods).toContain("Target.detachFromTarget"); expect(methods).toContain("Target.detachFromTarget");
+9 -3
View File
@@ -19,7 +19,10 @@ describe("CDP connection", () => {
it("correlates concurrent command responses over a real WebSocket", async () => { it("correlates concurrent command responses over a real WebSocket", async () => {
const browser = await startFakeCdpBrowser(); const browser = await startFakeCdpBrowser();
browsers.push(browser); browsers.push(browser);
const connection = await CdpConnection.connect(browser.browserWebSocketUrl); const connection = await CdpConnection.connect(
browser.browserWebSocketUrl,
"inspect_web_page",
);
try { try {
const [attached, frames] = await Promise.all([ const [attached, frames] = await Promise.all([
connection.send("Target.attachToTarget"), connection.send("Target.attachToTarget"),
@@ -37,13 +40,16 @@ describe("CDP connection", () => {
it("returns a typed timeout for an unresponsive CDP command", async () => { it("returns a typed timeout for an unresponsive CDP command", async () => {
const browser = await startFakeCdpBrowser({ hangOnMethod: "Page.enable" }); const browser = await startFakeCdpBrowser({ hangOnMethod: "Page.enable" });
browsers.push(browser); browsers.push(browser);
const connection = await CdpConnection.connect(browser.browserWebSocketUrl); const connection = await CdpConnection.connect(
browser.browserWebSocketUrl,
"observe_web_session",
);
vi.useFakeTimers(); vi.useFakeTimers();
try { try {
const pending = connection.send("Page.enable"); const pending = connection.send("Page.enable");
const assertion = expect(pending).rejects.toMatchObject({ const assertion = expect(pending).rejects.toMatchObject({
_tag: "AnalysisTimeoutError", _tag: "AnalysisTimeoutError",
operation: "inspect_web_page", operation: "observe_web_session",
timeoutMs: 5_000, timeoutMs: 5_000,
}); });
await vi.advanceTimersByTimeAsync(5_000); await vi.advanceTimersByTimeAsync(5_000);
+22
View File
@@ -142,6 +142,28 @@ describe("CdpElectronProvider", () => {
).toBe(true); ).toBe(true);
}); });
it("maps cancelled Electron discovery to the Electron operation", async () => {
const root = await electronFixture();
const controller = new AbortController();
controller.abort();
const result = await new CdpElectronProvider().listTargets(
listElectronTargetsInputSchema.parse({
cdp_endpoint: "http://127.0.0.1:9222",
allowed_file_roots: [root],
approved: true,
}),
{ signal: controller.signal },
);
expect(result).toMatchObject({
ok: false,
error: {
_tag: "AnalysisCancelledError",
operation: "list_electron_targets",
},
});
});
it("captures Electron script source only after separate approval", async () => { it("captures Electron script source only after separate approval", async () => {
const root = await electronFixture(); const root = await electronFixture();
const browser = await startFakeCdpBrowser({ const browser = await startFakeCdpBrowser({
+95
View File
@@ -0,0 +1,95 @@
import { execFile } from "node:child_process";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import { afterEach, describe, expect, it } from "vitest";
import {
readProjectPermissionStore,
writeProjectPermissionStore,
} from "../src/application/ProjectPermissionStore.js";
const execFileAsync = promisify(execFile);
const CLI_INTEGRATION_TIMEOUT_MS = 60_000;
const roots: string[] = [];
afterEach(async () => {
await Promise.all(
roots.splice(0).map((root) => rm(root, { recursive: true, force: true })),
);
});
describe("policy CLI revocation", () => {
it(
"requires confirmation in structured mode and accepts explicit approval",
async () => {
const project = await mkdtemp(join(tmpdir(), "rea-policy-cli-"));
roots.push(project);
const store = join(project, ".rea", "permissions.json");
const grant = {
grant_id: "project:evidence-read",
capability: "evidence_read" as const,
roots: [project],
executables: [],
environment_names: [],
network: "none" as const,
mount: false,
lifetime: "project" as const,
operation_identity: null,
expires_at: null,
};
expect(
(await writeProjectPermissionStore(store, project, [grant])).ok,
).toBe(true);
const environment = {
...process.env,
REA_PERMISSION_PROJECT_ROOT: project,
REA_PERMISSION_PROJECT_STORE: store,
};
const unapproved = await execFileAsync(
process.execPath,
["scripts/rea.mjs", "--json", "policy", "revoke", grant.grant_id],
{ cwd: process.cwd(), env: environment },
).catch((cause: unknown) => cause);
expect(unapproved).toMatchObject({ code: 1, stderr: "" });
expect(
JSON.parse((unapproved as { readonly stdout: string }).stdout),
).toEqual({
error: "ConfirmationRequired",
message: "Policy grant revocation requires confirmation",
grant_id: grant.grant_id,
remediation:
"Verify the grant ID, then rerun the same command with --yes.",
});
expect(await readProjectPermissionStore(store, project)).toMatchObject({
ok: true,
value: { grants: [grant] },
});
const approved = await execFileAsync(
process.execPath,
[
"scripts/rea.mjs",
"--json",
"policy",
"revoke",
grant.grant_id,
"--yes",
],
{ cwd: process.cwd(), env: environment },
);
expect(JSON.parse(approved.stdout)).toEqual({
revoked: grant.grant_id,
reload: "Send SIGHUP to the REA MCP process",
});
expect(await readProjectPermissionStore(store, project)).toMatchObject({
ok: true,
value: { grants: [] },
});
},
CLI_INTEGRATION_TIMEOUT_MS,
);
});
+12 -1
View File
@@ -51,7 +51,11 @@ interface FakeOptions {
readonly binaryWebSocketEvent?: boolean; readonly binaryWebSocketEvent?: boolean;
readonly invalidBinaryWebSocketEvent?: boolean; readonly invalidBinaryWebSocketEvent?: boolean;
readonly sourceMapBody?: string; readonly sourceMapBody?: string;
readonly sessionTimeline?: "same_origin" | "outside_policy"; readonly sessionTimeline?:
| "same_origin"
| "outside_policy"
| "target_detached";
readonly closeAfterMethod?: string;
readonly sensitiveShapes?: boolean; readonly sensitiveShapes?: boolean;
readonly invalidResponseBodyBase64?: boolean; readonly invalidResponseBodyBase64?: boolean;
readonly webMcpTools?: boolean; readonly webMcpTools?: boolean;
@@ -183,6 +187,8 @@ export const startFakeCdpBrowser = async (
if (options.malformedEventShapeOnMethod === command.method) if (options.malformedEventShapeOnMethod === command.method)
socket.send(JSON.stringify({ method: 42 })); socket.send(JSON.stringify({ method: 42 }));
emitEvents(socket, command, port, options); emitEvents(socket, command, port, options);
if (options.closeAfterMethod === command.method)
setImmediate(() => socket.close());
}); });
}); });
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
@@ -963,6 +969,11 @@ const emitEvents = (
name: "networkIdle", name: "networkIdle",
timestamp: 15, timestamp: 15,
}); });
if (options.sessionTimeline === "target_detached")
event(socket, "Target.detachedFromTarget", undefined, {
sessionId: command.sessionId,
targetId: "allowed-page",
});
} }
}; };
+8 -2
View File
@@ -1,6 +1,12 @@
import { spawn } from "node:child_process"; import { spawn } from "node:child_process";
const mode = process.argv[2]; const mode = process.argv[2];
const treeRunMarker = process.argv[3];
const treeRunArguments = (childMode) => [
process.argv[1],
childMode,
...(treeRunMarker === undefined ? [] : [treeRunMarker]),
];
if (mode === "interactive") { if (mode === "interactive") {
process.stdin.setRawMode?.(true); process.stdin.setRawMode?.(true);
@@ -32,14 +38,14 @@ if (mode === "interactive") {
process.exit(0); process.exit(0);
}, 25); }, 25);
} else if (mode === "tree-child") { } else if (mode === "tree-child") {
spawn(process.execPath, [process.argv[1], "tree-grandchild"], { spawn(process.execPath, treeRunArguments("tree-grandchild"), {
stdio: "ignore", stdio: "ignore",
}); });
setInterval(() => undefined, 1_000); setInterval(() => undefined, 1_000);
} else if (mode === "tree-grandchild") { } else if (mode === "tree-grandchild") {
setInterval(() => undefined, 1_000); setInterval(() => undefined, 1_000);
} else if (mode === "tree") { } else if (mode === "tree") {
spawn(process.execPath, [process.argv[1], "tree-child"], { stdio: "ignore" }); spawn(process.execPath, treeRunArguments("tree-child"), { stdio: "ignore" });
process.stdout.write("tree-ready\n"); process.stdout.write("tree-ready\n");
setInterval(() => undefined, 1_000); setInterval(() => undefined, 1_000);
} else if (mode === "crash") { } else if (mode === "crash") {
+80
View File
@@ -15,6 +15,7 @@ import {
PROCESS_COMPARISON_EVIDENCE, PROCESS_COMPARISON_EVIDENCE,
} from "../src/contracts/investigationExamples.js"; } from "../src/contracts/investigationExamples.js";
import { createEvidence, parseEvidence } from "../src/domain/evidence.js"; import { createEvidence, parseEvidence } from "../src/domain/evidence.js";
import { changedBehaviorResultSchema } from "../src/domain/changedBehavior.js";
import { jsonObjectSchema, jsonValueSchema } from "../src/domain/jsonValue.js"; import { jsonObjectSchema, jsonValueSchema } from "../src/domain/jsonValue.js";
import { createServer } from "../src/server/createServer.js"; import { createServer } from "../src/server/createServer.js";
import { observed } from "./fixtures/analysisExecution.js"; import { observed } from "./fixtures/analysisExecution.js";
@@ -214,6 +215,85 @@ describe("investigation MCP workflows", () => {
} }
}); });
it("replays a selected complete run after input access is removed", async () => {
const directory = await mkdtemp(
join(tmpdir(), "rea-investigation-replay-"),
);
const left = join(directory, "left");
const right = join(directory, "right");
const workspace = join(directory, "workspace.json");
await Promise.all([mkdir(left), mkdir(right)]);
await Promise.all([
writeFile(join(left, "feature.txt"), "old\n"),
writeFile(join(right, "feature.txt"), "new\n"),
]);
const filePolicy = evidencePolicy(directory);
const investigationRun = {
approved: true,
workspace_path: workspace,
workspace_name: "selected-replay",
left_path: left,
right_path: right,
options: { page_size: 500, change_limit: 100 },
};
const writer = await investigationAuthority(directory, true);
const initial = await connected(filePolicy, [directory], writer);
let runId: string | undefined;
try {
const created = await initial.client.callTool({
name: "find_changed_behavior",
arguments: { investigation_run: investigationRun },
});
expect(created.isError, JSON.stringify(created)).not.toBe(true);
const evidence = parseEvidence(
z.object({ result: z.unknown() }).parse(created.structuredContent)
.result,
);
const result = changedBehaviorResultSchema.parse(
evidence.normalized_result,
);
if (result.investigation_run === undefined)
throw new Error("missing investigation run summary");
runId = result.investigation_run.run_id;
} finally {
await close(initial.session, initial.server, initial.client);
}
if (runId === undefined) throw new Error("missing completed run ID");
await Promise.all([
rm(left, { recursive: true, force: true }),
rm(right, { recursive: true, force: true }),
]);
const workspaceOnly = await permissionAuthorityForRoot(
directory,
[
"investigation_workspace_read",
"investigation_workspace_write",
"investigation_input",
],
["investigation_workspace_read"],
);
const replay = await connected(filePolicy, [], workspaceOnly);
try {
const cached = await replay.client.callTool({
name: "find_changed_behavior",
arguments: {
investigation_run: {
...investigationRun,
replay_run_id: runId,
},
},
});
expect(cached.isError, JSON.stringify(cached)).not.toBe(true);
expect(
await readInvestigationWorkspace(workspace, filePolicy),
).toMatchObject({ ok: true, value: { revision: 3 } });
} finally {
await close(replay.session, replay.server, replay.client);
await rm(directory, { recursive: true, force: true });
}
});
it("refuses automatic artifact reads outside operator-approved roots", async () => { it("refuses automatic artifact reads outside operator-approved roots", async () => {
const directory = await mkdtemp(join(tmpdir(), "rea-investigation-mcp-")); const directory = await mkdtemp(join(tmpdir(), "rea-investigation-mcp-"));
const approvedInputs = join(directory, "approved-inputs"); const approvedInputs = join(directory, "approved-inputs");
+295 -2
View File
@@ -12,7 +12,7 @@ import { createHash } from "node:crypto";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { createPackageWithOptions } from "@electron/asar"; import { createPackageWithOptions } from "@electron/asar";
import canonicalize from "canonicalize"; import canonicalize from "canonicalize";
@@ -21,16 +21,25 @@ import {
readInvestigationWorkspace, readInvestigationWorkspace,
writeInvestigationWorkspace, writeInvestigationWorkspace,
} from "../src/application/InvestigationWorkspaceStore.js"; } from "../src/application/InvestigationWorkspaceStore.js";
import { changedBehaviorResultSchema } from "../src/domain/changedBehavior.js"; import { compareArtifacts } from "../src/domain/artifactComparison.js";
import { artifactInventoryResultSchema } from "../src/domain/artifactGraph.js";
import {
changedBehaviorResultSchema,
findChangedBehavior,
} from "../src/domain/changedBehavior.js";
import { createEvidence, type Evidence } from "../src/domain/evidence.js";
import { createEvidenceBundle } from "../src/domain/evidenceBundle.js"; import { createEvidenceBundle } from "../src/domain/evidenceBundle.js";
import type { EvidenceFilePolicy } from "../src/domain/evidenceBundle.js"; import type { EvidenceFilePolicy } from "../src/domain/evidenceBundle.js";
import { import {
createInvestigationWorkspace, createInvestigationWorkspace,
crossVersionInvestigationInputSchema, crossVersionInvestigationInputSchema,
investigationRunSchema, investigationRunSchema,
investigationRunSummarySchema,
parseInvestigationWorkspace, parseInvestigationWorkspace,
serializeInvestigationWorkspace, serializeInvestigationWorkspace,
} from "../src/domain/investigationWorkspace.js"; } from "../src/domain/investigationWorkspace.js";
import type { JsonValue } from "../src/domain/jsonValue.js";
import { ok } from "../src/domain/result.js";
const digestCanonical = (value: unknown): string => { const digestCanonical = (value: unknown): string => {
const encoded = canonicalize(value); const encoded = canonicalize(value);
@@ -54,6 +63,41 @@ const policy = (root: string): EvidenceFilePolicy => ({
maxNodes: 1_000_000, maxNodes: 1_000_000,
}); });
const recreateEvidence = (
evidence: Evidence,
overrides: {
readonly parameters?: Readonly<Record<string, JsonValue>>;
readonly result?: JsonValue;
readonly evidenceLinks?: readonly string[];
},
): Evidence =>
createEvidence(
evidence.subject === null
? undefined
: {
path: evidence.subject.local_path,
sha256: evidence.subject.digest.sha256,
format: evidence.subject.format,
...(evidence.subject.architecture === null
? {}
: { architecture: evidence.subject.architecture }),
},
evidence.provider,
{
predicateType: evidence.predicate_type,
operation: evidence.operation,
parameters: overrides.parameters ?? evidence.parameters,
result: overrides.result ?? evidence.normalized_result,
rawResult: evidence.raw_result,
confidence: evidence.confidence,
authority: evidence.authority,
environment: evidence.environment,
limitations: evidence.limitations,
locations: evidence.locations,
evidenceLinks: overrides.evidenceLinks ?? evidence.evidence_links,
},
);
const fixture = async () => { const fixture = async () => {
directory = await mkdtemp(join(tmpdir(), "rea-workspace-")); directory = await mkdtemp(join(tmpdir(), "rea-workspace-"));
const left = join(directory, "left"); const left = join(directory, "left");
@@ -239,6 +283,235 @@ describe("persistent cross-version investigation workspace", () => {
}); });
}); });
it("replays an explicitly selected complete run without input access", async () => {
const { left, right, input } = await fixture();
if (directory === undefined) throw new Error("missing fixture root");
const completed = await runCrossVersionInvestigation(
input,
policy(directory),
{ inputRoots: [directory] },
);
if (!completed.ok) throw completed.error;
const run = completed.value.workspace.runs[0];
if (run === undefined) throw new Error("missing completed run");
await Promise.all([
rm(left, { recursive: true, force: true }),
rm(right, { recursive: true, force: true }),
]);
const authorizeInputRead = vi.fn(() => Promise.resolve(ok(null)));
await expect(
runCrossVersionInvestigation(
{ ...input, replay_run_id: run.run_id },
policy(directory),
{ inputRoots: [], authorizeInputRead },
),
).resolves.toMatchObject({
ok: true,
value: {
reused: true,
evidence: { evidence_id: completed.value.evidence.evidence_id },
},
});
expect(authorizeInputRead).not.toHaveBeenCalled();
for (const replayInput of [
{ ...input, replay_run_id: `run_${"f".repeat(64)}` },
{ ...input, right_path: `${right}-other`, replay_run_id: run.run_id },
])
await expect(
runCrossVersionInvestigation(replayInput, policy(directory), {
inputRoots: [],
authorizeInputRead,
}),
).resolves.toMatchObject({
ok: false,
error: {
_tag: "InvestigationWorkspaceError",
reason: "revision-conflict",
},
});
expect(authorizeInputRead).not.toHaveBeenCalled();
const byId = new Map(
completed.value.workspace.bundle.records.map((record) => [
record.evidence_id,
record,
]),
);
const evidenceFor = (ids: readonly string[]): Evidence[] =>
ids.map((id) => {
const evidence = byId.get(id);
if (evidence === undefined)
throw new Error("missing inventory fixture Evidence");
return evidence;
});
const leftPages = evidenceFor(run.left_inventory_evidence_ids);
const rightPages = evidenceFor(run.right_inventory_evidence_ids);
const comparison = byId.get(run.comparison_evidence_id ?? "");
const result = byId.get(run.result_evidence_id ?? "");
if (comparison === undefined || result === undefined)
throw new Error("missing completed fixture Evidence");
const forgeWorkspace = (forgedLeftPages: readonly Evidence[]) => {
const comparisonResult = compareArtifacts(
forgedLeftPages,
rightPages,
0,
run.options.change_limit,
);
const forgedComparison = recreateEvidence(comparison, {
parameters: {
left_evidence_ids: forgedLeftPages.map(({ evidence_id: id }) => id),
right_evidence_ids: rightPages.map(({ evidence_id: id }) => id),
offset: 0,
limit: run.options.change_limit,
},
result: comparisonResult,
evidenceLinks: [
...forgedLeftPages.map(({ evidence_id: id }) => id),
...rightPages.map(({ evidence_id: id }) => id),
],
});
const resultLimit = Math.min(run.options.change_limit, 100);
const changed = findChangedBehavior([forgedComparison], 0, resultLimit);
const summary = investigationRunSummarySchema.parse({
schema_version: 1,
workspace_id: completed.value.workspace.workspace_id,
run_id: run.run_id,
left_manifest_id: run.left.manifest_id,
right_manifest_id: run.right.manifest_id,
inventory_evidence_count: forgedLeftPages.length + rightPages.length,
comparison_evidence_id: forgedComparison.evidence_id,
limitations: run.limitations,
});
const forgedResult = recreateEvidence(result, {
parameters: {
workspace_id: completed.value.workspace.workspace_id,
run_id: run.run_id,
comparison_evidence_ids: [forgedComparison.evidence_id],
offset: 0,
limit: resultLimit,
},
result: { ...changed, investigation_run: summary },
evidenceLinks: changed.evidence_links,
});
const forgedRun = investigationRunSchema.parse({
...run,
left_inventory_evidence_ids: forgedLeftPages.map(
({ evidence_id: id }) => id,
),
comparison_evidence_id: forgedComparison.evidence_id,
result_evidence_id: forgedResult.evidence_id,
});
const records = new Map(
[
...completed.value.workspace.bundle.records,
...forgedLeftPages,
forgedComparison,
forgedResult,
].map((record) => [record.evidence_id, record]),
);
return createInvestigationWorkspace(
input.workspace_name,
createEvidenceBundle([...records.values()]),
[forgedRun],
);
};
const firstPage = leftPages[0]!;
const firstPageResult = artifactInventoryResultSchema.parse(
firstPage.normalized_result,
);
const extraOffset = leftPages.length * input.options.page_size;
const extraPage = recreateEvidence(firstPage, {
parameters: {
...firstPage.parameters,
node_offset: extraOffset,
occurrence_offset: extraOffset,
edge_offset: extraOffset,
},
result: {
...firstPageResult,
nodes: {
...firstPageResult.nodes,
items: [],
offset: extraOffset,
next_offset: null,
},
occurrences: {
...firstPageResult.occurrences,
items: [],
offset: extraOffset,
next_offset: null,
},
edges: {
...firstPageResult.edges,
items: [],
offset: extraOffset,
next_offset: null,
},
},
});
const forgedVariants = [
{
name: "controls",
pages: [
recreateEvidence(firstPage, {
parameters: {
...firstPage.parameters,
max_entries: input.options.max_entries - 1,
},
}),
...leftPages.slice(1),
],
},
{
name: "page-total",
pages: [
recreateEvidence(firstPage, {
result: {
...firstPageResult,
nodes: {
...firstPageResult.nodes,
total: firstPageResult.nodes.total + 1,
},
},
}),
...leftPages.slice(1),
],
},
{ name: "page-count", pages: [...leftPages, extraPage] },
] as const;
for (const variant of forgedVariants) {
const forgedPath = join(directory, `forged-${variant.name}.json`);
expect(
await writeInvestigationWorkspace(
forgeWorkspace(variant.pages),
forgedPath,
null,
policy(directory),
),
).toMatchObject({ ok: true });
await expect(
runCrossVersionInvestigation(
{
...input,
workspace_path: forgedPath,
replay_run_id: run.run_id,
},
policy(directory),
{ inputRoots: [], authorizeInputRead },
),
).resolves.toMatchObject({
ok: false,
error: {
_tag: "InvestigationWorkspaceError",
reason: "revision-conflict",
},
});
}
expect(authorizeInputRead).not.toHaveBeenCalled();
});
it("enforces shared JSON depth limits while reading workspaces", async () => { it("enforces shared JSON depth limits while reading workspaces", async () => {
const { path, input } = await fixture(); const { path, input } = await fixture();
if (directory === undefined) throw new Error("missing fixture root"); if (directory === undefined) throw new Error("missing fixture root");
@@ -300,6 +573,26 @@ describe("persistent cross-version investigation workspace", () => {
), ),
).toMatchObject({ ok: true }); ).toMatchObject({ ok: true });
const authorizeInputRead = vi.fn(() => Promise.resolve(ok(null)));
await expect(
runCrossVersionInvestigation(
{
...input,
workspace_path: resumePath,
replay_run_id: partialRun.run_id,
},
policy(directory),
{ inputRoots: [], authorizeInputRead },
),
).resolves.toMatchObject({
ok: false,
error: {
_tag: "InvestigationWorkspaceError",
reason: "revision-conflict",
},
});
expect(authorizeInputRead).not.toHaveBeenCalled();
const resumed = await runCrossVersionInvestigation( const resumed = await runCrossVersionInvestigation(
{ ...input, workspace_path: resumePath }, { ...input, workspace_path: resumePath },
policy(directory), policy(directory),
+9 -1
View File
@@ -18,7 +18,13 @@ const dependencies = (
serve, serve,
writeStderr: (text) => output.push(text), writeStderr: (text) => output.push(text),
setExitCode: (code) => exitCodes.push(code), setExitCode: (code) => exitCodes.push(code),
registerShutdown: (handler) => shutdown.push(handler), registerShutdown: (handler) => {
shutdown.push(handler);
return () => {
const index = shutdown.indexOf(handler);
if (index >= 0) shutdown.splice(index, 1);
};
},
}); });
describe("MCP runtime errors", () => { describe("MCP runtime errors", () => {
@@ -52,6 +58,7 @@ describe("MCP runtime errors", () => {
shutdown[0]?.(); shutdown[0]?.();
await nextTurn(); await nextTurn();
expect(closeCalls).toBe(1); expect(closeCalls).toBe(1);
expect(shutdown).toEqual([]);
}); });
it("reports transport startup failure without its cause", async () => { it("reports transport startup failure without its cause", async () => {
@@ -87,6 +94,7 @@ describe("MCP runtime errors", () => {
); );
shutdown[0]?.(); shutdown[0]?.();
await nextTurn(); await nextTurn();
expect(shutdown).toEqual([]);
expect(exitCodes).toEqual([1]); expect(exitCodes).toEqual([1]);
expect(output).toEqual([ expect(output).toEqual([
"REA could not close cleanly. End the REA process before starting it again.\n", "REA could not close cleanly. End the REA process before starting it again.\n",
+183 -125
View File
@@ -1,3 +1,4 @@
import { randomUUID } from "node:crypto";
import { mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; import { mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
@@ -19,7 +20,10 @@ import {
} from "../src/application/ProcessCaptureLifecycle.js"; } from "../src/application/ProcessCaptureLifecycle.js";
import { ProcessCheckpoints } from "../src/application/ProcessCheckpoints.js"; import { ProcessCheckpoints } from "../src/application/ProcessCheckpoints.js";
import { normalizeProcessSamples } from "../src/application/ProcessNormalization.js"; import { normalizeProcessSamples } from "../src/application/ProcessNormalization.js";
import { readLinuxChildren } from "../src/application/ProcessSampling.js"; import {
isInitializedPtyRoot,
readLinuxChildren,
} from "../src/application/ProcessSampling.js";
import { TerminalRenderer } from "../src/application/TerminalRenderer.js"; import { TerminalRenderer } from "../src/application/TerminalRenderer.js";
import { import {
authorizeProcessScenario, authorizeProcessScenario,
@@ -37,6 +41,7 @@ const processFixture = fileURLToPath(
new URL("./fixtures/processFidelity.mjs", import.meta.url), new URL("./fixtures/processFidelity.mjs", import.meta.url),
); );
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
const PROCESS_CAPTURE_INTEGRATION_TIMEOUT_MS = 20_000;
const emptyCapture = (): ProcessCapture => { const emptyCapture = (): ProcessCapture => {
const normalization = { const normalization = {
@@ -211,6 +216,43 @@ describe("process capture domain", () => {
).toEqual([201, 202, 203]); ).toEqual([201, 202, 203]);
}); });
it("admits PTY samples only after stable session and token setup", () => {
const initialized = {
pid: 100,
parent_pid: 10,
process_group_id: 100,
session_id: 100,
startTime: "200",
};
expect(
isInitializedPtyRoot({
rootPid: 100,
expectedRunId: "run-token",
before: { ...initialized, process_group_id: 10, session_id: 10 },
observedRunId: undefined,
after: initialized,
}),
).toBe(false);
expect(
isInitializedPtyRoot({
rootPid: 100,
expectedRunId: "run-token",
before: initialized,
observedRunId: "run-token",
after: { ...initialized, startTime: "201" },
}),
).toBe(false);
expect(
isInitializedPtyRoot({
rootPid: 100,
expectedRunId: "run-token",
before: initialized,
observedRunId: "run-token",
after: initialized,
}),
).toBe(true);
});
it("keeps interaction and shim residual uncertainty in separate scopes", () => { it("keeps interaction and shim residual uncertainty in separate scopes", () => {
const baseCapture = emptyCapture(); const baseCapture = emptyCapture();
const interaction = parseProcessCapture({ const interaction = parseProcessCapture({
@@ -839,95 +881,103 @@ describe("process capture adapter", () => {
} }
}); });
it("renders terminal state, records shim invocations, and captures literal checkpoints", async () => { it(
const root = await mkdtemp(join(tmpdir(), "rea-v3-test-")); "renders terminal state, records shim invocations, and captures literal checkpoints",
const script = join(root, "scenario.mjs"); async () => {
await writeFile( const root = await mkdtemp(join(tmpdir(), "rea-v3-test-"));
script, const script = join(root, "scenario.mjs");
[ await writeFile(
'import { spawnSync } from "node:child_process";', script,
'const result = spawnSync("codex", ["--version"], { encoding: "utf8" });', [
'const node = spawnSync("node", ["--version"], { encoding: "utf8" });', 'import { spawnSync } from "node:child_process";',
"process.stdout.write(`probe:${result.stdout}`);", 'const result = spawnSync("codex", ["--version"], { encoding: "utf8" });',
"process.stdout.write(`runtime:${node.stdout}`);", 'const node = spawnSync("node", ["--version"], { encoding: "utf8" });',
].join("\n"), "process.stdout.write(`probe:${result.stdout}`);",
); "process.stdout.write(`runtime:${node.stdout}`);",
try { ].join("\n"),
const capability = await probeProcessCaptureCapability();
if (!capability.available) return;
const result = await captureProcessScenario(
parseProcessScenario({
approved: true,
executable: process.execPath,
arguments: [script],
working_directory: root,
filesystem_roots: [root],
checkpoints: [
{
name: "probe_seen",
trigger: { type: "terminal_literal", value: "probe:codex 1.2.3" },
},
],
command_shims: [
{
name: "codex",
routes: [
{
arguments: ["--version"],
outputs: [
{ at_ms: 0, stream: "stdout", data: "codex 1.2.3\n" },
],
termination: { type: "exit", code: 0 },
},
],
},
{
name: "node",
routes: [
{
arguments: ["--version"],
outputs: [
{ at_ms: 0, stream: "stdout", data: "node 9.8.7\n" },
],
termination: { type: "exit", code: 0 },
},
],
},
],
}),
{
enabled: true,
executableRoots: [dirname(process.execPath)],
workingRoots: [root],
allowedEnvironment: [],
allowExternalNetwork: true,
},
); );
expect(result.ok).toBe(true); try {
if (!result.ok) throw result.error; const capability = await probeProcessCaptureCapability();
expect(result.value.schema_version).toBe(4); if (!capability.available) return;
expect(result.value.rendered_frames.at(-1)?.lines.join("\n")).toContain( const result = await captureProcessScenario(
"probe:codex 1.2.3", parseProcessScenario({
); approved: true,
expect(result.value.shim_events).toEqual([ executable: process.execPath,
expect.objectContaining({ arguments: [script],
command: "codex", working_directory: root,
arguments: ["--version"], filesystem_roots: [root],
outcome: "matched", checkpoints: [
}), {
expect.objectContaining({ name: "probe_seen",
command: "node", trigger: {
arguments: ["--version"], type: "terminal_literal",
outcome: "matched", value: "probe:codex 1.2.3",
}), },
]); },
expect( ],
result.value.filesystem_checkpoints.map(({ name }) => name), command_shims: [
).toEqual(["before", "probe_seen", "after_settlement"]); {
} finally { name: "codex",
await rm(root, { recursive: true, force: true }); routes: [
} {
}); arguments: ["--version"],
outputs: [
{ at_ms: 0, stream: "stdout", data: "codex 1.2.3\n" },
],
termination: { type: "exit", code: 0 },
},
],
},
{
name: "node",
routes: [
{
arguments: ["--version"],
outputs: [
{ at_ms: 0, stream: "stdout", data: "node 9.8.7\n" },
],
termination: { type: "exit", code: 0 },
},
],
},
],
timeout_ms: 5_000,
idle_timeout_ms: 5_000,
}),
{
enabled: true,
executableRoots: [dirname(process.execPath)],
workingRoots: [root],
allowedEnvironment: [],
allowExternalNetwork: true,
},
);
if (!result.ok) throw result.error;
expect(result.value.schema_version).toBe(4);
expect(result.value.rendered_frames.at(-1)?.lines.join("\n")).toContain(
"probe:codex 1.2.3",
);
expect(result.value.shim_events).toEqual([
expect.objectContaining({
command: "codex",
arguments: ["--version"],
outcome: "matched",
}),
expect.objectContaining({
command: "node",
arguments: ["--version"],
outcome: "matched",
}),
]);
expect(
result.value.filesystem_checkpoints.map(({ name }) => name),
).toEqual(["before", "probe_seen", "after_settlement"]);
} finally {
await rm(root, { recursive: true, force: true });
}
},
PROCESS_CAPTURE_INTEGRATION_TIMEOUT_MS,
);
it("does not follow or disclose symlink targets outside declared roots", async () => { it("does not follow or disclose symlink targets outside declared roots", async () => {
const root = await mkdtemp(join(tmpdir(), "rea-symlink-test-")); const root = await mkdtemp(join(tmpdir(), "rea-symlink-test-"));
@@ -1108,40 +1158,48 @@ describe("process capture adapter", () => {
]); ]);
}); });
it("samples and cleans a source-owned child and grandchild process tree", async () => { it(
const capability = await probeProcessCaptureCapability(); "samples and cleans a source-owned child and grandchild process tree",
if (!capability.available) return; async () => {
const result = await captureProcessScenario( const capability = await probeProcessCaptureCapability();
parseProcessScenario({ if (!capability.available) return;
approved: true, const runMarker = `rea-tree-${randomUUID()}`;
executable: process.execPath, const result = await captureProcessScenario(
arguments: [processFixture, "tree"], parseProcessScenario({
working_directory: dirname(processFixture), approved: true,
timeout_ms: 2_000, executable: process.execPath,
idle_timeout_ms: 2_000, arguments: [processFixture, "tree", runMarker],
}), working_directory: dirname(processFixture),
{ timeout_ms: 5_000,
enabled: true, idle_timeout_ms: 5_000,
executableRoots: [dirname(process.execPath)], }),
workingRoots: [dirname(processFixture)], {
allowedEnvironment: [], enabled: true,
allowExternalNetwork: true, executableRoots: [dirname(process.execPath)],
}, workingRoots: [dirname(processFixture)],
); allowedEnvironment: [],
expect(result.ok).toBe(true); allowExternalNetwork: true,
if (!result.ok) throw result.error; },
const commands = result.value.process_samples.map(({ command }) => command); );
expect(commands.some((command) => command.includes("tree-child"))).toBe( if (!result.ok) throw result.error;
true, const commands = result.value.process_samples.map(
); ({ command }) => command,
expect( );
commands.some((command) => command.includes("tree-grandchild")), expect(commands.some((command) => command.includes("tree-child"))).toBe(
).toBe(true); true,
expect(JSON.stringify(result.value.process_samples)).not.toContain( );
dirname(processFixture), expect(commands.some((command) => command.includes("forks.js"))).toBe(
); false,
const { stdout } = await execFileAsync("ps", ["-axo", "command="]); );
expect(stdout).not.toContain(`${processFixture} tree-child`); expect(
expect(stdout).not.toContain(`${processFixture} tree-grandchild`); commands.some((command) => command.includes("tree-grandchild")),
}); ).toBe(true);
expect(JSON.stringify(result.value.process_samples)).not.toContain(
dirname(processFixture),
);
const { stdout } = await execFileAsync("ps", ["-axo", "command="]);
expect(stdout).not.toContain(runMarker);
},
PROCESS_CAPTURE_INTEGRATION_TIMEOUT_MS,
);
}); });
+100
View File
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { import {
cleanupOwnedProcessGroup, cleanupOwnedProcessGroup,
observeOwnedProcessGroup,
parseProcessEnvironment, parseProcessEnvironment,
type ProcessOwnershipHost, type ProcessOwnershipHost,
} from "../src/application/ProcessOwnership.js"; } from "../src/application/ProcessOwnership.js";
@@ -26,6 +27,7 @@ const host = (
pid: Number(pid), pid: Number(pid),
parentPid: Number(pid) === 100 ? 1 : 100, parentPid: Number(pid) === 100 ? 1 : 100,
processGroupId: 100, processGroupId: 100,
state: "S",
command: "fixture", command: "fixture",
})), })),
), ),
@@ -63,10 +65,108 @@ describe("owned process-group cleanup", () => {
expect(await cleanupOwnedProcessGroup(ownership, adapter)).toEqual({ expect(await cleanupOwnedProcessGroup(ownership, adapter)).toEqual({
cleaned: false, cleaned: false,
reason: "process group contains an unowned or PID-reused process", reason: "process group contains an unowned or PID-reused process",
failures: [{ pid: 101, reason: "run-token-mismatch" }],
}); });
expect(signalGroup).not.toHaveBeenCalled(); expect(signalGroup).not.toHaveBeenCalled();
}); });
it("checks every member and aggregates ownership read failures", async () => {
const environment = vi.fn((pid: number) => {
if (pid === 100)
return Promise.reject(new Error("transient procfs read"));
return Promise.resolve(
pid === 101
? { REA_PROCESS_RUN_ID: "different-run" }
: { REA_PROCESS_RUN_ID: "run-token" },
);
});
const signalGroup = vi.fn();
const adapter: ProcessOwnershipHost = {
listMembers: () =>
Promise.resolve(
[100, 101, 102].map((pid) => ({
pid,
parentPid: pid === 100 ? 1 : 100,
processGroupId: 100,
state: "S",
command: "fixture",
})),
),
environment,
signalGroup,
};
expect(await cleanupOwnedProcessGroup(ownership, adapter)).toEqual({
cleaned: false,
reason: "process group contains an unowned or PID-reused process",
failures: [
{ pid: 100, reason: "environment-unreadable" },
{ pid: 101, reason: "run-token-mismatch" },
],
});
expect(environment.mock.calls.map(([pid]) => pid)).toEqual([100, 101, 102]);
expect(signalGroup).not.toHaveBeenCalled();
});
it("ignores exited zombie members during live ownership checks", async () => {
const environment = vi.fn((pid: number) =>
Promise.resolve(pid === 101 ? {} : { REA_PROCESS_RUN_ID: "run-token" }),
);
const signalGroup = vi.fn();
const adapter: ProcessOwnershipHost = {
listMembers: () =>
Promise.resolve([
{
pid: 100,
parentPid: 1,
processGroupId: 100,
state: "S",
command: "fixture",
},
{
pid: 101,
parentPid: 100,
processGroupId: 100,
state: "Z",
command: "[node] <defunct>",
},
]),
environment,
signalGroup,
};
expect(await cleanupOwnedProcessGroup(ownership, adapter)).toEqual({
cleaned: true,
signaled: true,
});
expect(environment).toHaveBeenCalledTimes(1);
expect(environment).toHaveBeenCalledWith(100);
expect(signalGroup).toHaveBeenCalledWith(100, "SIGKILL");
});
it("observes a zombie-only group as settled", async () => {
const environment = vi.fn(() => Promise.resolve({}));
const adapter: ProcessOwnershipHost = {
listMembers: () =>
Promise.resolve([
{
pid: 101,
parentPid: 1,
processGroupId: 100,
state: "Z+",
command: "[node] <defunct>",
},
]),
environment,
signalGroup: vi.fn(),
};
expect(await observeOwnedProcessGroup(ownership, adapter)).toEqual({
state: "empty",
});
expect(environment).not.toHaveBeenCalled();
});
it("is idempotent when the owned group has already exited", async () => { it("is idempotent when the owned group has already exited", async () => {
const { adapter } = host({}); const { adapter } = host({});
expect(await cleanupOwnedProcessGroup(ownership, adapter)).toEqual({ expect(await cleanupOwnedProcessGroup(ownership, adapter)).toEqual({
+17
View File
@@ -192,6 +192,23 @@ describe("historical source graph", () => {
).toThrow(/Complete inventory/u); ).toThrow(/Complete inventory/u);
}); });
it("keeps distinct failure reasons for the same path and parser", () => {
const partial = graphInput();
partial.inventory_state = "partial";
partial.parse_failures = [
{ path: "src/main.ts", parser: "babel", reason: "First failure" },
{ path: "src/main.ts", parser: "babel", reason: "Second failure" },
];
expect(createHistoricalSourceGraph(partial).parse_failures).toEqual(
partial.parse_failures,
);
partial.parse_failures.push(partial.parse_failures[1]!);
expect(() => createHistoricalSourceGraph(partial)).toThrow(
/parse_failures.*unique/iu,
);
});
it("rejects strict unknown fields, invalid VCS, dangling internal edges, and false manifests", () => { it("rejects strict unknown fields, invalid VCS, dangling internal edges, and false manifests", () => {
expect(() => expect(() =>
createHistoricalSourceGraph({ ...graphInput(), surprise: true }), createHistoricalSourceGraph({ ...graphInput(), surprise: true }),
+2 -2
View File
@@ -37,7 +37,7 @@ describe("production stdio runtime", () => {
name: "current_document", name: "current_document",
arguments: {}, arguments: {},
}); });
expect(result.isError).not.toBe(true); expect(result.isError === true).toBe(process.platform === "linux");
} finally { } finally {
await client.close(); await client.close();
await transport.close(); await transport.close();
@@ -53,7 +53,7 @@ describe("production stdio runtime", () => {
mode: "mcp", mode: "mcp",
layer: "server", layer: "server",
tool: "current_document", tool: "current_document",
status: "ok", status: process.platform === "linux" ? "error" : "ok",
}), }),
); );
expect(stderr).not.toContain("HOPPER_LOADER_ARGS_JSON"); expect(stderr).not.toContain("HOPPER_LOADER_ARGS_JSON");
+64 -2
View File
@@ -19,9 +19,11 @@ class FakeSetupHost implements SetupHost {
hopper: string | undefined; hopper: string | undefined;
hopperInstallSucceeds = true; hopperInstallSucceeds = true;
skill: "installed" | "unchanged" | "failed" = "installed"; skill: "installed" | "unchanged" | "failed" = "installed";
skillNeedsInstallation = true;
clients: readonly SetupClient[] = []; clients: readonly SetupClient[] = [];
clientResults = new Map<string, ClientConfigurationResult>(); clientResults = new Map<string, ClientConfigurationResult>();
hopperInstalls = 0; hopperInstalls = 0;
skillInstallations = 0;
configurations = 0; configurations = 0;
doctorHealthy: boolean | undefined; doctorHealthy: boolean | undefined;
linuxDemoRuntimeMissing = false; linuxDemoRuntimeMissing = false;
@@ -62,8 +64,12 @@ class FakeSetupHost implements SetupHost {
this.clientResults.get(client.name) ?? { status: "configured" }, this.clientResults.get(client.name) ?? { status: "configured" },
); );
}; };
installSkill = (): Promise<"installed" | "unchanged" | "failed"> => skillInstallationNeeded = (): Promise<boolean> =>
Promise.resolve(this.skill); Promise.resolve(this.skillNeedsInstallation);
installSkill = (): Promise<"installed" | "unchanged" | "failed"> => {
this.skillInstallations += 1;
return Promise.resolve(this.skill);
};
doctor = (): Promise<{ doctor = (): Promise<{
healthy: boolean; healthy: boolean;
hopperPath?: string; hopperPath?: string;
@@ -126,6 +132,21 @@ describe("setup workflow", () => {
); );
}); });
it("omits an aligned skill from the plan and does not reinstall it", async () => {
const host = new FakeSetupHost();
host.hopper = "/Applications/Hopper";
host.skillNeedsInstallation = false;
const result = await runSetup(options(true), host);
expect(result.status).toBe("ready");
expect(result.plannedActions.map(({ kind }) => kind)).not.toContain(
"install_skill",
);
expect(result.appliedActions).toEqual([]);
expect(host.skillInstallations).toBe(0);
});
it("reports ready after an accepted Linux plan installs healthy Hopper", async () => { it("reports ready after an accepted Linux plan installs healthy Hopper", async () => {
const host = new FakeSetupHost("linux"); const host = new FakeSetupHost("linux");
host.distribution = { host.distribution = {
@@ -218,6 +239,19 @@ describe("setup workflow", () => {
expect(host.hopperInstalls).toBe(0); expect(host.hopperInstalls).toBe(0);
}); });
it("treats --install-hopper as approval, not a healthy reinstall request", async () => {
const host = new FakeSetupHost();
host.hopper = "/Applications/Hopper";
const result = await runSetup(options(true, true), host);
expect(result.status).toBe("ready");
expect(result.plannedActions.map(({ kind }) => kind)).not.toContain(
"install_hopper",
);
expect(host.hopperInstalls).toBe(0);
});
it("installs missing Linux demo dependencies for existing Hopper", async () => { it("installs missing Linux demo dependencies for existing Hopper", async () => {
const host = new FakeSetupHost("linux"); const host = new FakeSetupHost("linux");
host.distribution = { host.distribution = {
@@ -297,6 +331,34 @@ describe("setup workflow", () => {
expect(result.remediation).not.toContain("internal_client_key"); expect(result.remediation).not.toContain("internal_client_key");
}); });
it("records later client outcomes after an earlier configuration fails", async () => {
const host = new FakeSetupHost();
host.hopper = "/Applications/Hopper";
host.clients = [
{ name: "before", configPath: "/before.json" },
{ name: "broken", configPath: "/broken.json" },
{ name: "after", configPath: "/after.json" },
];
host.clientResults.set("broken", { status: "failed", reason: "write" });
const result = await runSetup(options(true), host);
expect(result.status).toBe("needs_human");
expect(host.configurations).toBe(3);
expect(result.clients).toEqual({
before: { status: "configured" },
broken: { status: "failed", reason: "write" },
after: { status: "configured" },
});
expect(result.appliedActions).toEqual([
"configured_before",
"configured_after",
]);
expect(result.remediation).toBe(
"Agent configuration could not be updated. Check file permissions, then rerun setup.",
);
});
it("explains skill installation recovery", async () => { it("explains skill installation recovery", async () => {
const host = new FakeSetupHost(); const host = new FakeSetupHost();
host.hopper = "/Applications/Hopper"; host.hopper = "/Applications/Hopper";
+13
View File
@@ -3,6 +3,7 @@ import { tmpdir } from "node:os";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
import { installCanonicalSkill } from "../src/application/Setup.js"; import { installCanonicalSkill } from "../src/application/Setup.js";
import { canonicalSkillInstallationNeeded } from "../src/application/SetupSkill.js";
import { TOOL_CONTRACTS } from "../src/contracts/toolContracts.js"; import { TOOL_CONTRACTS } from "../src/contracts/toolContracts.js";
const roots: string[] = []; const roots: string[] = [];
@@ -32,6 +33,18 @@ describe("canonical skill transaction", () => {
`tool_count: ${String(TOOL_CONTRACTS.length)}`, `tool_count: ${String(TOOL_CONTRACTS.length)}`,
); );
expect(await readFile(sibling, "utf8")).toBe("unrelated skill\n"); expect(await readFile(sibling, "utf8")).toBe("unrelated skill\n");
expect(await canonicalSkillInstallationNeeded(home)).toBe(false);
expect(await installCanonicalSkill(home)).toBe("unchanged"); expect(await installCanonicalSkill(home)).toBe("unchanged");
}); });
it("reports a missing or stale canonical skill as needing installation", async () => {
const home = await mkdtemp(join(tmpdir(), "rea-skill-plan-test-"));
roots.push(home);
const destination = join(home, ".agents/skills/rea-analysis/SKILL.md");
expect(await canonicalSkillInstallationNeeded(home)).toBe(true);
await mkdir(dirname(destination), { recursive: true });
await writeFile(destination, "stale managed skill\n");
expect(await canonicalSkillInstallationNeeded(home)).toBe(true);
});
}); });
+7
View File
@@ -1,3 +1,6 @@
import { tmpdir } from "node:os";
import { join } from "node:path";
import { defineConfig } from "vitest/config"; import { defineConfig } from "vitest/config";
export default defineConfig({ export default defineConfig({
@@ -8,6 +11,10 @@ export default defineConfig({
coverage: { coverage: {
enabled: true, enabled: true,
provider: "v8", provider: "v8",
reportsDirectory: join(
tmpdir(),
`rea-vitest-coverage-${String(process.pid)}`,
),
include: ["src/**"], include: ["src/**"],
thresholds: { thresholds: {
statements: 65, statements: 65,