Compare commits
12 Commits
agent/java
...
agent/tria
| Author | SHA1 | Date |
|---|---|---|
|
|
e67d77c8c9 | |
|
|
e27f635b73 | |
|
|
000d4f5e56 | |
|
|
6cdaf8f2b3 | |
|
|
da63a72d99 | |
|
|
700d13659f | |
|
|
8c2932e403 | |
|
|
22b9dff3a0 | |
|
|
eb9e8a1610 | |
|
|
b24a9983db | |
|
|
b5dc7b70ec | |
|
|
c240ae19c6 |
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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(" ");
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -328,6 +328,7 @@ export class CdpBrowserProvider implements BrowserObservationPort {
|
|||
return await capturePage({
|
||||
connection: targetSession.connection,
|
||||
sessionId: targetSession.sessionId,
|
||||
operation,
|
||||
discovery,
|
||||
target,
|
||||
input,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 = [];
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 }) =>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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" }),
|
||||
});
|
||||
|
|
|
|||
16
src/main.ts
16
src/main.ts
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
|
|
@ -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",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue