rea-agents 0.2.1

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.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +312 -0
  3. package/bridge/hopper_bridge.py +557 -0
  4. package/dist/application/AnalysisProvider.js +1 -0
  5. package/dist/application/BinarySession.js +173 -0
  6. package/dist/application/DirectAnalysis.js +31 -0
  7. package/dist/application/Doctor.js +110 -0
  8. package/dist/application/EnhancedTools.js +357 -0
  9. package/dist/application/EvidenceLedger.js +65 -0
  10. package/dist/application/HopperToolPort.js +1 -0
  11. package/dist/application/LoopbackReplay.js +84 -0
  12. package/dist/application/ProcessHarness.js +347 -0
  13. package/dist/application/Setup.js +240 -0
  14. package/dist/application/homebrew.js +19 -0
  15. package/dist/application/runtime.js +11 -0
  16. package/dist/cli.js +88 -0
  17. package/dist/config.js +86 -0
  18. package/dist/contracts/enhancedInputs.js +73 -0
  19. package/dist/contracts/toolContracts.js +144 -0
  20. package/dist/contracts/toolOutputSchemas.js +267 -0
  21. package/dist/domain/binaryTarget.js +321 -0
  22. package/dist/domain/errors.js +81 -0
  23. package/dist/domain/evidence.js +146 -0
  24. package/dist/domain/evidenceBundle.js +11 -0
  25. package/dist/domain/hopperValues.js +253 -0
  26. package/dist/domain/jsonValue.js +3 -0
  27. package/dist/domain/processCapture.js +252 -0
  28. package/dist/domain/result.js +4 -0
  29. package/dist/domain/symbolAnalysis.js +68 -0
  30. package/dist/hopper/BridgeLauncher.js +130 -0
  31. package/dist/hopper/HopperClient.js +314 -0
  32. package/dist/hopper/HopperProvider.js +66 -0
  33. package/dist/hopper/protocol.js +32 -0
  34. package/dist/identity.js +14 -0
  35. package/dist/logger.js +27 -0
  36. package/dist/main.js +81 -0
  37. package/dist/server/createServer.js +30 -0
  38. package/dist/server/registerEnhancedTools.js +53 -0
  39. package/dist/server/registerOfficialTools.js +59 -0
  40. package/dist/server/registerSessionTools.js +159 -0
  41. package/dist/server/toolLogging.js +11 -0
  42. package/dist/server/toolResult.js +44 -0
  43. package/package.json +109 -0
  44. package/scripts/rea.mjs +15 -0
  45. package/scripts/rebuild-native.mjs +22 -0
  46. package/skills/rea-analysis/SKILL.md +44 -0
