Compare commits

...

12 Commits

49 changed files with 2141 additions and 413 deletions

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
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
`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
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
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;

View File

@ -6,21 +6,15 @@ import { relative, resolve } from "node:path";
const root = resolve(import.meta.dirname, "..");
// Collect all TS, MJS, and Python source files, excluding generated and third-party dirs.
const sourceFiles = [
...globSync("src/**/*.ts", {
// Native globSync is available throughout the Node versions in package.engines.
// Its exclusion option is `exclude`; `ignore` and `nodir` belong to npm glob.
const sourceFiles = globSync(
["src/**/*.ts", "tests/**/*.ts", "scripts/**/*.mjs", "bridge/**/*.py"],
{
cwd: root,
nodir: true,
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 }),
];
exclude: (path) => path.split(/[\\/]/u).includes("node_modules"),
},
);
const patterns = [
{ regex: /TODO(?::\s*(.*))?$/gm, label: "TODO" },

View File

@ -170,72 +170,88 @@ try {
throw new Error("packaged artifact inventory CLI failed");
// prettier-ignore
await verifyPackagedInvestigation({ cli, workspace, evidenceRoot, artifactArchive, environment });
const overview = json(
await run(cli, ["analyze", process.execPath, "--json"], environment),
);
if (
overview.operation !== "binary_overview" ||
overview.normalized_result?.procedure_count < 1
)
throw new Error(
`packaged Hopper-backed analyze CLI failed: ${JSON.stringify(overview)}`,
if (process.platform === "linux") {
const unsupportedExecution = await runWithStatus(
cli,
["analyze", process.execPath, "--json"],
environment,
);
const inspected = json(
await run(
cli,
[
"inspect",
process.execPath,
"--detail",
"detailed",
"--limit",
"1",
"--json",
],
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 unsupported = json(unsupportedExecution.stdout);
if (
unsupportedExecution.status !== 1 ||
unsupported.details?.failure_code !== "unsupported_hopper_build"
)
throw new Error(
`packaged Linux Hopper verification did not fail closed: ${JSON.stringify(unsupported)}`,
);
} else {
const overview = json(
await run(cli, ["analyze", process.execPath, "--json"], environment),
);
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)}`);
if (
overview.operation !== "binary_overview" ||
overview.normalized_result?.procedure_count < 1
)
throw new Error(
`packaged Hopper-backed analyze CLI failed: ${JSON.stringify(overview)}`,
);
const inspected = json(
await run(
cli,
[
"inspect",
process.execPath,
"--detail",
"detailed",
"--limit",
"1",
"--json",
],
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(
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(
await run(cli, ["capabilities", "--json"], environment),
);
@ -247,20 +263,22 @@ try {
providers.providers.some(({ id }) => typeof id !== "string")
)
throw new Error("packaged providers CLI failed");
const searchResult = json(
await run(
cli,
["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 (process.platform !== "linux") {
const searchResult = json(
await run(
cli,
["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)}`,
);
}
const referenceImport = json(
await run(
cli,
@ -478,30 +496,41 @@ try {
);
if (opened.isError === true)
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(
{ name: "current_document", arguments: {} },
mcpOptions,
);
if (
current.isError === true ||
json(prompts.mcpText(current)).normalized_result !== "fixture"
)
throw new Error("packaged MCP bridge call failed");
const batch = await client.callTool(
{ name: "batch_decompile", arguments: { addresses: ["0x1000"] } },
mcpOptions,
);
const batchResult = json(prompts.mcpText(batch)).normalized_result;
if (
batch.isError === true ||
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");
if (process.platform === "linux") {
if (
current.isError !== true ||
!prompts
.mcpText(current)
.includes("not supported for unattended Linux analysis")
)
throw new Error("packaged Linux MCP did not reject an unpinned Hopper");
} else {
if (json(prompts.mcpText(current)).normalized_result !== "fixture")
throw new Error("packaged MCP bridge call failed");
const batch = await client.callTool(
{ name: "batch_decompile", arguments: { addresses: ["0x1000"] } },
mcpOptions,
);
const batchResult = json(prompts.mcpText(batch)).normalized_result;
if (
batch.isError === true ||
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(
{ name: "close_binary", arguments: {} },
mcpOptions,

View File

@ -45,6 +45,7 @@ import {
ARTIFACT_COMPARISON_PROVIDER,
CHANGED_BEHAVIOR_PROVIDER,
} from "./InvestigationProviders.js";
import { selectCompletedInvestigationReplay } from "./CrossVersionInvestigationReplay.js";
export interface CrossVersionInvestigationOutcome {
readonly evidence: Evidence;
@ -59,6 +60,7 @@ export interface CrossVersionInvestigationExecution {
readonly signal?: AbortSignal;
readonly progress?: ProgressReporter;
readonly integrityContinueEnabled?: boolean;
readonly authorizeInputRead?: () => Promise<Result<null, AnalysisError>>;
readonly authorizeWorkspaceWrite?: () => Promise<Result<null, AnalysisError>>;
}
@ -84,6 +86,23 @@ export const runCrossVersionInvestigation = async (
if (!preflight.ok) return preflight;
if (isCancelled(execution.signal))
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({
phase: "scan_versions",
completed: 0,

View File

@ -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);

View File

@ -243,12 +243,13 @@ const startCaptureRuntime = async (options: {
renderer,
started,
});
const stopSampler = startProcessSampler(
terminal.pid,
const stopSampler = startProcessSampler({
rootPid: terminal.pid,
runId: options.runId,
started,
scenario.limits.processes,
options.samples,
);
limit: scenario.limits.processes,
samples: options.samples,
});
return {
replay,
renderer,

View File

@ -19,6 +19,7 @@ interface ProcessGroupMember {
readonly pid: number;
readonly parentPid: number;
readonly processGroupId: number;
readonly state: string;
readonly command: string;
}
@ -30,9 +31,19 @@ export interface ProcessOwnershipHost {
}
/** 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 =
| { 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. */
export type ProcessGroupObservation =
@ -59,17 +70,18 @@ const systemHost: ProcessOwnershipHost = {
if (process.platform === "win32") return [];
const { stdout } = await execFileAsync("ps", [
"-axo",
"pid=,ppid=,pgid=,command=",
"pid=,ppid=,pgid=,stat=,command=",
]);
return stdout
.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)
.map((match) => ({
pid: Number(match[1]),
parentPid: Number(match[2]),
processGroupId: Number(match[3]),
command: match[4] ?? "",
state: match[4] ?? "",
command: match[5] ?? "",
}))
.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.
*
@ -109,8 +128,11 @@ export const cleanupOwnedProcessGroup = async (
} catch {
return { cleaned: false, reason: "process group could not be inspected" };
}
if (members.length === 0) return { cleaned: true, signaled: false };
const leader = members.find((member) => member.pid === ownership.leaderPid);
const liveMembers = liveProcessGroupMembers(members);
if (liveMembers.length === 0) return { cleaned: true, signaled: false };
const leader = liveMembers.find(
(member) => member.pid === ownership.leaderPid,
);
if (leader !== undefined) {
if (
ownership.expectedParentPid !== undefined &&
@ -129,23 +151,24 @@ export const cleanupOwnedProcessGroup = async (
reason: "owned launcher command identity did not match",
};
}
for (const member of members) {
let environment: Readonly<Record<string, string>>;
const failures: ProcessOwnershipValidationFailure[] = [];
for (const member of liveMembers) {
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 {
return {
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",
};
failures.push({ pid: member.pid, reason: "environment-unreadable" });
}
}
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 {
host.signalGroup(ownership.processGroupId, "SIGKILL");
} catch (cause: unknown) {
@ -171,8 +194,9 @@ export const observeOwnedProcessGroup = async (
reason: "process group could not be inspected",
};
}
if (members.length === 0) return { state: "empty" };
for (const member of members) {
const liveMembers = liveProcessGroupMembers(members);
if (liveMembers.length === 0) return { state: "empty" };
for (const member of liveMembers) {
try {
if (
(await host.environment(member.pid)).REA_PROCESS_RUN_ID !==
@ -192,6 +216,11 @@ export const observeOwnedProcessGroup = async (
return { state: "alive" };
};
const liveProcessGroupMembers = (
members: readonly ProcessGroupMember[],
): readonly ProcessGroupMember[] =>
members.filter(({ state }) => !state.startsWith("Z"));
const commandMatches = (actual: string, expected: string): boolean => {
const normalizedActual = actual.trim();
const normalizedExpected = expected.trim();

View File

@ -2,6 +2,7 @@ import { execFile } from "node:child_process";
import { readFile, readdir } from "node:fs/promises";
import { promisify } from "node:util";
import type { ProcessSample } from "../domain/processCapture.js";
import { readProcessRunId } from "./ProcessOwnership.js";
const execFileAsync = promisify(execFile);
const PROC_READ_CONCURRENCY = 64;
@ -27,6 +28,29 @@ interface SampleProcessContext {
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 =>
cause instanceof Error &&
"code" in cause &&
@ -305,6 +329,47 @@ const readPsRows = async (
.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 (
context: SampleProcessContext,
): Promise<readonly ProcessRow[]> => {
@ -375,31 +440,47 @@ const sampleProcesses = async (
};
/** Start bounded process-tree sampling and expose an awaited stop. */
export const startProcessSampler = (
rootPid: number,
started: number,
limit: number,
samples: ProcessSample[],
): (() => Promise<{ readonly partial: boolean }>) => {
export const startProcessSampler = (options: {
readonly rootPid: number;
readonly runId: string;
readonly started: number;
readonly limit: number;
readonly samples: ProcessSample[];
}): (() => Promise<{ readonly partial: boolean }>) => {
const { rootPid, runId, started, limit, samples } = options;
const identities = new Map<number, string>();
const lastObservations = new Map<number, string>();
let pending: Promise<void> | undefined;
let stopped = false;
let abortCurrent: (() => void) | undefined;
let partial = false;
let rootInitialized = false;
const sample = (): void => {
if (stopped || pending !== undefined) return;
const controller = new AbortController();
abortCurrent = () => controller.abort();
pending = sampleProcesses({
rootPid,
elapsedMs: Date.now() - started,
limit: limit + 1,
identities,
signal: controller.signal,
})
pending = (async () => {
if (!rootInitialized) {
const identity = await inspectInitializedPtyRoot(
rootPid,
runId,
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) => {
for (const value of values) {
const observation = JSON.stringify({

View File

@ -1,6 +1,7 @@
import { isSecretLikePath } from "../domain/referenceSourceClassification.js";
import {
createHistoricalSourceGraph,
referenceSourceParseFailureKey,
type HistoricalSourceGraph,
type HistoricalSourceGraphInput,
} from "../domain/referenceSourceGraph.js";
@ -204,18 +205,19 @@ export const importReferenceSource = async (
if (isAborted(options.signal)) return err(cancelled());
const uniqueRelationships = deduplicateRelationships(relationships);
const uniqueFailures = [...parseFailures]
.sort((left, right) => {
const byPath = byCodePoint(left.path, right.path);
if (byPath !== 0) return byPath;
return byCodePoint(left.parser, right.parser);
})
.filter((value, index, array) => {
if (index === 0) return true;
const previous = array[index - 1];
if (previous === undefined) return true;
return value.path !== previous.path || value.parser !== previous.parser;
});
const uniqueFailures = [
...new Map(
parseFailures.map((parseFailure) => [
referenceSourceParseFailureKey(parseFailure),
parseFailure,
]),
).values(),
].sort((left, right) =>
byCodePoint(
referenceSourceParseFailureKey(left),
referenceSourceParseFailureKey(right),
),
);
const sortedEntries = [...entries].sort((left, right) =>
byCodePoint(left.path, right.path),

View File

@ -25,7 +25,10 @@ import { installMacHopper } from "./MacHopper.js";
import { configureDetectedClients } from "./SetupClients.js";
import { supportedClients, 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 {
setupInstallFailure,
@ -69,6 +72,7 @@ export interface SetupHost {
hopperPath: string | undefined,
command: readonly string[],
): Promise<ClientConfigurationResult>;
skillInstallationNeeded(): Promise<boolean>;
installSkill(): Promise<"installed" | "unchanged" | "failed">;
doctor(): Promise<Awaited<ReturnType<typeof runDoctor>>>;
}
@ -140,7 +144,13 @@ export const runSetup = async (
);
const installHopper = hopperPath === undefined || linuxHopperRepairNeeded;
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 interactiveApproval = false;
if (!approved && confirm !== undefined && !options.structured) {
@ -182,12 +192,14 @@ export const runSetup = async (
appliedActions,
});
if (clientFailure !== undefined) return fail(clientFailure);
const skill = await host.installSkill();
if (skill === "failed")
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 (installSkill) {
const skill = await host.installSkill();
if (skill === "failed")
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");
}
const doctor = await host.doctor();
const remediation = finalSetupRemediation(
host.platform,
@ -261,6 +273,7 @@ const systemSetupHost = (): SetupHost => {
: client.format === "toml"
? configureTomlClient(client, hopperPath, command)
: configureJsonClient(client, hopperPath, command),
skillInstallationNeeded: () => canonicalSkillInstallationNeeded(homedir()),
installSkill: () => installCanonicalSkill(homedir()),
doctor: () => runDoctor(undefined, doctorHost),
};

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.";
};
/** 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: {
readonly host: SetupHost;
readonly detectedClients: readonly SetupClient[];
@ -23,6 +23,7 @@ export const configureDetectedClients = async (options: {
readonly clients: Record<string, ClientConfigurationResult>;
readonly appliedActions: string[];
}): Promise<string | undefined> => {
const failures = new Set<"backup" | "write" | "readback">();
for (const client of options.detectedClients) {
const result = await options.host.configureClient(
client,
@ -30,10 +31,11 @@ export const configureDetectedClients = async (options: {
options.command,
);
options.clients[client.name] = result;
if (result.status === "failed")
return failedConfigurationMessage(result.reason);
if (result.status === "failed") failures.add(result.reason);
if (result.status === "configured")
options.appliedActions.push(`configured_${client.name}`);
}
return undefined;
return failures.size === 0
? undefined
: [...failures].map(failedConfigurationMessage).join(" ");
};

View File

@ -5,6 +5,7 @@ export const setupPlan = (
platform: NodeJS.Platform,
installHopper: boolean,
clients: readonly SetupClient[],
installSkill: boolean,
): readonly SetupAction[] => [
...(installHopper
? [
@ -32,10 +33,14 @@ export const setupPlan = (
external: false,
}),
),
{
kind: "install_skill",
target: "~/.agents/skills/rea-analysis/SKILL.md",
detail: "Install or update the bundled REA analysis skill.",
external: false,
},
...(installSkill
? [
{
kind: "install_skill" as const,
target: "~/.agents/skills/rea-analysis/SKILL.md",
detail: "Install or update the bundled REA analysis skill.",
external: false,
},
]
: []),
];

View File

@ -4,26 +4,43 @@ import writeFileAtomic from "write-file-atomic";
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. */
export const installCanonicalSkill = async (
home: string,
): Promise<"installed" | "unchanged" | "failed"> => {
const destination = join(
home,
".agents/skills",
PRODUCT_IDENTITY.skillName,
"SKILL.md",
);
const destination = canonicalSkillDestination(home);
const backup = `${destination}.rea.backup`;
let original: string | undefined;
try {
const content = await readFile(
new URL(
`../../skills/${PRODUCT_IDENTITY.skillName}/SKILL.md`,
import.meta.url,
),
"utf8",
);
const content = await readCanonicalSkill();
original = await readFile(destination, "utf8").catch(() => undefined);
if (original === content) return "unchanged";
await mkdir(dirname(destination), { recursive: true });

View File

@ -194,6 +194,8 @@ const runCommand = (
): Promise<boolean> =>
new Promise((resolveResult) => {
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:
output === "human" ? "inherit" : ["inherit", process.stderr, "inherit"],
});

View File

@ -328,6 +328,7 @@ export class CdpBrowserProvider implements BrowserObservationPort {
return await capturePage({
connection: targetSession.connection,
sessionId: targetSession.sessionId,
operation,
discovery,
target,
input,

View File

@ -1,6 +1,7 @@
import {
AnalysisCancelledError,
BrowserObservationError,
type BrowserObservationOperation,
} from "../domain/errors.js";
import { sanitizeBrowserUrl } from "../domain/browserObservation.js";
import { createWebTextArtifact } from "../domain/webContentArtifact.js";
@ -74,11 +75,11 @@ export const sourceExcluded = (
export const delayWithCancellation = async (
durationMs: number,
operation: BrowserObservationOperation,
signal?: AbortSignal,
): Promise<void> => {
if (durationMs === 0) return;
if (signal?.aborted === true)
throw new AnalysisCancelledError("inspect_web_page");
if (signal?.aborted === true) throw new AnalysisCancelledError(operation);
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
signal?.removeEventListener("abort", onAbort);
@ -87,7 +88,7 @@ export const delayWithCancellation = async (
const onAbort = (): void => {
clearTimeout(timer);
signal?.removeEventListener("abort", onAbort);
reject(new AnalysisCancelledError("inspect_web_page"));
reject(new AnalysisCancelledError(operation));
};
signal?.addEventListener("abort", onAbort, { once: true });
if (signal?.aborted === true) onAbort();

View File

@ -5,6 +5,7 @@ import {
AnalysisError,
AnalysisTimeoutError,
BrowserObservationError,
type BrowserObservationOperation,
} from "../domain/errors.js";
export interface CdpEvent {
@ -25,34 +26,38 @@ const COMMAND_TIMEOUT_MS = 5_000;
const MAX_PAYLOAD_BYTES = 16 * 1_024 * 1_024;
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 {
readonly #pending = new Map<number, PendingCommand>();
readonly #listeners = new Set<(event: CdpEvent) => void>();
readonly #disconnectListeners = new Set<() => void>();
#nextId = 1;
#closed = 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("close", () => this.#failPending("disconnected"));
socket.on("error", () => this.#failPending("disconnected"));
socket.on("close", () => this.#disconnect());
socket.on("error", () => this.#disconnect());
}
/** Connect to one already-validated loopback browser WebSocket. */
/** Connect to one already-validated loopback CDP WebSocket. */
static async connect(
url: string,
operation: BrowserObservationOperation,
signal?: AbortSignal,
): Promise<CdpConnection> {
if (signal?.aborted === true)
throw new AnalysisCancelledError("inspect_web_page");
if (signal?.aborted === true) throw new AnalysisCancelledError(operation);
const socket = new WebSocket(url, {
handshakeTimeout: CONNECT_TIMEOUT_MS,
maxPayload: MAX_PAYLOAD_BYTES,
perMessageDeflate: false,
});
await waitForOpen(socket, signal);
return new CdpConnection(socket);
await waitForOpen(socket, operation, signal);
return new CdpConnection(socket, operation);
}
/** Subscribe to validated CDP event envelopes. */
@ -61,6 +66,16 @@ export class CdpConnection {
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. */
async send(
method: string,
@ -69,13 +84,13 @@ export class CdpConnection {
signal?: AbortSignal,
): Promise<unknown> {
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)
throw new BrowserObservationError("inspect_web_page", "disconnected");
throw new BrowserObservationError(this.operation, "disconnected");
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)
throw new AnalysisCancelledError("inspect_web_page");
throw new AnalysisCancelledError(this.operation);
const id = this.#nextId;
this.#nextId += 1;
return await new Promise((resolve, reject) => {
@ -85,15 +100,13 @@ export class CdpConnection {
clearTimeout(pending.timer);
pending.removeAbort();
this.#pending.delete(id);
reject(new AnalysisCancelledError("inspect_web_page"));
reject(new AnalysisCancelledError(this.operation));
};
signal?.addEventListener("abort", onAbort, { once: true });
const timer = setTimeout(() => {
signal?.removeEventListener("abort", onAbort);
this.#pending.delete(id);
reject(
new AnalysisTimeoutError("inspect_web_page", COMMAND_TIMEOUT_MS),
);
reject(new AnalysisTimeoutError(this.operation, COMMAND_TIMEOUT_MS));
}, COMMAND_TIMEOUT_MS);
this.#pending.set(id, {
resolve,
@ -114,7 +127,7 @@ export class CdpConnection {
if (pending === undefined) return;
this.#complete(id, pending);
reject(
new BrowserObservationError("inspect_web_page", "disconnected", {
new BrowserObservationError(this.operation, "disconnected", {
cause: error,
}),
);
@ -191,7 +204,7 @@ export class CdpConnection {
this.#complete(id, pending);
if ("error" in message) {
pending.reject(
new BrowserObservationError("inspect_web_page", "protocol_error"),
new BrowserObservationError(this.operation, "protocol_error"),
);
return;
}
@ -209,9 +222,17 @@ export class CdpConnection {
else this.#protocolFailed = true;
for (const [id, pending] of this.#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 =>
@ -226,13 +247,14 @@ const isRecord = (value: unknown): value is Record<string, unknown> =>
const waitForOpen = async (
socket: WebSocket,
operation: BrowserObservationOperation,
signal?: AbortSignal,
): Promise<void> =>
await new Promise((resolve, reject) => {
const timer = setTimeout(() => {
cleanup();
terminateSocket(socket);
reject(new AnalysisTimeoutError("inspect_web_page", CONNECT_TIMEOUT_MS));
reject(new AnalysisTimeoutError(operation, CONNECT_TIMEOUT_MS));
}, CONNECT_TIMEOUT_MS);
const onOpen = (): void => {
cleanup();
@ -242,17 +264,15 @@ const waitForOpen = async (
cleanup();
reject(
signal?.aborted === true
? new AnalysisCancelledError("inspect_web_page")
: new BrowserObservationError(
"inspect_web_page",
"endpoint_unreachable",
{ cause },
),
? new AnalysisCancelledError(operation)
: new BrowserObservationError(operation, "endpoint_unreachable", {
cause,
}),
);
};
const onAbort = (): void => {
terminateSocket(socket);
onFailure(new AnalysisCancelledError("inspect_web_page"));
onFailure(new AnalysisCancelledError(operation));
};
const cleanup = (): void => {
clearTimeout(timer);

View File

@ -95,7 +95,11 @@ export const inspectCdpElectronPage = async (
context.sessionId,
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");
const resources = await captureResources(context, roots, completeness);
const dom = await captureDom(context, roots, completeness);

View File

@ -54,7 +54,7 @@ export class CdpElectronProvider implements ElectronObservationPort {
const roots = await canonicalElectronRoots(input.allowed_file_roots);
const discovery = await discoverCdpEndpoint(
input.cdp_endpoint,
"list_browser_targets",
"list_electron_targets",
options.signal,
);
const allowed = [];

View File

@ -3,7 +3,10 @@ import type {
ObserveWebSessionInput,
WebObservationSession,
} 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 { CdpConnection, type CdpEvent } from "./CdpConnection.js";
import { CdpCaptureCompleteness } from "./CdpCaptureCompleteness.js";
@ -48,8 +51,12 @@ export const observeCdpSession = async (
initialUrl,
);
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 {
await context.connection.send(
"Page.enable",
@ -115,6 +122,7 @@ export const observeCdpSession = async (
};
} finally {
removeListener();
removeDisconnectListener();
}
};
@ -162,8 +170,7 @@ class TimelineCapture {
break;
case "Inspector.targetCrashed":
case "Target.detachedFromTarget":
this.#add("target_terminated", params, null, null, "target_terminated");
this.end("target_terminated");
this.targetTerminated();
break;
}
}
@ -181,6 +188,12 @@ class TimelineCapture {
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 {
const destination = scopedUrl(rawUrl, this.allowedOrigins);
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 (
durationMs: number,
capture: TimelineCapture,
@ -321,7 +346,7 @@ const waitForWindow = async (
settled = true;
clearTimeout(timer);
removeEnd();
reject(signal?.reason ?? new Error("browser observation cancelled"));
reject(new AnalysisCancelledError("observe_web_session"));
};
const timer = setTimeout(() => finish("window_elapsed"), durationMs);
removeEnd = capture.onEnd(finish);
@ -347,7 +372,7 @@ const authorizedFrame = async (
"inspect_web_page",
"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");
};

View File

@ -3,7 +3,10 @@ import type {
WebPageInspection,
} from "../domain/browserObservation.js";
import type { ProgressReporter } from "../application/ProgressReporter.js";
import { BrowserObservationError } from "../domain/errors.js";
import {
BrowserObservationError,
type BrowserObservationOperation,
} from "../domain/errors.js";
import {
reconcileCapturedWebScript,
stableWebResources,
@ -38,6 +41,10 @@ import type { WebSourceMapRequest } from "./WebSourceMapFetcher.js";
interface CaptureContext {
readonly connection: CdpConnection;
readonly sessionId: string | undefined;
readonly operation: Extract<
BrowserObservationOperation,
"inspect_web_page" | "analyze_web_bundle"
>;
readonly discovery: CdpEndpointDiscovery;
readonly target: CdpEndpointTarget;
readonly input: InspectWebPageInput;
@ -225,7 +232,11 @@ const authorizeObservationWindow = async (
events.beginAuthorizedFrame(mainFrame.frame_id);
await enableObservationDomains(connection, sessionId, signal);
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)
throw new BrowserObservationError("inspect_web_page", "target_not_allowed");
};
@ -323,7 +334,7 @@ const authorizedFrameTree = async (
"inspect_web_page",
"target_not_allowed",
);
await delayWithCancellation(25, context.signal);
await delayWithCancellation(25, context.operation, context.signal);
}
throw new BrowserObservationError("inspect_web_page", "target_not_allowed");
};

View File

@ -23,7 +23,11 @@ export const openCdpTargetSession = async (
signal?: AbortSignal,
): Promise<CdpTargetSession> => {
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 };
try {
const attached = await connection.send(

View File

@ -96,6 +96,7 @@ export const discoverWebMcp = async (
// CDP sends the enable response before the required toolsAdded replay.
await delayWithCancellation(
Math.max(context.input.observation_ms, 25),
"discover_webmcp_tools",
context.signal,
);
await assertStableAuthorizedFrame(context, initialUrl, origins);

View File

@ -160,7 +160,9 @@ const registerSetupCommands = (
installHopper: z
.boolean()
.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" },
run: ({ options, formatExplicit }) =>

View File

@ -18,6 +18,11 @@ const investigationOptionsSchema = z.object({
.describe("Approve persistent workspace writes"),
workspaceName: z.string().trim().min(1).max(200).default("default"),
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),
maxTotalBytes: z
.number()
@ -55,6 +60,7 @@ export const registerInvestigationCommands = (
alias: {
workspaceName: "workspace-name",
expectedRevision: "expected-revision",
replayRunId: "replay-run-id",
maxEntries: "max-entries",
maxTotalBytes: "max-total-bytes",
maxEntryBytes: "max-entry-bytes",
@ -99,13 +105,6 @@ const runInvestigation = async (
},
);
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(
{
approved: true,
@ -114,6 +113,9 @@ const runInvestigation = async (
...(options.expectedRevision === undefined
? {}
: { expected_workspace_revision: options.expectedRevision }),
...(options.replayRunId === undefined
? {}
: { replay_run_id: options.replayRunId }),
left_path: args.leftPath,
right_path: args.rightPath,
integrity_policy: options.integrityPolicy,
@ -134,6 +136,13 @@ const runInvestigation = async (
{
inputRoots: config.value.investigationInputRoots,
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,
},
);

View File

@ -1,4 +1,5 @@
import { Cli, z } from "incur";
import { createInterface } from "node:readline/promises";
import { loadConfiguredPermissionAuthority } from "./application/PermissionConfiguration.js";
import {
@ -49,9 +50,13 @@ export const registerPolicyCommands = (
network: z.enum(["none", "loopback", "external"]).default("none"),
mount: 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" },
run: ({ args, options }) =>
alias: { environmentNames: "environment-names", yes: "y" },
run: ({ args, options, formatExplicit }) =>
logCliCommand(logger, "policy", async () => {
const config = parseConfig(process.env);
if (!config.ok) return failure(config.error);
@ -76,6 +81,22 @@ export const registerPolicyCommands = (
permissionProjectStore === undefined
)
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(
permissionProjectStore,
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 (
config: AppConfig,
capability: PermissionCapability,

View File

@ -59,6 +59,10 @@ export const crossVersionInvestigationInputSchema = z
workspace_path: z.string().min(1).max(4_096),
workspace_name: z.string().trim().min(1).max(200).default("default"),
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),
right_path: z.string().min(1).max(4_096),
integrity_policy: z.enum(["fail", "record-and-continue"]).default("fail"),

View File

@ -128,6 +128,11 @@ const parseFailureSchema = z.strictObject({
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({
path: relativePathSchema,
reason: z.enum([
@ -400,7 +405,7 @@ function checkGraphInvariants(
context,
);
checkSortedUnique(
graph.parse_failures.map(({ path, parser }) => `${path}\u0000${parser}`),
graph.parse_failures.map(referenceSourceParseFailureKey),
"parse_failures",
context,
);

View File

@ -40,15 +40,25 @@ export interface BridgeLauncher {
): Promise<Result<BridgeLaunch, HopperStartError | HopperCancelledError>>;
}
export interface HopperApplicationLauncherOptions {
interface SharedHopperApplicationLauncherOptions {
readonly launcherPath: string;
readonly targetPath: string;
readonly targetKind: "executable" | "database";
readonly loaderArgs: readonly 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.
*
@ -186,10 +196,13 @@ const linuxDemoLaunch = (
};
};
const usesLinuxDemo = (options: HopperApplicationLauncherOptions): boolean =>
process.platform === "linux" &&
basename(options.launcherPath) === "Hopper" &&
options.demoHelperPath !== undefined;
/** Select the version-pinned adapter from the caller's explicit launch mode. */
export const usesLinuxDemo = (
options: HopperApplicationLauncherOptions,
): options is HopperApplicationLauncherOptions & {
readonly launchMode: "verified_linux_demo";
readonly demoHelperPath: string;
} => options.launchMode === "verified_linux_demo";
const spawnLauncher = async (
command: string,

View File

@ -114,9 +114,14 @@ export class HopperProvider implements AnalysisProvider {
bridgeScriptPath: fileURLToPath(
new URL("../../bridge/hopper_bridge.py", import.meta.url),
),
demoHelperPath: fileURLToPath(
new URL("../../scripts/hopper-demo-x11.py", import.meta.url),
),
...(process.platform === "linux"
? {
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" }),
});

View File

@ -33,7 +33,7 @@ interface RuntimeDependencies {
readonly serve: typeof serveStdio;
readonly writeStderr: (text: string) => void;
readonly setExitCode: (code: number) => void;
readonly registerShutdown: (handler: () => void) => void;
readonly registerShutdown: (handler: () => void) => () => void;
readonly registerReload?: (handler: () => void) => () => void;
}
@ -49,6 +49,15 @@ const runtimeDependencies = (): RuntimeDependencies => ({
process.once("SIGTERM", handler);
process.stdin.once("end", 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) => {
process.on("SIGHUP", handler);
@ -241,9 +250,11 @@ export const run = async (
})();
}) ?? (() => undefined);
let unregisterShutdown = (): void => undefined;
let shutdownPromise: Promise<void> | undefined;
const shutdown = (): Promise<void> => {
shutdownPromise ??= (async () => {
unregisterShutdown();
unregisterReload();
await handle.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;
};

View File

@ -19,7 +19,10 @@ import {
} from "../domain/changedBehavior.js";
import { createEvidence, type Evidence } from "../domain/evidence.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 { err, ok, type Result } from "../domain/result.js";
import {
@ -93,14 +96,19 @@ const registerChangedBehavior = (
contractOptions(contract),
async (input, context) => {
const parsed = changedBehaviorInputSchema.parse(input);
if (parsed.investigation_run !== undefined) {
const investigationRun = parsed.investigation_run;
if (investigationRun !== undefined) {
const progress = mcpProgressReporter(context);
let workspaceAuthorization: DeferredFileWriteAuthorization | undefined;
let authorizeInputRead:
| (() => Promise<Result<null, AnalysisError>>)
| undefined;
if (policies.permissionAuthority !== undefined) {
const authority = policies.permissionAuthority;
const authorizedWorkspace = await authorizeFileReadWithDeferredWrite(
policies.permissionAuthority,
authority,
{
path: parsed.investigation_run.workspace_path,
path: investigationRun.workspace_path,
readCapability: "investigation_workspace_read",
writeCapability: "investigation_workspace_write",
operation: contract.name,
@ -109,23 +117,16 @@ const registerChangedBehavior = (
if (!authorizedWorkspace.ok)
return toCallToolResult(authorizedWorkspace, contract);
workspaceAuthorization = authorizedWorkspace.value;
const authorizedInput = await authorizeRootPermission(
policies.permissionAuthority,
{
authorizeInputRead = () =>
authorizeRootPermission(authority, {
capability: "investigation_input",
roots: [
parsed.investigation_run.left_path,
parsed.investigation_run.right_path,
],
roots: [investigationRun.left_path, investigationRun.right_path],
access: "read",
operation: contract.name,
},
);
if (!authorizedInput.ok)
return toCallToolResult(authorizedInput, contract);
});
}
const investigated = await runCrossVersionInvestigation(
parsed.investigation_run,
investigationRun,
policies.evidenceFiles,
{
...investigationExecution(
@ -141,6 +142,7 @@ const registerChangedBehavior = (
authorizeWorkspaceWrite:
workspaceAuthorization.authorizeWrite,
}),
...(authorizeInputRead === undefined ? {} : { authorizeInputRead }),
},
);
if (!investigated.ok) return toCallToolResult(investigated, contract);

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 });
}
});
});

View File

@ -109,6 +109,82 @@ describe("CdpBrowserProvider", () => {
).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 () => {
const browser = await startFakeCdpBrowser({ invalidAttachedSession: true });
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 () => {
const browser = await startFakeCdpBrowser({ webMcpTools: true });
browsers.push(browser);
@ -1198,7 +1326,10 @@ describe("CdpBrowserProvider", () => {
const result = await pending;
expect(result).toMatchObject({
ok: false,
error: { _tag: "AnalysisCancelledError" },
error: {
_tag: "AnalysisCancelledError",
operation: "inspect_web_page",
},
});
const methods = browser.commands.map(({ method }) => method);
expect(methods).toContain("Target.detachFromTarget");

View File

@ -19,7 +19,10 @@ describe("CDP connection", () => {
it("correlates concurrent command responses over a real WebSocket", async () => {
const browser = await startFakeCdpBrowser();
browsers.push(browser);
const connection = await CdpConnection.connect(browser.browserWebSocketUrl);
const connection = await CdpConnection.connect(
browser.browserWebSocketUrl,
"inspect_web_page",
);
try {
const [attached, frames] = await Promise.all([
connection.send("Target.attachToTarget"),
@ -37,13 +40,16 @@ describe("CDP connection", () => {
it("returns a typed timeout for an unresponsive CDP command", async () => {
const browser = await startFakeCdpBrowser({ hangOnMethod: "Page.enable" });
browsers.push(browser);
const connection = await CdpConnection.connect(browser.browserWebSocketUrl);
const connection = await CdpConnection.connect(
browser.browserWebSocketUrl,
"observe_web_session",
);
vi.useFakeTimers();
try {
const pending = connection.send("Page.enable");
const assertion = expect(pending).rejects.toMatchObject({
_tag: "AnalysisTimeoutError",
operation: "inspect_web_page",
operation: "observe_web_session",
timeoutMs: 5_000,
});
await vi.advanceTimersByTimeAsync(5_000);

View File

@ -142,6 +142,28 @@ describe("CdpElectronProvider", () => {
).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 () => {
const root = await electronFixture();
const browser = await startFakeCdpBrowser({

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,
);
});

View File

@ -51,7 +51,11 @@ interface FakeOptions {
readonly binaryWebSocketEvent?: boolean;
readonly invalidBinaryWebSocketEvent?: boolean;
readonly sourceMapBody?: string;
readonly sessionTimeline?: "same_origin" | "outside_policy";
readonly sessionTimeline?:
| "same_origin"
| "outside_policy"
| "target_detached";
readonly closeAfterMethod?: string;
readonly sensitiveShapes?: boolean;
readonly invalidResponseBodyBase64?: boolean;
readonly webMcpTools?: boolean;
@ -183,6 +187,8 @@ export const startFakeCdpBrowser = async (
if (options.malformedEventShapeOnMethod === command.method)
socket.send(JSON.stringify({ method: 42 }));
emitEvents(socket, command, port, options);
if (options.closeAfterMethod === command.method)
setImmediate(() => socket.close());
});
});
await new Promise<void>((resolve, reject) => {
@ -963,6 +969,11 @@ const emitEvents = (
name: "networkIdle",
timestamp: 15,
});
if (options.sessionTimeline === "target_detached")
event(socket, "Target.detachedFromTarget", undefined, {
sessionId: command.sessionId,
targetId: "allowed-page",
});
}
};

View File

@ -1,6 +1,12 @@
import { spawn } from "node:child_process";
const mode = process.argv[2];
const treeRunMarker = process.argv[3];
const treeRunArguments = (childMode) => [
process.argv[1],
childMode,
...(treeRunMarker === undefined ? [] : [treeRunMarker]),
];
if (mode === "interactive") {
process.stdin.setRawMode?.(true);
@ -32,14 +38,14 @@ if (mode === "interactive") {
process.exit(0);
}, 25);
} else if (mode === "tree-child") {
spawn(process.execPath, [process.argv[1], "tree-grandchild"], {
spawn(process.execPath, treeRunArguments("tree-grandchild"), {
stdio: "ignore",
});
setInterval(() => undefined, 1_000);
} else if (mode === "tree-grandchild") {
setInterval(() => undefined, 1_000);
} 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");
setInterval(() => undefined, 1_000);
} else if (mode === "crash") {

View File

@ -15,6 +15,7 @@ import {
PROCESS_COMPARISON_EVIDENCE,
} from "../src/contracts/investigationExamples.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 { createServer } from "../src/server/createServer.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 () => {
const directory = await mkdtemp(join(tmpdir(), "rea-investigation-mcp-"));
const approvedInputs = join(directory, "approved-inputs");

View File

@ -12,7 +12,7 @@ import { createHash } from "node:crypto";
import { tmpdir } from "node:os";
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 canonicalize from "canonicalize";
@ -21,16 +21,25 @@ import {
readInvestigationWorkspace,
writeInvestigationWorkspace,
} 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 type { EvidenceFilePolicy } from "../src/domain/evidenceBundle.js";
import {
createInvestigationWorkspace,
crossVersionInvestigationInputSchema,
investigationRunSchema,
investigationRunSummarySchema,
parseInvestigationWorkspace,
serializeInvestigationWorkspace,
} 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 encoded = canonicalize(value);
@ -54,6 +63,41 @@ const policy = (root: string): EvidenceFilePolicy => ({
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 () => {
directory = await mkdtemp(join(tmpdir(), "rea-workspace-"));
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 () => {
const { path, input } = await fixture();
if (directory === undefined) throw new Error("missing fixture root");
@ -300,6 +573,26 @@ describe("persistent cross-version investigation workspace", () => {
),
).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(
{ ...input, workspace_path: resumePath },
policy(directory),

View File

@ -18,7 +18,13 @@ const dependencies = (
serve,
writeStderr: (text) => output.push(text),
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", () => {
@ -52,6 +58,7 @@ describe("MCP runtime errors", () => {
shutdown[0]?.();
await nextTurn();
expect(closeCalls).toBe(1);
expect(shutdown).toEqual([]);
});
it("reports transport startup failure without its cause", async () => {
@ -87,6 +94,7 @@ describe("MCP runtime errors", () => {
);
shutdown[0]?.();
await nextTurn();
expect(shutdown).toEqual([]);
expect(exitCodes).toEqual([1]);
expect(output).toEqual([
"REA could not close cleanly. End the REA process before starting it again.\n",

View File

@ -1,3 +1,4 @@
import { randomUUID } from "node:crypto";
import { mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
@ -19,7 +20,10 @@ import {
} from "../src/application/ProcessCaptureLifecycle.js";
import { ProcessCheckpoints } from "../src/application/ProcessCheckpoints.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 {
authorizeProcessScenario,
@ -37,6 +41,7 @@ const processFixture = fileURLToPath(
new URL("./fixtures/processFidelity.mjs", import.meta.url),
);
const execFileAsync = promisify(execFile);
const PROCESS_CAPTURE_INTEGRATION_TIMEOUT_MS = 20_000;
const emptyCapture = (): ProcessCapture => {
const normalization = {
@ -211,6 +216,43 @@ describe("process capture domain", () => {
).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", () => {
const baseCapture = emptyCapture();
const interaction = parseProcessCapture({
@ -839,95 +881,103 @@ describe("process capture adapter", () => {
}
});
it("renders terminal state, records shim invocations, and captures literal checkpoints", async () => {
const root = await mkdtemp(join(tmpdir(), "rea-v3-test-"));
const script = join(root, "scenario.mjs");
await writeFile(
script,
[
'import { spawnSync } from "node:child_process";',
'const result = spawnSync("codex", ["--version"], { encoding: "utf8" });',
'const node = spawnSync("node", ["--version"], { encoding: "utf8" });',
"process.stdout.write(`probe:${result.stdout}`);",
"process.stdout.write(`runtime:${node.stdout}`);",
].join("\n"),
);
try {
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,
},
it(
"renders terminal state, records shim invocations, and captures literal checkpoints",
async () => {
const root = await mkdtemp(join(tmpdir(), "rea-v3-test-"));
const script = join(root, "scenario.mjs");
await writeFile(
script,
[
'import { spawnSync } from "node:child_process";',
'const result = spawnSync("codex", ["--version"], { encoding: "utf8" });',
'const node = spawnSync("node", ["--version"], { encoding: "utf8" });',
"process.stdout.write(`probe:${result.stdout}`);",
"process.stdout.write(`runtime:${node.stdout}`);",
].join("\n"),
);
expect(result.ok).toBe(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 });
}
});
try {
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 },
},
],
},
],
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 () => {
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 () => {
const capability = await probeProcessCaptureCapability();
if (!capability.available) return;
const result = await captureProcessScenario(
parseProcessScenario({
approved: true,
executable: process.execPath,
arguments: [processFixture, "tree"],
working_directory: dirname(processFixture),
timeout_ms: 2_000,
idle_timeout_ms: 2_000,
}),
{
enabled: true,
executableRoots: [dirname(process.execPath)],
workingRoots: [dirname(processFixture)],
allowedEnvironment: [],
allowExternalNetwork: true,
},
);
expect(result.ok).toBe(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(
true,
);
expect(
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(`${processFixture} tree-child`);
expect(stdout).not.toContain(`${processFixture} tree-grandchild`);
});
it(
"samples and cleans a source-owned child and grandchild process tree",
async () => {
const capability = await probeProcessCaptureCapability();
if (!capability.available) return;
const runMarker = `rea-tree-${randomUUID()}`;
const result = await captureProcessScenario(
parseProcessScenario({
approved: true,
executable: process.execPath,
arguments: [processFixture, "tree", runMarker],
working_directory: dirname(processFixture),
timeout_ms: 5_000,
idle_timeout_ms: 5_000,
}),
{
enabled: true,
executableRoots: [dirname(process.execPath)],
workingRoots: [dirname(processFixture)],
allowedEnvironment: [],
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(
true,
);
expect(commands.some((command) => command.includes("forks.js"))).toBe(
false,
);
expect(
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,
);
});

View File

@ -1,6 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import {
cleanupOwnedProcessGroup,
observeOwnedProcessGroup,
parseProcessEnvironment,
type ProcessOwnershipHost,
} from "../src/application/ProcessOwnership.js";
@ -26,6 +27,7 @@ const host = (
pid: Number(pid),
parentPid: Number(pid) === 100 ? 1 : 100,
processGroupId: 100,
state: "S",
command: "fixture",
})),
),
@ -63,10 +65,108 @@ describe("owned process-group cleanup", () => {
expect(await cleanupOwnedProcessGroup(ownership, adapter)).toEqual({
cleaned: false,
reason: "process group contains an unowned or PID-reused process",
failures: [{ pid: 101, reason: "run-token-mismatch" }],
});
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 () => {
const { adapter } = host({});
expect(await cleanupOwnedProcessGroup(ownership, adapter)).toEqual({

View File

@ -192,6 +192,23 @@ describe("historical source graph", () => {
).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", () => {
expect(() =>
createHistoricalSourceGraph({ ...graphInput(), surprise: true }),

View File

@ -37,7 +37,7 @@ describe("production stdio runtime", () => {
name: "current_document",
arguments: {},
});
expect(result.isError).not.toBe(true);
expect(result.isError === true).toBe(process.platform === "linux");
} finally {
await client.close();
await transport.close();
@ -53,7 +53,7 @@ describe("production stdio runtime", () => {
mode: "mcp",
layer: "server",
tool: "current_document",
status: "ok",
status: process.platform === "linux" ? "error" : "ok",
}),
);
expect(stderr).not.toContain("HOPPER_LOADER_ARGS_JSON");

View File

@ -19,9 +19,11 @@ class FakeSetupHost implements SetupHost {
hopper: string | undefined;
hopperInstallSucceeds = true;
skill: "installed" | "unchanged" | "failed" = "installed";
skillNeedsInstallation = true;
clients: readonly SetupClient[] = [];
clientResults = new Map<string, ClientConfigurationResult>();
hopperInstalls = 0;
skillInstallations = 0;
configurations = 0;
doctorHealthy: boolean | undefined;
linuxDemoRuntimeMissing = false;
@ -62,8 +64,12 @@ class FakeSetupHost implements SetupHost {
this.clientResults.get(client.name) ?? { status: "configured" },
);
};
installSkill = (): Promise<"installed" | "unchanged" | "failed"> =>
Promise.resolve(this.skill);
skillInstallationNeeded = (): Promise<boolean> =>
Promise.resolve(this.skillNeedsInstallation);
installSkill = (): Promise<"installed" | "unchanged" | "failed"> => {
this.skillInstallations += 1;
return Promise.resolve(this.skill);
};
doctor = (): Promise<{
healthy: boolean;
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 () => {
const host = new FakeSetupHost("linux");
host.distribution = {
@ -218,6 +239,19 @@ describe("setup workflow", () => {
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 () => {
const host = new FakeSetupHost("linux");
host.distribution = {
@ -297,6 +331,34 @@ describe("setup workflow", () => {
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 () => {
const host = new FakeSetupHost();
host.hopper = "/Applications/Hopper";

View File

@ -3,6 +3,7 @@ import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { installCanonicalSkill } from "../src/application/Setup.js";
import { canonicalSkillInstallationNeeded } from "../src/application/SetupSkill.js";
import { TOOL_CONTRACTS } from "../src/contracts/toolContracts.js";
const roots: string[] = [];
@ -32,6 +33,18 @@ describe("canonical skill transaction", () => {
`tool_count: ${String(TOOL_CONTRACTS.length)}`,
);
expect(await readFile(sibling, "utf8")).toBe("unrelated skill\n");
expect(await canonicalSkillInstallationNeeded(home)).toBe(false);
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);
});
});

View File

@ -1,3 +1,6 @@
import { tmpdir } from "node:os";
import { join } from "node:path";
import { defineConfig } from "vitest/config";
export default defineConfig({
@ -8,6 +11,10 @@ export default defineConfig({
coverage: {
enabled: true,
provider: "v8",
reportsDirectory: join(
tmpdir(),
`rea-vitest-coverage-${String(process.pid)}`,
),
include: ["src/**"],
thresholds: {
statements: 65,