rea-agents 1.2.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +74 -27
- package/bridge/hopper_bridge.py +161 -12
- package/dist/application/AnalysisSnapshotCache.js +131 -0
- package/dist/application/AnalysisSnapshotFiles.js +31 -0
- package/dist/application/ArtifactInventory.js +4 -0
- package/dist/application/AuthorizedArtifactInventory.js +16 -0
- package/dist/application/BinarySession.js +66 -36
- package/dist/application/BinarySessionPort.js +1 -0
- package/dist/application/BoundedJsonFiles.js +109 -0
- package/dist/application/CrossVersionInventory.js +98 -0
- package/dist/application/CrossVersionInvestigation.js +295 -0
- package/dist/application/DirectAnalysis.js +115 -22
- package/dist/application/Doctor.js +30 -1
- package/dist/application/EvidenceBundleFiles.js +15 -108
- package/dist/application/EvidenceLedger.js +3 -2
- package/dist/application/InvestigationProviders.js +48 -0
- package/dist/application/InvestigationWorkspaceStore.js +214 -0
- package/dist/application/LinuxHopper.js +72 -48
- package/dist/application/ProcessCaptureAuthority.js +2 -2
- package/dist/application/ProcessCaptureError.js +12 -0
- package/dist/application/ProcessCaptureLifecycle.js +19 -1
- package/dist/application/ProcessCli.js +98 -31
- package/dist/application/ProcessHarness.js +2 -2
- package/dist/application/ReferenceSourceImportEntries.js +18 -3
- package/dist/application/ReferenceSourceImportTypes.js +32 -0
- package/dist/application/Setup.js +33 -60
- package/dist/application/SetupClients.js +19 -0
- package/dist/application/SetupInstallFailure.js +53 -0
- package/dist/application/SetupPlan.js +31 -0
- package/dist/application/Uninstall.js +33 -23
- package/dist/application/UnknownEvidence.js +33 -0
- package/dist/application/Upgrade.js +1 -1
- package/dist/application/runtime.js +1 -1
- package/dist/artifacts/ArtifactProvider.js +2 -5
- package/dist/cli.js +31 -11
- package/dist/cliEvidenceCommands.js +11 -12
- package/dist/cliInvestigationCommands.js +86 -0
- package/dist/cliOutput.js +41 -0
- package/dist/cliProcessCommands.js +14 -4
- package/dist/config.js +39 -24
- package/dist/contracts/promptContracts.js +256 -0
- package/dist/contracts/sessionLifecycleInputs.js +11 -0
- package/dist/contracts/toolContractTypes.js +1 -0
- package/dist/contracts/toolContracts.js +10 -6
- package/dist/contracts/toolOutputSchemas.js +33 -4
- package/dist/domain/analysisSnapshot.js +149 -0
- package/dist/domain/changedBehavior.js +30 -7
- package/dist/domain/errors.js +180 -41
- package/dist/domain/evidence.js +4 -2
- package/dist/domain/evidenceBundle.js +28 -0
- package/dist/domain/hopperStartupFailure.js +55 -0
- package/dist/domain/investigationWorkspace.js +214 -0
- package/dist/domain/jsonValue.js +2 -0
- package/dist/domain/nativeInspection.js +3 -2
- package/dist/domain/processCapture.js +5 -4
- package/dist/domain/processComparison.js +5 -4
- package/dist/hopper/BridgeLauncher.js +47 -20
- package/dist/hopper/HopperClient.js +17 -2
- package/dist/hopper/HopperProvider.js +1 -0
- package/dist/main.js +47 -24
- package/dist/server/createServer.js +2 -0
- package/dist/server/promptCompletion.js +144 -0
- package/dist/server/registerEnhancedTools.js +2 -6
- package/dist/server/registerEvidenceTools.js +2 -8
- package/dist/server/registerFunctionComparisonTool.js +1 -1
- package/dist/server/registerInvestigationTools.js +31 -11
- package/dist/server/registerOfficialTools.js +6 -13
- package/dist/server/registerProcessComparisonTool.js +1 -7
- package/dist/server/registerPrompts.js +67 -0
- package/dist/server/registerSessionStatusTool.js +11 -0
- package/dist/server/registerSessionTools.js +52 -18
- package/dist/server/sessionEvidence.js +2 -8
- package/dist/server/sessionToolPolicies.js +1 -42
- package/dist/server/toolResult.js +7 -3
- package/install.sh +11 -11
- package/package.json +5 -3
- package/scripts/hopper-demo-x11.py +349 -0
- package/scripts/rea.mjs +6 -3
- package/skills/rea-analysis/SKILL.md +9 -2
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import canonicalize from "canonicalize";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { evidenceBundleSchema, parseEvidenceBundle, } from "./evidenceBundle.js";
|
|
5
|
+
const digestSchema = z.string().regex(/^[a-f0-9]{64}$/u);
|
|
6
|
+
const evidenceIdSchema = z.string().regex(/^ev_[a-f0-9]{64}$/u);
|
|
7
|
+
const artifactFormatSchema = evidenceBundleSchema.shape.artifacts.element.shape.format;
|
|
8
|
+
/** Bounded controls for one automatic, provider-neutral artifact comparison. */
|
|
9
|
+
const investigationRunOptionsSchema = z.object({
|
|
10
|
+
max_entries: z.number().int().min(1).max(50_000).default(10_000),
|
|
11
|
+
max_total_bytes: z
|
|
12
|
+
.number()
|
|
13
|
+
.int()
|
|
14
|
+
.min(1)
|
|
15
|
+
.max(Number.MAX_SAFE_INTEGER)
|
|
16
|
+
.default(1_073_741_824),
|
|
17
|
+
max_entry_bytes: z
|
|
18
|
+
.number()
|
|
19
|
+
.int()
|
|
20
|
+
.min(1)
|
|
21
|
+
.max(Number.MAX_SAFE_INTEGER)
|
|
22
|
+
.default(268_435_456),
|
|
23
|
+
max_compression_ratio: z.number().min(1).max(100_000).default(1_000),
|
|
24
|
+
max_depth: z.number().int().min(0).max(100).default(20),
|
|
25
|
+
max_path_bytes: z.number().int().min(1).max(65_535).default(4_096),
|
|
26
|
+
page_size: z.number().int().min(1).max(500).default(500),
|
|
27
|
+
change_limit: z.number().int().min(1).max(500).default(500),
|
|
28
|
+
});
|
|
29
|
+
const DEFAULT_INVESTIGATION_RUN_OPTIONS = {
|
|
30
|
+
max_entries: 10_000,
|
|
31
|
+
max_total_bytes: 1_073_741_824,
|
|
32
|
+
max_entry_bytes: 268_435_456,
|
|
33
|
+
max_compression_ratio: 1_000,
|
|
34
|
+
max_depth: 20,
|
|
35
|
+
max_path_bytes: 4_096,
|
|
36
|
+
page_size: 500,
|
|
37
|
+
change_limit: 500,
|
|
38
|
+
};
|
|
39
|
+
/** Explicit write approval and two local versions for an automatic run. */
|
|
40
|
+
export const crossVersionInvestigationInputSchema = z.object({
|
|
41
|
+
approved: z.literal(true),
|
|
42
|
+
workspace_path: z.string().min(1).max(4_096),
|
|
43
|
+
workspace_name: z.string().trim().min(1).max(200).default("default"),
|
|
44
|
+
expected_workspace_revision: z.number().int().min(1).optional(),
|
|
45
|
+
left_path: z.string().min(1).max(4_096),
|
|
46
|
+
right_path: z.string().min(1).max(4_096),
|
|
47
|
+
options: investigationRunOptionsSchema.default(DEFAULT_INVESTIGATION_RUN_OPTIONS),
|
|
48
|
+
});
|
|
49
|
+
const targetSchema = z.object({
|
|
50
|
+
root_sha256: digestSchema,
|
|
51
|
+
graph_sha256: digestSchema,
|
|
52
|
+
manifest_id: z.string().regex(/^agm_[a-f0-9]{64}$/u),
|
|
53
|
+
format: artifactFormatSchema,
|
|
54
|
+
});
|
|
55
|
+
const stageSchema = z.enum([
|
|
56
|
+
"inventory_left",
|
|
57
|
+
"inventory_right",
|
|
58
|
+
"compare_artifacts",
|
|
59
|
+
"find_changed_behavior",
|
|
60
|
+
]);
|
|
61
|
+
export const investigationRunSchema = z.object({
|
|
62
|
+
schema_version: z.literal(1),
|
|
63
|
+
run_id: z.string().regex(/^run_[a-f0-9]{64}$/u),
|
|
64
|
+
request_sha256: digestSchema,
|
|
65
|
+
left: targetSchema,
|
|
66
|
+
right: targetSchema,
|
|
67
|
+
options: investigationRunOptionsSchema,
|
|
68
|
+
status: z.enum(["running", "complete"]),
|
|
69
|
+
completed_stages: z.array(stageSchema).max(4),
|
|
70
|
+
left_inventory_evidence_ids: z.array(evidenceIdSchema).min(1).max(100),
|
|
71
|
+
right_inventory_evidence_ids: z.array(evidenceIdSchema).min(1).max(100),
|
|
72
|
+
comparison_evidence_id: evidenceIdSchema.nullable(),
|
|
73
|
+
result_evidence_id: evidenceIdSchema.nullable(),
|
|
74
|
+
limitations: z.array(z.string().max(4_096)).max(100),
|
|
75
|
+
});
|
|
76
|
+
export const investigationRunSummarySchema = z.object({
|
|
77
|
+
schema_version: z.literal(1),
|
|
78
|
+
workspace_id: z.string().regex(/^ws_[a-f0-9]{64}$/u),
|
|
79
|
+
run_id: z.string().regex(/^run_[a-f0-9]{64}$/u),
|
|
80
|
+
left_manifest_id: z.string().regex(/^agm_[a-f0-9]{64}$/u),
|
|
81
|
+
right_manifest_id: z.string().regex(/^agm_[a-f0-9]{64}$/u),
|
|
82
|
+
inventory_evidence_count: z.number().int().min(2).max(200),
|
|
83
|
+
comparison_evidence_id: evidenceIdSchema,
|
|
84
|
+
limitations: z.array(z.string().max(4_096)).max(100),
|
|
85
|
+
});
|
|
86
|
+
const investigationWorkspaceSchema = z.object({
|
|
87
|
+
workspace_version: z.literal(1),
|
|
88
|
+
workspace_id: z.string().regex(/^ws_[a-f0-9]{64}$/u),
|
|
89
|
+
name: z.string().trim().min(1).max(200),
|
|
90
|
+
revision: z.number().int().min(1),
|
|
91
|
+
previous_revision_digest: z
|
|
92
|
+
.string()
|
|
93
|
+
.regex(/^wrev_[a-f0-9]{64}$/u)
|
|
94
|
+
.nullable(),
|
|
95
|
+
revision_digest: z.string().regex(/^wrev_[a-f0-9]{64}$/u),
|
|
96
|
+
bundle: evidenceBundleSchema,
|
|
97
|
+
runs: z.array(investigationRunSchema).max(1_000),
|
|
98
|
+
});
|
|
99
|
+
/** Derive a stable run identity from content commitments and bounded controls. */
|
|
100
|
+
export const createInvestigationRunIdentity = (input) => {
|
|
101
|
+
const requestSha256 = digestCanonical({
|
|
102
|
+
schema: "rea.cross-version-investigation-request/v1",
|
|
103
|
+
left: input.left,
|
|
104
|
+
right: input.right,
|
|
105
|
+
options: input.options,
|
|
106
|
+
});
|
|
107
|
+
return { runId: `run_${requestSha256}`, requestSha256 };
|
|
108
|
+
};
|
|
109
|
+
/** Build the first canonical workspace revision. */
|
|
110
|
+
export const createInvestigationWorkspace = (name, bundle, runs) => buildWorkspace({
|
|
111
|
+
workspaceId: `ws_${digestCanonical({ schema: "rea.workspace/v1", name })}`,
|
|
112
|
+
name,
|
|
113
|
+
revision: 1,
|
|
114
|
+
previousRevisionDigest: null,
|
|
115
|
+
bundle,
|
|
116
|
+
runs,
|
|
117
|
+
});
|
|
118
|
+
/** Create the next immutable workspace revision. */
|
|
119
|
+
export const reviseInvestigationWorkspace = (current, bundle, runs) => buildWorkspace({
|
|
120
|
+
workspaceId: current.workspace_id,
|
|
121
|
+
name: current.name,
|
|
122
|
+
revision: current.revision + 1,
|
|
123
|
+
previousRevisionDigest: current.revision_digest,
|
|
124
|
+
bundle,
|
|
125
|
+
runs,
|
|
126
|
+
});
|
|
127
|
+
/** Parse a workspace and verify identities, revision digest, and references. */
|
|
128
|
+
export const parseInvestigationWorkspace = (input) => {
|
|
129
|
+
const parsed = investigationWorkspaceSchema.parse(input);
|
|
130
|
+
const bundle = parseEvidenceBundle(parsed.bundle);
|
|
131
|
+
const canonical = buildWorkspace({
|
|
132
|
+
workspaceId: parsed.workspace_id,
|
|
133
|
+
name: parsed.name,
|
|
134
|
+
revision: parsed.revision,
|
|
135
|
+
previousRevisionDigest: parsed.previous_revision_digest,
|
|
136
|
+
bundle,
|
|
137
|
+
runs: parsed.runs,
|
|
138
|
+
});
|
|
139
|
+
if (parsed.workspace_id !== workspaceIdFor(parsed.name))
|
|
140
|
+
throw new TypeError("Investigation workspace ID does not match its name");
|
|
141
|
+
if (parsed.revision_digest !== canonical.revision_digest)
|
|
142
|
+
throw new TypeError("Investigation workspace revision digest is invalid");
|
|
143
|
+
if (JSON.stringify(parsed) !== JSON.stringify(canonical))
|
|
144
|
+
throw new TypeError("Investigation workspace is not canonical");
|
|
145
|
+
validateRunReferences(canonical);
|
|
146
|
+
return canonical;
|
|
147
|
+
};
|
|
148
|
+
/** Encode a validated workspace as byte-stable canonical JSON. */
|
|
149
|
+
export const serializeInvestigationWorkspace = (workspace) => canonicalJson(parseInvestigationWorkspace(workspace));
|
|
150
|
+
const buildWorkspace = (input) => {
|
|
151
|
+
const runs = [...input.runs]
|
|
152
|
+
.map((run) => investigationRunSchema.parse(run))
|
|
153
|
+
.sort((left, right) => left.run_id.localeCompare(right.run_id));
|
|
154
|
+
assertUnique(runs.map(({ run_id: id }) => id), "investigation run ID");
|
|
155
|
+
const semantic = {
|
|
156
|
+
workspace_version: 1,
|
|
157
|
+
workspace_id: input.workspaceId,
|
|
158
|
+
name: input.name,
|
|
159
|
+
revision: input.revision,
|
|
160
|
+
previous_revision_digest: input.previousRevisionDigest,
|
|
161
|
+
bundle: parseEvidenceBundle(input.bundle),
|
|
162
|
+
runs,
|
|
163
|
+
};
|
|
164
|
+
return investigationWorkspaceSchema.parse({
|
|
165
|
+
...semantic,
|
|
166
|
+
revision_digest: `wrev_${digestCanonical(semantic)}`,
|
|
167
|
+
});
|
|
168
|
+
};
|
|
169
|
+
const validateRunReferences = (workspace) => {
|
|
170
|
+
const evidenceIds = new Set(workspace.bundle.records.map(({ evidence_id: id }) => id));
|
|
171
|
+
for (const run of workspace.runs) {
|
|
172
|
+
const expectedIdentity = createInvestigationRunIdentity(run);
|
|
173
|
+
if (run.run_id !== expectedIdentity.runId ||
|
|
174
|
+
run.request_sha256 !== expectedIdentity.requestSha256)
|
|
175
|
+
throw new TypeError("Investigation run identity is invalid");
|
|
176
|
+
validateRunStages(run);
|
|
177
|
+
const references = [
|
|
178
|
+
...run.left_inventory_evidence_ids,
|
|
179
|
+
...run.right_inventory_evidence_ids,
|
|
180
|
+
...(run.comparison_evidence_id === null
|
|
181
|
+
? []
|
|
182
|
+
: [run.comparison_evidence_id]),
|
|
183
|
+
...(run.result_evidence_id === null ? [] : [run.result_evidence_id]),
|
|
184
|
+
];
|
|
185
|
+
if (references.some((evidenceId) => !evidenceIds.has(evidenceId)))
|
|
186
|
+
throw new TypeError("Investigation run references missing Evidence");
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
const validateRunStages = (run) => {
|
|
190
|
+
const expected = [
|
|
191
|
+
"inventory_left",
|
|
192
|
+
"inventory_right",
|
|
193
|
+
...(run.comparison_evidence_id === null ? [] : ["compare_artifacts"]),
|
|
194
|
+
...(run.result_evidence_id === null ? [] : ["find_changed_behavior"]),
|
|
195
|
+
];
|
|
196
|
+
if (JSON.stringify(run.completed_stages) !== JSON.stringify(expected))
|
|
197
|
+
throw new TypeError("Investigation run stages are inconsistent");
|
|
198
|
+
if (run.status === "complete" && run.result_evidence_id === null)
|
|
199
|
+
throw new TypeError("Completed investigation run has no result Evidence");
|
|
200
|
+
if (run.status === "running" && run.result_evidence_id !== null)
|
|
201
|
+
throw new TypeError("Running investigation run has final result Evidence");
|
|
202
|
+
};
|
|
203
|
+
const workspaceIdFor = (name) => `ws_${digestCanonical({ schema: "rea.workspace/v1", name })}`;
|
|
204
|
+
const digestCanonical = (value) => createHash("sha256").update(canonicalJson(value)).digest("hex");
|
|
205
|
+
const canonicalJson = (value) => {
|
|
206
|
+
const encoded = canonicalize(value);
|
|
207
|
+
if (encoded === undefined)
|
|
208
|
+
throw new TypeError("Investigation workspace could not canonicalize data");
|
|
209
|
+
return encoded;
|
|
210
|
+
};
|
|
211
|
+
const assertUnique = (values, description) => {
|
|
212
|
+
if (new Set(values).size !== values.length)
|
|
213
|
+
throw new TypeError(`Duplicate ${description}`);
|
|
214
|
+
};
|
package/dist/domain/jsonValue.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
/** JSON-safe value shared by domain, application, and adapter boundaries. */
|
|
3
3
|
export const jsonValueSchema = z.json();
|
|
4
|
+
/** JSON object boundary for caller-visible parameter maps. */
|
|
5
|
+
export const jsonObjectSchema = z.record(z.string(), jsonValueSchema);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { jsonValueSchema } from "./jsonValue.js";
|
|
2
3
|
const hexAddress = z.string().regex(/^0x[a-fA-F0-9]+$/u);
|
|
3
4
|
const coverage = (item) => z.object({
|
|
4
5
|
items: z.array(item),
|
|
@@ -106,7 +107,7 @@ export const inspectSignatureSchema = z.object({
|
|
|
106
107
|
hash_algorithms: z.array(z.string()),
|
|
107
108
|
authorities: z.array(z.string()),
|
|
108
109
|
designated_requirement: z.string().nullable(),
|
|
109
|
-
entitlements:
|
|
110
|
+
entitlements: jsonValueSchema.nullable(),
|
|
110
111
|
timestamp: z.string().nullable(),
|
|
111
112
|
hardened_runtime: z.boolean().nullable(),
|
|
112
113
|
provenance: z.array(nativeCommandInvocationSchema),
|
|
@@ -114,7 +115,7 @@ export const inspectSignatureSchema = z.object({
|
|
|
114
115
|
});
|
|
115
116
|
export const inspectPlistSchema = z.object({
|
|
116
117
|
format: z.enum(["xml", "binary", "json", "unknown"]),
|
|
117
|
-
value:
|
|
118
|
+
value: jsonValueSchema,
|
|
118
119
|
bundle: z.object({
|
|
119
120
|
identifier: z.string().nullable(),
|
|
120
121
|
executable: z.string().nullable(),
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { jsonValueSchema } from "./jsonValue.js";
|
|
2
3
|
import { LEGACY_PROCESS_CAPTURE_MESSAGE, normalizationSchema, } from "./processScenario.js";
|
|
3
4
|
export * from "./processScenario.js";
|
|
4
5
|
const fileStateSchema = z.object({
|
|
@@ -31,10 +32,10 @@ export const processCaptureSchema = z.object({
|
|
|
31
32
|
pty_backend: z.literal("node-pty"),
|
|
32
33
|
started_at: z.iso.datetime(),
|
|
33
34
|
completed_at: z.iso.datetime(),
|
|
34
|
-
scenario: z.record(z.string(),
|
|
35
|
-
comparison_contract: z.record(z.string(),
|
|
36
|
-
shim_plan: z.array(
|
|
37
|
-
replay_plan: z.record(z.string(),
|
|
35
|
+
scenario: z.record(z.string(), jsonValueSchema),
|
|
36
|
+
comparison_contract: z.record(z.string(), jsonValueSchema),
|
|
37
|
+
shim_plan: z.array(jsonValueSchema),
|
|
38
|
+
replay_plan: z.record(z.string(), jsonValueSchema),
|
|
38
39
|
full_scenario_sha256: z.string().regex(/^[a-f0-9]{64}$/u),
|
|
39
40
|
comparison_contract_sha256: z.string().regex(/^[a-f0-9]{64}$/u),
|
|
40
41
|
executable_sha256: z.string().regex(/^[a-f0-9]{64}$/u),
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { validateProcessCapture } from "./processCapture.js";
|
|
3
|
+
import { jsonValueSchema } from "./jsonValue.js";
|
|
3
4
|
/** Comparison classification that never equates incomplete evidence. */
|
|
4
5
|
export const comparisonStatusSchema = z.enum([
|
|
5
6
|
"unchanged",
|
|
@@ -37,8 +38,8 @@ export const processCaptureComparisonSchema = z
|
|
|
37
38
|
index: z.number().int().nonnegative(),
|
|
38
39
|
left_at_ms: z.number().int().nonnegative().nullable(),
|
|
39
40
|
right_at_ms: z.number().int().nonnegative().nullable(),
|
|
40
|
-
left:
|
|
41
|
-
right:
|
|
41
|
+
left: jsonValueSchema.nullable(),
|
|
42
|
+
right: jsonValueSchema.nullable(),
|
|
42
43
|
}),
|
|
43
44
|
]),
|
|
44
45
|
limitations: z.array(z.string()),
|
|
@@ -127,8 +128,8 @@ const firstCollectionDivergence = (dimension, left, right) => {
|
|
|
127
128
|
index,
|
|
128
129
|
left_at_ms: eventTime(leftValue),
|
|
129
130
|
right_at_ms: eventTime(rightValue),
|
|
130
|
-
left: leftValue,
|
|
131
|
-
right: rightValue,
|
|
131
|
+
left: jsonValueSchema.parse(leftValue),
|
|
132
|
+
right: jsonValueSchema.parse(rightValue),
|
|
132
133
|
};
|
|
133
134
|
}
|
|
134
135
|
return null;
|
|
@@ -34,6 +34,7 @@ export class HopperApplicationLauncher {
|
|
|
34
34
|
`REA_SOCKET = ${JSON.stringify(session.socketPath)}`,
|
|
35
35
|
`REA_TOKEN = ${JSON.stringify(session.token)}`,
|
|
36
36
|
`REA_RUN_ID = ${JSON.stringify(session.runId)}`,
|
|
37
|
+
`REA_TARGET_PATH = ${JSON.stringify(this.options.targetPath)}`,
|
|
37
38
|
`exec(compile(open(${JSON.stringify(this.options.bridgeScriptPath)}, 'rb').read(), ${JSON.stringify(this.options.bridgeScriptPath)}, 'exec'))`,
|
|
38
39
|
"",
|
|
39
40
|
].join("\n");
|
|
@@ -59,25 +60,12 @@ export class HopperApplicationLauncher {
|
|
|
59
60
|
const prepared = await prepareHopperApplication(this.options.launcherPath, options.signal);
|
|
60
61
|
if (!prepared)
|
|
61
62
|
return err(new HopperCancelledError());
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
env: { ...process.env, REA_PROCESS_RUN_ID: session.runId },
|
|
66
|
-
});
|
|
67
|
-
const started = await new Promise((resolve) => {
|
|
68
|
-
const onSpawn = () => {
|
|
69
|
-
child.off("error", onError);
|
|
70
|
-
resolve(ok(undefined));
|
|
71
|
-
};
|
|
72
|
-
const onError = (cause) => {
|
|
73
|
-
child.off("spawn", onSpawn);
|
|
74
|
-
resolve(err(new HopperStartError({ cause })));
|
|
75
|
-
};
|
|
76
|
-
child.once("spawn", onSpawn);
|
|
77
|
-
child.once("error", onError);
|
|
78
|
-
});
|
|
63
|
+
const linuxDemo = linuxDemoLaunch(this.options, session, args);
|
|
64
|
+
const ownershipCommand = linuxDemo?.ownershipCommand ?? this.options.launcherPath;
|
|
65
|
+
const started = await spawnLauncher(linuxDemo?.command ?? this.options.launcherPath, linuxDemo?.args ?? args, session.runId);
|
|
79
66
|
if (!started.ok)
|
|
80
67
|
return started;
|
|
68
|
+
const child = started.value;
|
|
81
69
|
const pid = child.pid;
|
|
82
70
|
if (pid === undefined)
|
|
83
71
|
return err(new HopperStartError({ cause: new Error("missing launcher PID") }));
|
|
@@ -87,20 +75,20 @@ export class HopperApplicationLauncher {
|
|
|
87
75
|
pid,
|
|
88
76
|
process_group_id: pid,
|
|
89
77
|
parent_pid: process.pid,
|
|
90
|
-
launcher: this.options.launcherPath,
|
|
78
|
+
launcher: linuxDemo?.command ?? this.options.launcherPath,
|
|
91
79
|
created_at: new Date().toISOString(),
|
|
92
80
|
};
|
|
93
81
|
try {
|
|
94
82
|
await writeFileAtomic(join(session.directory, "ownership.json"), `${JSON.stringify(ownership)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
95
83
|
}
|
|
96
84
|
catch (cause) {
|
|
97
|
-
await cleanupOwnedProcessGroup(ownedProcessGroup(session, pid,
|
|
85
|
+
await cleanupOwnedProcessGroup(ownedProcessGroup(session, pid, ownershipCommand));
|
|
98
86
|
return err(new HopperStartError({ cause }));
|
|
99
87
|
}
|
|
100
88
|
return ok({
|
|
101
89
|
process: child,
|
|
102
90
|
ownsProcessLifetime: true,
|
|
103
|
-
cleanup: () => cleanupOwnedProcessGroup(ownedProcessGroup(session, pid,
|
|
91
|
+
cleanup: () => cleanupOwnedProcessGroup(ownedProcessGroup(session, pid, ownershipCommand)),
|
|
104
92
|
});
|
|
105
93
|
}
|
|
106
94
|
catch (cause) {
|
|
@@ -108,6 +96,45 @@ export class HopperApplicationLauncher {
|
|
|
108
96
|
}
|
|
109
97
|
}
|
|
110
98
|
}
|
|
99
|
+
const linuxDemoLaunch = (options, session, hopperArgs) => {
|
|
100
|
+
if (process.platform !== "linux" ||
|
|
101
|
+
basename(options.launcherPath) !== "Hopper" ||
|
|
102
|
+
options.demoHelperPath === undefined)
|
|
103
|
+
return undefined;
|
|
104
|
+
return {
|
|
105
|
+
command: "/usr/bin/python3",
|
|
106
|
+
ownershipCommand: "/usr/bin/python3",
|
|
107
|
+
args: [
|
|
108
|
+
options.demoHelperPath,
|
|
109
|
+
"--hopper",
|
|
110
|
+
options.launcherPath,
|
|
111
|
+
"--socket",
|
|
112
|
+
session.socketPath,
|
|
113
|
+
"--",
|
|
114
|
+
options.launcherPath,
|
|
115
|
+
...hopperArgs,
|
|
116
|
+
],
|
|
117
|
+
};
|
|
118
|
+
};
|
|
119
|
+
const spawnLauncher = async (command, args, runId) => {
|
|
120
|
+
const child = spawn(command, args, {
|
|
121
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
122
|
+
detached: process.platform !== "win32",
|
|
123
|
+
env: { ...process.env, REA_PROCESS_RUN_ID: runId },
|
|
124
|
+
});
|
|
125
|
+
return new Promise((resolve) => {
|
|
126
|
+
const onSpawn = () => {
|
|
127
|
+
child.off("error", onError);
|
|
128
|
+
resolve(ok(child));
|
|
129
|
+
};
|
|
130
|
+
const onError = (cause) => {
|
|
131
|
+
child.off("spawn", onSpawn);
|
|
132
|
+
resolve(err(new HopperStartError({ cause })));
|
|
133
|
+
};
|
|
134
|
+
child.once("spawn", onSpawn);
|
|
135
|
+
child.once("error", onError);
|
|
136
|
+
});
|
|
137
|
+
};
|
|
111
138
|
const ownedProcessGroup = (session, pid, launcherPath) => ({
|
|
112
139
|
runId: session.runId,
|
|
113
140
|
leaderPid: pid,
|
|
@@ -3,11 +3,12 @@ import { access, mkdtemp, rm } from "node:fs/promises";
|
|
|
3
3
|
import { createConnection } from "node:net";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
|
-
import { HopperCancelledError, HopperProcessError, HopperProtocolError, HopperStartError, HopperTimeoutError, } from "../domain/errors.js";
|
|
6
|
+
import { HopperCancelledError, HopperProcessError, HopperProtocolError, HopperStartError, hopperStartupFailure, HopperTimeoutError, } from "../domain/errors.js";
|
|
7
7
|
import { err, ok } from "../domain/result.js";
|
|
8
8
|
import { silentLogger } from "../logger.js";
|
|
9
9
|
import { parseResponseLine, responseResult } from "./protocol.js";
|
|
10
10
|
const MAX_LINE_BYTES = 10 * 1024 * 1024;
|
|
11
|
+
const SHUTDOWN_TIMEOUT_MS = 30_000;
|
|
11
12
|
const SESSION_ROOT = process.platform === "darwin" ? "/tmp" : tmpdir();
|
|
12
13
|
/**
|
|
13
14
|
* Owns one authenticated NDJSON-over-Unix-socket bridge session.
|
|
@@ -28,6 +29,7 @@ export class HopperClient {
|
|
|
28
29
|
#nextId = 1;
|
|
29
30
|
#buffer = "";
|
|
30
31
|
#closing = false;
|
|
32
|
+
#launcherExitCode;
|
|
31
33
|
#startPromise;
|
|
32
34
|
#closePromise;
|
|
33
35
|
constructor(options) {
|
|
@@ -57,6 +59,7 @@ export class HopperClient {
|
|
|
57
59
|
}
|
|
58
60
|
const socketPath = join(this.#directory, "bridge.sock");
|
|
59
61
|
this.#token = randomBytes(32).toString("hex");
|
|
62
|
+
this.#launcherExitCode = undefined;
|
|
60
63
|
const runId = randomUUID();
|
|
61
64
|
const launched = await this.#options.launcher.launch({
|
|
62
65
|
directory: this.#directory,
|
|
@@ -105,7 +108,9 @@ export class HopperClient {
|
|
|
105
108
|
try {
|
|
106
109
|
const socket = this.#socket;
|
|
107
110
|
if (socket !== undefined && !socket.destroyed) {
|
|
108
|
-
await this.#request("shutdown", {}, { timeoutMs:
|
|
111
|
+
const shutdown = await this.#request("shutdown", {}, { timeoutMs: SHUTDOWN_TIMEOUT_MS }).catch(() => err(new HopperProcessError(null)));
|
|
112
|
+
if (!shutdown.ok || !isShutdownAcknowledgement(shutdown.value))
|
|
113
|
+
this.#logger.warn({ status: shutdown.ok ? "invalid-acknowledgement" : "failed" }, "Hopper document shutdown was not confirmed");
|
|
109
114
|
}
|
|
110
115
|
this.#failAll(new HopperProcessError(null));
|
|
111
116
|
socket?.destroy();
|
|
@@ -139,6 +144,9 @@ export class HopperClient {
|
|
|
139
144
|
return err(new HopperCancelledError());
|
|
140
145
|
if (this.#closing)
|
|
141
146
|
return err(new HopperProcessError(null));
|
|
147
|
+
if (this.#launcherExitCode !== undefined &&
|
|
148
|
+
hopperStartupFailure(this.#launcherExitCode) !== undefined)
|
|
149
|
+
return err(new HopperProcessError(this.#launcherExitCode));
|
|
142
150
|
try {
|
|
143
151
|
await access(socketPath);
|
|
144
152
|
}
|
|
@@ -222,6 +230,7 @@ export class HopperClient {
|
|
|
222
230
|
});
|
|
223
231
|
});
|
|
224
232
|
launch.process.on("exit", (code) => {
|
|
233
|
+
this.#launcherExitCode = code;
|
|
225
234
|
this.#options.onDiagnostic?.({ type: "launcher-exit", code });
|
|
226
235
|
if (launch.ownsProcessLifetime && !this.#closing) {
|
|
227
236
|
this.#failAll(new HopperProcessError(code));
|
|
@@ -310,6 +319,12 @@ const connectOnce = async (socketPath) => new Promise((resolve) => {
|
|
|
310
319
|
resolve(err(new HopperStartError({ cause })));
|
|
311
320
|
});
|
|
312
321
|
});
|
|
322
|
+
const isShutdownAcknowledgement = (value) => typeof value === "object" &&
|
|
323
|
+
value !== null &&
|
|
324
|
+
!Array.isArray(value) &&
|
|
325
|
+
value.shutdown === true &&
|
|
326
|
+
value.analysis_stopped === true &&
|
|
327
|
+
value.document_closed === true;
|
|
313
328
|
const parseServerInfo = (value, expectedRunId) => {
|
|
314
329
|
if (typeof value === "object" &&
|
|
315
330
|
value !== null &&
|
|
@@ -78,6 +78,7 @@ export class HopperProvider {
|
|
|
78
78
|
? this.config.hopperLoaderArgs
|
|
79
79
|
: target.loaderArgs,
|
|
80
80
|
bridgeScriptPath: fileURLToPath(new URL("../../bridge/hopper_bridge.py", import.meta.url)),
|
|
81
|
+
demoHelperPath: fileURLToPath(new URL("../../scripts/hopper-demo-x11.py", import.meta.url)),
|
|
81
82
|
}),
|
|
82
83
|
logger: this.logger.child({ layer: "bridge" }),
|
|
83
84
|
});
|
package/dist/main.js
CHANGED
|
@@ -6,15 +6,34 @@ import { parseConfig } from "./config.js";
|
|
|
6
6
|
import { createBinarySession } from "./application/runtime.js";
|
|
7
7
|
import { createServer } from "./server/createServer.js";
|
|
8
8
|
import { createLogger } from "./logger.js";
|
|
9
|
+
import { projectAnalysisError } from "./domain/errors.js";
|
|
10
|
+
const MCP_CONNECTION_LOST = "REA lost its MCP client connection. Restart REA from your MCP client.";
|
|
11
|
+
const MCP_CONNECTION_START_FAILED = "REA could not start its MCP connection. Restart REA from your MCP client; run `rea doctor` if it fails again.";
|
|
12
|
+
const MCP_SHUTDOWN_FAILED = "REA could not close cleanly. End the REA process before starting it again.";
|
|
13
|
+
const SERVER_START_FAILED = "REA could not start. Run `rea doctor`, then restart REA from your MCP client.";
|
|
14
|
+
const runtimeDependencies = () => ({
|
|
15
|
+
env: process.env,
|
|
16
|
+
serve: serveStdio,
|
|
17
|
+
writeStderr: (text) => process.stderr.write(text),
|
|
18
|
+
setExitCode: (code) => {
|
|
19
|
+
process.exitCode = code;
|
|
20
|
+
},
|
|
21
|
+
registerShutdown: (handler) => {
|
|
22
|
+
process.once("SIGINT", handler);
|
|
23
|
+
process.once("SIGTERM", handler);
|
|
24
|
+
process.stdin.once("end", handler);
|
|
25
|
+
process.stdin.once("close", handler);
|
|
26
|
+
},
|
|
27
|
+
});
|
|
9
28
|
/**
|
|
10
29
|
* Start the long-lived MCP adapter and install idempotent shutdown handlers.
|
|
11
30
|
* The adapter owns its BinarySession for the process lifetime; EOF and process
|
|
12
31
|
* signals close bridge resources without assuming ownership of the Hopper app.
|
|
13
32
|
*/
|
|
14
|
-
export const run = async () => {
|
|
15
|
-
const config = parseConfig(
|
|
33
|
+
export const run = async (dependencies = runtimeDependencies()) => {
|
|
34
|
+
const config = parseConfig(dependencies.env);
|
|
16
35
|
if (!config.ok) {
|
|
17
|
-
|
|
36
|
+
dependencies.writeStderr(`${projectAnalysisError(config.error).message}\n`);
|
|
18
37
|
return 1;
|
|
19
38
|
}
|
|
20
39
|
const logger = createLogger("mcp", config.value.logLevel);
|
|
@@ -27,27 +46,29 @@ export const run = async () => {
|
|
|
27
46
|
if (!opened.ok) {
|
|
28
47
|
await session.close();
|
|
29
48
|
serverLogger.error({ errorTag: opened.error._tag }, "Initial target failed to open");
|
|
30
|
-
|
|
49
|
+
dependencies.writeStderr(`${projectAnalysisError(opened.error).message}\n`);
|
|
31
50
|
return 1;
|
|
32
51
|
}
|
|
33
52
|
}
|
|
34
53
|
let handle;
|
|
35
54
|
try {
|
|
36
|
-
handle =
|
|
55
|
+
handle = dependencies.serve(() => createServer(session, session, {
|
|
37
56
|
logger,
|
|
38
57
|
processPolicy: config.value.processExecutionPolicy,
|
|
39
58
|
evidenceFilePolicy: config.value.evidenceFilePolicy,
|
|
59
|
+
investigationInputRoots: config.value.investigationInputRoots,
|
|
60
|
+
analysisSnapshotFilePolicy: config.value.analysisSnapshotFilePolicy,
|
|
40
61
|
}), {
|
|
41
62
|
onerror: () => {
|
|
42
|
-
serverLogger.error(
|
|
43
|
-
|
|
63
|
+
serverLogger.error(MCP_CONNECTION_LOST);
|
|
64
|
+
dependencies.writeStderr(`${MCP_CONNECTION_LOST}\n`);
|
|
44
65
|
},
|
|
45
66
|
});
|
|
46
67
|
}
|
|
47
68
|
catch {
|
|
48
69
|
await session.close();
|
|
49
|
-
serverLogger.error(
|
|
50
|
-
|
|
70
|
+
serverLogger.error(MCP_CONNECTION_START_FAILED);
|
|
71
|
+
dependencies.writeStderr(`${MCP_CONNECTION_START_FAILED}\n`);
|
|
51
72
|
return 1;
|
|
52
73
|
}
|
|
53
74
|
let shutdownPromise;
|
|
@@ -60,26 +81,28 @@ export const run = async () => {
|
|
|
60
81
|
};
|
|
61
82
|
const requestShutdown = () => {
|
|
62
83
|
shutdown().catch(() => {
|
|
63
|
-
|
|
64
|
-
serverLogger.error(
|
|
65
|
-
|
|
84
|
+
dependencies.setExitCode(1);
|
|
85
|
+
serverLogger.error(MCP_SHUTDOWN_FAILED);
|
|
86
|
+
dependencies.writeStderr(`${MCP_SHUTDOWN_FAILED}\n`);
|
|
66
87
|
});
|
|
67
88
|
};
|
|
68
|
-
|
|
69
|
-
process.once("SIGTERM", requestShutdown);
|
|
70
|
-
process.stdin.once("end", requestShutdown);
|
|
71
|
-
process.stdin.once("close", requestShutdown);
|
|
89
|
+
dependencies.registerShutdown(requestShutdown);
|
|
72
90
|
return 0;
|
|
73
91
|
};
|
|
92
|
+
/** Run the executable boundary without exposing an unexpected startup cause. */
|
|
93
|
+
export const runEntrypoint = async (start = () => run(), writeStderr = (text) => process.stderr.write(text), setExitCode = (code) => {
|
|
94
|
+
process.exitCode = code;
|
|
95
|
+
}) => {
|
|
96
|
+
try {
|
|
97
|
+
setExitCode(await start());
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
writeStderr(`${SERVER_START_FAILED}\n`);
|
|
101
|
+
setExitCode(1);
|
|
102
|
+
}
|
|
103
|
+
};
|
|
74
104
|
const entryPath = process.argv[1];
|
|
75
105
|
if (entryPath !== undefined &&
|
|
76
106
|
pathToFileURL(realpathSync(entryPath)).href === import.meta.url) {
|
|
77
|
-
|
|
78
|
-
.then((exitCode) => {
|
|
79
|
-
process.exitCode = exitCode;
|
|
80
|
-
})
|
|
81
|
-
.catch(() => {
|
|
82
|
-
process.stderr.write("Unexpected server startup failure\n");
|
|
83
|
-
process.exitCode = 1;
|
|
84
|
-
});
|
|
107
|
+
void runEntrypoint();
|
|
85
108
|
}
|
|
@@ -6,6 +6,7 @@ import { registerSessionTools } from "./registerSessionTools.js";
|
|
|
6
6
|
import { registerNativeTools } from "./registerNativeTools.js";
|
|
7
7
|
import { registerArtifactTools } from "./registerArtifactTools.js";
|
|
8
8
|
import { silentLogger } from "../logger.js";
|
|
9
|
+
import { registerGuidedPrompts } from "./registerPrompts.js";
|
|
9
10
|
/**
|
|
10
11
|
* Construct one MCP server without acquiring subprocess resources.
|
|
11
12
|
* Supplying a session adds target lifecycle tools; omitting it retains the
|
|
@@ -50,6 +51,7 @@ export const createServer = (analysis, session, options = {}) => {
|
|
|
50
51
|
activeTarget,
|
|
51
52
|
recordEvidence,
|
|
52
53
|
});
|
|
54
|
+
registerGuidedPrompts(server, analysis, session);
|
|
53
55
|
if (session !== undefined)
|
|
54
56
|
registerSessionTools(server, session, toolLogger, options);
|
|
55
57
|
return server;
|