@@ -0,0 +1,130 @@
1
+ import { spawn } from "node:child_process";
2
+ import { execFile } from "node:child_process";
3
+ import { chmod, writeFile } from "node:fs/promises";
4
+ import { basename, dirname, extname, join } from "node:path";
5
+ import { promisify } from "node:util";
6
+ import { HopperCancelledError, HopperStartError } from "../domain/errors.js";
7
+ import { err, ok } from "../domain/result.js";
8
+ const execFileAsync = promisify(execFile);
9
+ const HOPPER_BACKGROUND_STARTUP_MS = 5_000;
10
+ /**
11
+ * Launches Hopper through its documented CLI and injects only REA's owned bridge.
12
+ *
13
+ * Hopper's `hopper` helper internally issues an AppleScript `activate` command.
14
+ * Consequently, opening a target may bring Hopper to the foreground even though
15
+ * REA first asks macOS to start the application hidden and in the background.
16
+ * REA cannot reliably suppress that activation without replacing Hopper's
17
+ * supported launcher; callers must treat possible foreground UI as an upstream
18
+ * Hopper constraint, not as evidence that analysis failed.
19
+ *
20
+ * The returned process is the short-lived launcher helper, not the Hopper app,
21
+ * so REA does not claim ownership of the application's lifetime.
22
+ */
23
+ export class HopperApplicationLauncher {
24
+ options;
25
+ constructor(options) {
26
+ this.options = options;
27
+ }
28
+ async launch(session, options = {}) {
29
+ const bootstrapPath = `${session.directory}/bootstrap.py`;
30
+ const source = [
31
+ `REA_SOCKET = ${JSON.stringify(session.socketPath)}`,
32
+ `REA_TOKEN = ${JSON.stringify(session.token)}`,
33
+ `exec(compile(open(${JSON.stringify(this.options.bridgeScriptPath)}, 'rb').read(), ${JSON.stringify(this.options.bridgeScriptPath)}, 'exec'))`,
34
+ "",
35
+ ].join("\n");
36
+ try {
37
+ await writeFile(bootstrapPath, source, { encoding: "utf8", mode: 0o600 });
38
+ await chmod(bootstrapPath, 0o600);
39
+ }
40
+ catch (cause) {
41
+ return err(new HopperStartError({ cause }));
42
+ }
43
+ const action = this.options.targetKind === "database" ? "--database" : "--executable";
44
+ const args = [
45
+ ...this.options.loaderArgs,
46
+ "--analysis",
47
+ "--python",
48
+ bootstrapPath,
49
+ action,
50
+ this.options.targetPath,
51
+ ];
52
+ try {
53
+ if (options.signal?.aborted === true)
54
+ return err(new HopperCancelledError());
55
+ const prepared = await prepareHopperApplication(this.options.launcherPath, options.signal);
56
+ if (!prepared)
57
+ return err(new HopperCancelledError());
58
+ const child = spawn(this.options.launcherPath, args, {
59
+ stdio: ["ignore", "ignore", "pipe"],
60
+ });
61
+ const started = await new Promise((resolve) => {
62
+ const onSpawn = () => {
63
+ child.off("error", onError);
64
+ resolve(ok(undefined));
65
+ };
66
+ const onError = (cause) => {
67
+ child.off("spawn", onSpawn);
68
+ resolve(err(new HopperStartError({ cause })));
69
+ };
70
+ child.once("spawn", onSpawn);
71
+ child.once("error", onError);
72
+ });
73
+ if (!started.ok)
74
+ return started;
75
+ return ok({ process: child, ownsProcessLifetime: false });
76
+ }
77
+ catch (cause) {
78
+ return err(new HopperStartError({ cause }));
79
+ }
80
+ }
81
+ }
82
+ const prepareHopperApplication = async (launcherPath, signal) => {
83
+ const appBundle = hopperApplicationBundle(launcherPath);
84
+ if (appBundle === undefined)
85
+ return signal?.aborted !== true;
86
+ const executablePath = join(appBundle, "Contents/MacOS/Hopper Disassembler");
87
+ if (await processIsRunning(executablePath))
88
+ return signal?.aborted !== true;
89
+ // This reduces activation when Hopper is cold, but the vendor launcher that
90
+ // follows may still activate its window. See HopperApplicationLauncher.
91
+ await execFileAsync("/usr/bin/open", [
92
+ "--hide",
93
+ "--background",
94
+ "-a",
95
+ appBundle,
96
+ ]);
97
+ return waitForDelay(HOPPER_BACKGROUND_STARTUP_MS, signal);
98
+ };
99
+ const waitForDelay = (milliseconds, signal) => {
100
+ if (signal?.aborted === true)
101
+ return Promise.resolve(false);
102
+ return new Promise((resolve) => {
103
+ const timer = setTimeout(() => {
104
+ signal?.removeEventListener("abort", onAbort);
105
+ resolve(true);
106
+ }, milliseconds);
107
+ const onAbort = () => {
108
+ clearTimeout(timer);
109
+ resolve(false);
110
+ };
111
+ signal?.addEventListener("abort", onAbort, { once: true });
112
+ });
113
+ };
114
+ const hopperApplicationBundle = (launcherPath) => {
115
+ if (basename(launcherPath) !== "hopper")
116
+ return undefined;
117
+ const candidate = dirname(dirname(dirname(launcherPath)));
118
+ return extname(candidate) === ".app" ? candidate : undefined;
119
+ };
120
+ const processIsRunning = async (executablePath) => {
121
+ try {
122
+ const processes = await execFileAsync("/bin/ps", ["-ax", "-o", "command="]);
123
+ return processes.stdout
124
+ .split("\n")
125
+ .some((command) => command.trim() === executablePath);
126
+ }
127
+ catch {
128
+ return false;
129
+ }
130
+ };
@@ -0,0 +1,314 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { access, mkdtemp, rm } from "node:fs/promises";
3
+ import { createConnection } from "node:net";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { HopperCancelledError, HopperProcessError, HopperProtocolError, HopperStartError, HopperTimeoutError, } from "../domain/errors.js";
7
+ import { err, ok } from "../domain/result.js";
8
+ import { silentLogger } from "../logger.js";
9
+ import { parseResponseLine, responseResult } from "./protocol.js";
10
+ const MAX_LINE_BYTES = 10 * 1024 * 1024;
11
+ const SESSION_ROOT = process.platform === "darwin" ? "/tmp" : tmpdir();
12
+ /**
13
+ * Owns one authenticated NDJSON-over-Unix-socket bridge session.
14
+ *
15
+ * Each instance creates a private directory and random bearer token, correlates
16
+ * concurrent requests by numeric id, and removes its artifacts on close. It
17
+ * only terminates launch processes explicitly marked as owned; the normal
18
+ * Hopper launcher does not confer ownership of the GUI application.
19
+ */
20
+ export class HopperClient {
21
+ #options;
22
+ #pending = new Map();
23
+ #logger;
24
+ #socket;
25
+ #launch;
26
+ #directory;
27
+ #token;
28
+ #nextId = 1;
29
+ #buffer = "";
30
+ #closing = false;
31
+ #startPromise;
32
+ #closePromise;
33
+ constructor(options) {
34
+ this.#logger = options.logger ?? silentLogger;
35
+ this.#options = {
36
+ ...options,
37
+ requestTimeoutMs: options.requestTimeoutMs ?? 30_000,
38
+ startupTimeoutMs: options.startupTimeoutMs ?? 120_000,
39
+ };
40
+ }
41
+ /** Launch the bridge once and complete its authenticated health handshake. */
42
+ start(signal) {
43
+ this.#startPromise ??= this.#start(signal);
44
+ return this.#startPromise;
45
+ }
46
+ async #start(signal) {
47
+ if (signal?.aborted === true)
48
+ return err(new HopperCancelledError());
49
+ if (this.#socket !== undefined || this.#directory !== undefined) {
50
+ return err(new HopperProtocolError("Hopper client is already started"));
51
+ }
52
+ try {
53
+ this.#directory = await mkdtemp(join(SESSION_ROOT, "rea-"));
54
+ }
55
+ catch (cause) {
56
+ return err(new HopperStartError({ cause }));
57
+ }
58
+ const socketPath = join(this.#directory, "bridge.sock");
59
+ this.#token = randomBytes(32).toString("hex");
60
+ const launched = await this.#options.launcher.launch({
61
+ directory: this.#directory,
62
+ socketPath,
63
+ token: this.#token,
64
+ }, signal === undefined ? {} : { signal });
65
+ if (!launched.ok) {
66
+ await this.close();
67
+ return launched;
68
+ }
69
+ this.#launch = launched.value;
70
+ this.#attachLauncher(launched.value);
71
+ const connected = await this.#connect(socketPath, signal);
72
+ if (!connected.ok) {
73
+ await this.close();
74
+ return connected;
75
+ }
76
+ const health = await this.#request("health", {}, {
77
+ timeoutMs: this.#options.startupTimeoutMs,
78
+ ...(signal === undefined ? {} : { signal }),
79
+ });
80
+ if (!health.ok) {
81
+ await this.close();
82
+ return health;
83
+ }
84
+ const parsed = parseServerInfo(health.value);
85
+ if (!parsed.ok)
86
+ await this.close();
87
+ return parsed;
88
+ }
89
+ /** Invoke one declared operation, returning timeout and cancellation as values. */
90
+ async callTool(name, arguments_ = {}, options = {}) {
91
+ const started = await this.start(options.signal);
92
+ if (!started.ok)
93
+ return started;
94
+ return this.#request(name, arguments_, options);
95
+ }
96
+ /** Stop the bridge, settle outstanding requests, and remove session artifacts. */
97
+ close() {
98
+ this.#closePromise ??= Promise.resolve().then(() => this.#close());
99
+ return this.#closePromise;
100
+ }
101
+ async #close() {
102
+ this.#closing = true;
103
+ try {
104
+ const socket = this.#socket;
105
+ if (socket !== undefined && !socket.destroyed) {
106
+ await this.#request("shutdown", {}, { timeoutMs: 500 }).catch(() => err(new HopperProcessError(null)));
107
+ }
108
+ this.#failAll(new HopperProcessError(null));
109
+ socket?.destroy();
110
+ this.#socket = undefined;
111
+ if (this.#launch?.ownsProcessLifetime === true) {
112
+ this.#launch.process.kill("SIGTERM");
113
+ }
114
+ this.#launch = undefined;
115
+ if (this.#directory !== undefined) {
116
+ await rm(this.#directory, { recursive: true, force: true });
117
+ this.#directory = undefined;
118
+ }
119
+ this.#token = undefined;
120
+ this.#startPromise = undefined;
121
+ }
122
+ finally {
123
+ this.#closing = false;
124
+ this.#closePromise = undefined;
125
+ }
126
+ }
127
+ async #connect(socketPath, signal) {
128
+ const deadline = Date.now() + this.#options.startupTimeoutMs;
129
+ while (Date.now() < deadline) {
130
+ if (signal?.aborted === true)
131
+ return err(new HopperCancelledError());
132
+ if (this.#closing)
133
+ return err(new HopperProcessError(null));
134
+ try {
135
+ await access(socketPath);
136
+ }
137
+ catch {
138
+ if (!(await waitForDelay(50, signal)))
139
+ return err(new HopperCancelledError());
140
+ continue;
141
+ }
142
+ const attempt = await connectOnce(socketPath);
143
+ if (attempt.ok) {
144
+ this.#socket = attempt.value;
145
+ this.#attachSocket(attempt.value);
146
+ return ok(undefined);
147
+ }
148
+ if (!(await waitForDelay(50, signal)))
149
+ return err(new HopperCancelledError());
150
+ }
151
+ return err(new HopperTimeoutError(this.#options.startupTimeoutMs));
152
+ }
153
+ async #request(method, params, options) {
154
+ const socket = this.#socket;
155
+ const token = this.#token;
156
+ if (socket === undefined || socket.destroyed || token === undefined) {
157
+ return err(new HopperProcessError(null));
158
+ }
159
+ if (options.signal?.aborted === true)
160
+ return err(new HopperCancelledError());
161
+ const id = this.#nextId++;
162
+ const startedAt = performance.now();
163
+ const timeoutMs = options.timeoutMs ?? this.#options.requestTimeoutMs;
164
+ const response = new Promise((resolve) => {
165
+ const onAbort = options.signal === undefined
166
+ ? undefined
167
+ : () => {
168
+ this.#abandon(id, err(new HopperCancelledError()));
169
+ };
170
+ const timer = setTimeout(() => {
171
+ this.#abandon(id, err(new HopperTimeoutError(timeoutMs)));
172
+ }, timeoutMs);
173
+ this.#pending.set(id, {
174
+ resolve,
175
+ timer,
176
+ signal: options.signal,
177
+ onAbort,
178
+ });
179
+ options.signal?.addEventListener("abort", onAbort ?? (() => undefined), {
180
+ once: true,
181
+ });
182
+ });
183
+ socket.write(`${JSON.stringify({ id, token, method, params })}\n`, (cause) => {
184
+ if (cause !== undefined && cause !== null)
185
+ this.#settle(id, err(new HopperProcessError(null)));
186
+ });
187
+ const result = await response;
188
+ this.#logger[result.ok ? "debug" : "warn"]({
189
+ method,
190
+ durationMs: Math.round((performance.now() - startedAt) * 100) / 100,
191
+ status: result.ok ? "ok" : "error",
192
+ ...(result.ok ? {} : { errorTag: result.error._tag }),
193
+ }, "Hopper bridge request completed");
194
+ return result;
195
+ }
196
+ #attachSocket(socket) {
197
+ socket.setEncoding("utf8");
198
+ socket.on("data", (chunk) => {
199
+ this.#onData(chunk);
200
+ });
201
+ socket.on("error", () => {
202
+ this.#failAll(new HopperProcessError(null));
203
+ });
204
+ socket.on("close", () => {
205
+ if (!this.#closing)
206
+ this.#failAll(new HopperProcessError(null));
207
+ });
208
+ }
209
+ #attachLauncher(launch) {
210
+ launch.process.stderr?.on("data", (chunk) => {
211
+ this.#options.onDiagnostic?.({
212
+ type: "launcher-stderr",
213
+ bytes: chunk.byteLength,
214
+ });
215
+ });
216
+ launch.process.on("exit", (code) => {
217
+ this.#options.onDiagnostic?.({ type: "launcher-exit", code });
218
+ if (launch.ownsProcessLifetime && !this.#closing) {
219
+ this.#failAll(new HopperProcessError(code));
220
+ }
221
+ });
222
+ }
223
+ #onData(chunk) {
224
+ this.#buffer += chunk;
225
+ let newline = this.#buffer.indexOf("\n");
226
+ while (newline >= 0) {
227
+ const line = this.#buffer.slice(0, newline).trim();
228
+ this.#buffer = this.#buffer.slice(newline + 1);
229
+ if (Buffer.byteLength(line) > MAX_LINE_BYTES) {
230
+ this.#abortProtocol("Hopper response exceeded the maximum line size");
231
+ return;
232
+ }
233
+ if (line.length > 0)
234
+ this.#onLine(line);
235
+ newline = this.#buffer.indexOf("\n");
236
+ }
237
+ if (Buffer.byteLength(this.#buffer) > MAX_LINE_BYTES) {
238
+ this.#abortProtocol("Hopper response exceeded the maximum line size");
239
+ }
240
+ }
241
+ #onLine(line) {
242
+ const parsed = parseResponseLine(line);
243
+ if (!parsed.ok) {
244
+ this.#abortProtocol(parsed.error.message, parsed.error);
245
+ return;
246
+ }
247
+ if (!this.#pending.has(parsed.value.id)) {
248
+ if (parsed.value.id >= this.#nextId) {
249
+ this.#abortProtocol("Hopper returned an unknown response id");
250
+ }
251
+ return;
252
+ }
253
+ this.#settle(parsed.value.id, responseResult(parsed.value));
254
+ }
255
+ #abortProtocol(message, cause) {
256
+ this.#failAll(new HopperProtocolError(message, { cause }));
257
+ this.#socket?.destroy();
258
+ }
259
+ #settle(id, result) {
260
+ const pending = this.#pending.get(id);
261
+ if (pending === undefined)
262
+ return;
263
+ this.#pending.delete(id);
264
+ clearTimeout(pending.timer);
265
+ if (pending.signal !== undefined && pending.onAbort !== undefined) {
266
+ pending.signal.removeEventListener("abort", pending.onAbort);
267
+ }
268
+ pending.resolve(result);
269
+ }
270
+ #abandon(id, result) {
271
+ if (!this.#pending.has(id))
272
+ return;
273
+ this.#settle(id, result);
274
+ }
275
+ #failAll(error) {
276
+ for (const id of this.#pending.keys())
277
+ this.#settle(id, err(error));
278
+ }
279
+ }
280
+ const waitForDelay = (milliseconds, signal) => {
281
+ if (signal?.aborted === true)
282
+ return Promise.resolve(false);
283
+ return new Promise((resolve) => {
284
+ const timer = setTimeout(() => {
285
+ signal?.removeEventListener("abort", onAbort);
286
+ resolve(true);
287
+ }, milliseconds);
288
+ const onAbort = () => {
289
+ clearTimeout(timer);
290
+ resolve(false);
291
+ };
292
+ signal?.addEventListener("abort", onAbort, { once: true });
293
+ });
294
+ };
295
+ const connectOnce = async (socketPath) => new Promise((resolve) => {
296
+ const socket = createConnection(socketPath);
297
+ socket.once("connect", () => {
298
+ resolve(ok(socket));
299
+ });
300
+ socket.once("error", (cause) => {
301
+ socket.destroy();
302
+ resolve(err(new HopperStartError({ cause })));
303
+ });
304
+ });
305
+ const parseServerInfo = (value) => {
306
+ if (typeof value === "object" &&
307
+ value !== null &&
308
+ !Array.isArray(value) &&
309
+ value.name === "REA Hopper bridge" &&
310
+ typeof value.version === "string") {
311
+ return ok({ name: value.name, version: value.version });
312
+ }
313
+ return err(new HopperProtocolError("Hopper bridge health result is invalid"));
314
+ };
@@ -0,0 +1,66 @@
1
+ import { fileURLToPath } from "node:url";
2
+ import { HopperApplicationLauncher } from "./BridgeLauncher.js";
3
+ import { HopperClient } from "./HopperClient.js";
4
+ const IDENTITY = {
5
+ id: "hopper",
6
+ name: "Hopper Disassembler",
7
+ version: null,
8
+ };
9
+ const OPERATIONS = [
10
+ "direct-analysis",
11
+ "decompilation",
12
+ "disassembly",
13
+ "cross-references",
14
+ "containing-procedure-resolution",
15
+ "procedure-references",
16
+ "analysis-metadata-mutation",
17
+ ];
18
+ const CAPABILITIES = OPERATIONS.map((operation) => ({
19
+ operation,
20
+ version: 1,
21
+ available: true,
22
+ pagination: "none",
23
+ exhaustive: false,
24
+ effects: {
25
+ mutatesArtifact: operation === "analysis-metadata-mutation",
26
+ launchesProcess: true,
27
+ mayShowUi: true,
28
+ mayAccessNetwork: false,
29
+ mayWriteFilesystem: operation === "analysis-metadata-mutation",
30
+ requiresPrivileges: false,
31
+ },
32
+ limitations: ["Results depend on Hopper's completed static analysis."],
33
+ }));
34
+ /** Concrete analysis provider backed by REA's private Hopper bridge. */
35
+ export class HopperProvider {
36
+ config;
37
+ logger;
38
+ constructor(config, logger) {
39
+ this.config = config;
40
+ this.logger = logger;
41
+ }
42
+ identity() {
43
+ return IDENTITY;
44
+ }
45
+ capabilities() {
46
+ return CAPABILITIES;
47
+ }
48
+ createClient(target) {
49
+ const client = new HopperClient({
50
+ launcher: new HopperApplicationLauncher({
51
+ launcherPath: this.config.hopperLauncherPath,
52
+ targetPath: target.path,
53
+ targetKind: target.kind,
54
+ loaderArgs: this.config.hopperLoaderArgs.length > 0
55
+ ? this.config.hopperLoaderArgs
56
+ : target.loaderArgs,
57
+ bridgeScriptPath: fileURLToPath(new URL("../../bridge/hopper_bridge.py", import.meta.url)),
58
+ }),
59
+ logger: this.logger.child({ layer: "bridge" }),
60
+ });
61
+ return {
62
+ execute: (operation, parameters, options) => client.callTool(operation, parameters, options),
63
+ close: () => client.close(),
64
+ };
65
+ }
66
+ }
@@ -0,0 +1,32 @@
1
+ import { z } from "zod";
2
+ import { HopperProtocolError, HopperRemoteError } from "../domain/errors.js";
3
+ import { jsonValueSchema } from "../domain/jsonValue.js";
4
+ import { err, ok } from "../domain/result.js";
5
+ const responseSchema = z.union([
6
+ z.object({
7
+ id: z.number().int().nonnegative(),
8
+ result: jsonValueSchema,
9
+ }),
10
+ z.object({
11
+ id: z.number().int().nonnegative(),
12
+ error: z.object({ code: z.number().int(), message: z.string() }),
13
+ }),
14
+ ]);
15
+ /** Parse one complete Hopper NDJSON response line. */
16
+ export const parseResponseLine = (line) => {
17
+ let decoded;
18
+ try {
19
+ decoded = JSON.parse(line);
20
+ }
21
+ catch (cause) {
22
+ return err(new HopperProtocolError("Hopper returned malformed JSON", { cause }));
23
+ }
24
+ const parsed = responseSchema.safeParse(decoded);
25
+ return parsed.success
26
+ ? ok(parsed.data)
27
+ : err(new HopperProtocolError("Hopper returned a response outside the bridge contract", { cause: parsed.error }));
28
+ };
29
+ /** Project a parsed response into its result or expected remote failure. */
30
+ export const responseResult = (response) => "error" in response
31
+ ? err(new HopperRemoteError(response.error.code, response.error.message))
32
+ : ok(response.result);
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Canonical public identity shared by packaging, setup, MCP, and skill install.
3
+ * Centralization prevents persistent client registrations from drifting from
4
+ * the npm package or executable exposed in package.json.
5
+ */
6
+ export const PRODUCT_IDENTITY = {
7
+ displayName: "REA",
8
+ packageName: "rea-agents",
9
+ cliBinary: "rea",
10
+ mcpCommand: "npx -y rea-agents mcp",
11
+ mcpServerKey: "rea",
12
+ skillName: "rea-analysis",
13
+ configFileName: "rea.json",
14
+ };
package/dist/logger.js ADDED
@@ -0,0 +1,27 @@
1
+ import pino, {} from "pino";
2
+ import { z } from "zod";
3
+ const logLevelSchema = z
4
+ .enum(["trace", "debug", "info", "warn", "error", "fatal", "silent"])
5
+ .default("info");
6
+ /** Parse the optional REA_LOG_LEVEL environment variable. */
7
+ export const parseLogLevel = (value) => logLevelSchema.parse(value);
8
+ /** Create a JSON logger whose destination cannot corrupt MCP stdout frames. */
9
+ export const createLogger = (mode, level) => pino({
10
+ level,
11
+ base: { application: "rea", mode },
12
+ redact: {
13
+ paths: [
14
+ "token",
15
+ "*.token",
16
+ "params",
17
+ "*.params",
18
+ "arguments",
19
+ "*.arguments",
20
+ "environment",
21
+ "*.environment",
22
+ ],
23
+ censor: "[Redacted]",
24
+ },
25
+ }, pino.destination({ dest: mode === "mcp" ? 2 : 1, sync: false }));
26
+ /** Logger used by embedders that have not opted into process output. */
27
+ export const silentLogger = pino({ level: "silent" });
package/dist/main.js ADDED
@@ -0,0 +1,81 @@
1
+ #!/usr/bin/env node
2
+ import { pathToFileURL } from "node:url";
3
+ import { realpathSync } from "node:fs";
4
+ import { serveStdio, } from "@modelcontextprotocol/server/stdio";
5
+ import { parseConfig } from "./config.js";
6
+ import { createBinarySession } from "./application/runtime.js";
7
+ import { createServer } from "./server/createServer.js";
8
+ import { createLogger } from "./logger.js";
9
+ /**
10
+ * Start the long-lived MCP adapter and install idempotent shutdown handlers.
11
+ * The adapter owns its BinarySession for the process lifetime; EOF and process
12
+ * signals close bridge resources without assuming ownership of the Hopper app.
13
+ */
14
+ export const run = async () => {
15
+ const config = parseConfig(process.env);
16
+ if (!config.ok) {
17
+ process.stderr.write(`${config.error._tag}: ${config.error.message}\n`);
18
+ return 1;
19
+ }
20
+ const logger = createLogger("mcp", config.value.logLevel);
21
+ const serverLogger = logger.child({ layer: "server" });
22
+ const session = createBinarySession(config.value, logger);
23
+ if (config.value.hopperTargetPath !== undefined) {
24
+ const opened = await session.open(config.value.hopperTargetPath, {
25
+ targetKind: config.value.hopperTargetKind,
26
+ });
27
+ if (!opened.ok) {
28
+ await session.close();
29
+ serverLogger.error({ errorTag: opened.error._tag }, "Initial target failed to open");
30
+ process.stderr.write(`${opened.error._tag}: ${opened.error.message}\n`);
31
+ return 1;
32
+ }
33
+ }
34
+ let handle;
35
+ try {
36
+ handle = serveStdio(() => createServer(session, session, logger, config.value.processExecutionPolicy), {
37
+ onerror: () => {
38
+ serverLogger.error("MCP stdio transport error");
39
+ process.stderr.write("MCP stdio transport error\n");
40
+ },
41
+ });
42
+ }
43
+ catch {
44
+ await session.close();
45
+ serverLogger.error("Failed to start MCP stdio transport");
46
+ process.stderr.write("Failed to start MCP stdio transport\n");
47
+ return 1;
48
+ }
49
+ let shutdownPromise;
50
+ const shutdown = () => {
51
+ shutdownPromise ??= (async () => {
52
+ await handle.close();
53
+ await session.close();
54
+ })();
55
+ return shutdownPromise;
56
+ };
57
+ const requestShutdown = () => {
58
+ shutdown().catch(() => {
59
+ process.exitCode = 1;
60
+ serverLogger.error("Failed to shut down cleanly");
61
+ process.stderr.write("Failed to shut down cleanly\n");
62
+ });
63
+ };
64
+ process.once("SIGINT", requestShutdown);
65
+ process.once("SIGTERM", requestShutdown);
66
+ process.stdin.once("end", requestShutdown);
67
+ process.stdin.once("close", requestShutdown);
68
+ return 0;
69
+ };
70
+ const entryPath = process.argv[1];
71
+ if (entryPath !== undefined &&
72
+ pathToFileURL(realpathSync(entryPath)).href === import.meta.url) {
73
+ run()
74
+ .then((exitCode) => {
75
+ process.exitCode = exitCode;
76
+ })
77
+ .catch(() => {
78
+ process.stderr.write("Unexpected server startup failure\n");
79
+ process.exitCode = 1;
80
+ });
81
+ }