rea-agents 0.3.0 → 0.4.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 +124 -20
- package/bridge/hopper_bridge.py +141 -13
- package/dist/application/AnalysisProvider.js +12 -1
- package/dist/application/ArtifactExtraction.js +166 -0
- package/dist/application/ArtifactGraphConstruction.js +257 -0
- package/dist/application/ArtifactInventory.js +253 -0
- package/dist/application/BinarySession.js +164 -14
- package/dist/application/CompositeProvider.js +73 -0
- package/dist/application/DirectAnalysis.js +27 -3
- package/dist/application/Doctor.js +35 -6
- package/dist/application/EnhancedTools.js +8 -7
- package/dist/application/EvidenceBundleCommands.js +29 -0
- package/dist/application/EvidenceBundleFiles.js +127 -0
- package/dist/application/EvidenceLedger.js +225 -18
- package/dist/application/FilesystemSnapshot.js +124 -0
- package/dist/application/LinuxHopper.js +186 -0
- package/dist/application/LoopbackReplay.js +195 -37
- package/dist/application/ProcessHarness.js +200 -191
- package/dist/application/ProcessNormalization.js +44 -0
- package/dist/application/ProcessOwnership.js +84 -0
- package/dist/application/ProcessSampling.js +284 -0
- package/dist/application/RealHopperAssertions.js +105 -0
- package/dist/application/ReferenceSourceImport.js +182 -0
- package/dist/application/ReferenceSourceImportEntries.js +122 -0
- package/dist/application/ReferenceSourceImportPolicy.js +73 -0
- package/dist/application/ReferenceSourceImportTypes.js +18 -0
- package/dist/application/ReferenceSourceVcsAdapter.js +34 -0
- package/dist/application/Setup.js +192 -41
- package/dist/application/Uninstall.js +130 -0
- package/dist/application/runtime.js +8 -1
- package/dist/artifacts/ArtifactPaths.js +51 -0
- package/dist/artifacts/ArtifactProvider.js +130 -0
- package/dist/artifacts/ArtifactReader.js +9 -0
- package/dist/artifacts/AsarArtifactReader.js +62 -0
- package/dist/artifacts/DirectoryArtifactReader.js +107 -0
- package/dist/artifacts/MachOSliceArtifactReader.js +66 -0
- package/dist/artifacts/SafeOutputTree.js +199 -0
- package/dist/artifacts/StreamBytes.js +10 -0
- package/dist/artifacts/ZipArtifactReader.js +109 -0
- package/dist/cli.js +149 -2
- package/dist/config.js +35 -1
- package/dist/contracts/artifactComparisonExample.js +95 -0
- package/dist/contracts/artifactToolContracts.js +84 -0
- package/dist/contracts/enhancedInputs.js +4 -0
- package/dist/contracts/functionComparisonExample.js +57 -0
- package/dist/contracts/investigationExamples.js +118 -0
- package/dist/contracts/nativeToolContracts.js +52 -0
- package/dist/contracts/processCaptureExample.js +25 -0
- package/dist/contracts/toolContractExamples.js +69 -0
- package/dist/contracts/toolContracts.js +99 -36
- package/dist/contracts/toolOutputSchemas.js +154 -82
- package/dist/contracts/unknownContractExamples.js +33 -0
- package/dist/domain/artifactComparison.js +273 -0
- package/dist/domain/artifactGraph.js +194 -0
- package/dist/domain/artifactInventoryEvidence.js +150 -0
- package/dist/domain/binaryTarget.js +55 -0
- package/dist/domain/bundleComparison.js +266 -0
- package/dist/domain/callPath.js +346 -0
- package/dist/domain/changedBehavior.js +294 -0
- package/dist/domain/errors.js +192 -4
- package/dist/domain/evidence.js +41 -10
- package/dist/domain/evidenceBundle.js +187 -6
- package/dist/domain/functionComparison.js +201 -0
- package/dist/domain/functionComparisonNormalization.js +112 -0
- package/dist/domain/functionComparisonResults.js +54 -0
- package/dist/domain/functionComparisonSchemas.js +82 -0
- package/dist/domain/functionDossierEvidence.js +171 -0
- package/dist/domain/hopperValues.js +14 -7
- package/dist/domain/nativeInspection.js +142 -0
- package/dist/domain/processCapture.js +152 -54
- package/dist/domain/processComparison.js +106 -0
- package/dist/domain/reconstructionUnknowns.js +90 -0
- package/dist/domain/reconstructionVerification.js +285 -0
- package/dist/domain/reconstructionVerificationSchemas.js +126 -0
- package/dist/domain/referenceSourceClassification.js +496 -0
- package/dist/domain/referenceSourceGraph.js +376 -0
- package/dist/domain/referenceSourceImportParsing.js +235 -0
- package/dist/domain/referenceSourcePolicy.js +1 -0
- package/dist/domain/residualUnknown.js +239 -0
- package/dist/domain/staticRuntimeCorrelation.js +375 -0
- package/dist/hopper/BridgeLauncher.js +40 -3
- package/dist/hopper/HopperClient.js +14 -5
- package/dist/hopper/HopperProvider.js +57 -22
- package/dist/identity.js +1 -0
- package/dist/main.js +5 -1
- package/dist/native/CommandRunner.js +156 -0
- package/dist/native/NativeMacOSProvider.js +306 -0
- package/dist/native/NativeMachoInspection.js +135 -0
- package/dist/native/parsers/codesign.js +55 -0
- package/dist/native/parsers/demangle.js +26 -0
- package/dist/native/parsers/dyldInfo.js +25 -0
- package/dist/native/parsers/lipo.js +67 -0
- package/dist/native/parsers/otool.js +193 -0
- package/dist/native/parsers/plist.js +23 -0
- package/dist/reference/ReferenceSourceReader.js +73 -0
- package/dist/reference/ReferenceSourceReaderEntries.js +206 -0
- package/dist/reference/ReferenceSourceReaderErrors.js +19 -0
- package/dist/reference/ReferenceSourceReaderFile.js +119 -0
- package/dist/reference/ReferenceSourceReaderPaths.js +23 -0
- package/dist/reference/ReferenceSourceReaderTypes.js +2 -0
- package/dist/reference/ReferenceSourceReaderValidate.js +71 -0
- package/dist/server/createServer.js +31 -5
- package/dist/server/recordDerivedEvidence.js +10 -0
- package/dist/server/registerArtifactComparisonTool.js +62 -0
- package/dist/server/registerArtifactTools.js +6 -0
- package/dist/server/registerBundleComparisonTool.js +47 -0
- package/dist/server/registerEnhancedTools.js +64 -12
- package/dist/server/registerEvidenceTools.js +36 -0
- package/dist/server/registerFunctionComparisonTool.js +68 -0
- package/dist/server/registerInvestigationTools.js +224 -0
- package/dist/server/registerNativeTools.js +6 -0
- package/dist/server/registerOfficialTools.js +47 -14
- package/dist/server/registerProcessComparisonTool.js +106 -0
- package/dist/server/registerSessionTools.js +179 -70
- package/dist/server/sessionEvidence.js +28 -0
- package/dist/server/sessionToolPolicies.js +64 -0
- package/dist/server/toolRegistrationOptions.js +7 -0
- package/dist/server/toolResult.js +8 -5
- package/install.sh +198 -0
- package/package.json +18 -1
- package/scripts/rea.mjs +5 -1
- package/skills/rea-analysis/SKILL.md +77 -2
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { readFile, realpath, stat } from "node:fs/promises";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import ignore from "ignore";
|
|
4
|
+
import { err, ok } from "../domain/result.js";
|
|
5
|
+
import { withinRoot } from "../reference/ReferenceSourceReaderPaths.js";
|
|
6
|
+
import { DEFAULT_REFERENCE_SOURCE_IGNORE_PATTERNS, DEFAULT_REFERENCE_SOURCE_LIMITS, } from "./ReferenceSourceImportTypes.js";
|
|
7
|
+
const failure = (code, message) => ({
|
|
8
|
+
tag: "reference-source-import",
|
|
9
|
+
code,
|
|
10
|
+
message,
|
|
11
|
+
});
|
|
12
|
+
const validLimits = (limits) => Object.values(limits).every((value) => Number.isSafeInteger(value) && value > 0);
|
|
13
|
+
const maximumLimits = (options) => ({
|
|
14
|
+
maxBytes: options.policy.maxBytes,
|
|
15
|
+
maxEntries: options.policy.maxEntries,
|
|
16
|
+
maxDepth: options.policy.maxDepth,
|
|
17
|
+
maxPathBytes: options.policy.maxPathBytes,
|
|
18
|
+
});
|
|
19
|
+
const clampLimits = (requested, maximum) => ({
|
|
20
|
+
maxBytes: Math.min(requested.maxBytes, maximum.maxBytes),
|
|
21
|
+
maxEntries: Math.min(requested.maxEntries, maximum.maxEntries),
|
|
22
|
+
maxDepth: Math.min(requested.maxDepth, maximum.maxDepth),
|
|
23
|
+
maxPathBytes: Math.min(requested.maxPathBytes, maximum.maxPathBytes),
|
|
24
|
+
});
|
|
25
|
+
const authorizeRoot = async (requestedRoot, approvedRoots) => {
|
|
26
|
+
if (approvedRoots.length === 0)
|
|
27
|
+
return err(failure("policy", "No approved reference source roots"));
|
|
28
|
+
try {
|
|
29
|
+
if (!(await stat(requestedRoot)).isDirectory())
|
|
30
|
+
return err(failure("invalid-root", "Reference source root is not a directory"));
|
|
31
|
+
const canonicalRoot = await realpath(resolve(requestedRoot));
|
|
32
|
+
for (const approvedRoot of approvedRoots) {
|
|
33
|
+
const canonicalApproved = await realpath(resolve(approvedRoot));
|
|
34
|
+
if (withinRoot(canonicalApproved, canonicalRoot))
|
|
35
|
+
return ok(canonicalRoot);
|
|
36
|
+
}
|
|
37
|
+
return err(failure("policy", "Reference source root is outside approved roots"));
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return err(failure("invalid-root", "Reference source root could not be resolved"));
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
const buildIgnored = async (root, excludePaths) => {
|
|
44
|
+
const ignored = ignore();
|
|
45
|
+
try {
|
|
46
|
+
ignored.add(await readFile(join(root, ".gitignore"), "utf8"));
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// Missing or unreadable ignore file does not authorize broader access.
|
|
50
|
+
}
|
|
51
|
+
ignored.add([...DEFAULT_REFERENCE_SOURCE_IGNORE_PATTERNS]);
|
|
52
|
+
for (const path of excludePaths) {
|
|
53
|
+
ignored.add(path);
|
|
54
|
+
ignored.add(`${path}/`);
|
|
55
|
+
}
|
|
56
|
+
return ignored;
|
|
57
|
+
};
|
|
58
|
+
/** Validate limits, authorize root, and build non-executing path filters. */
|
|
59
|
+
export const prepareReferenceSourceImport = async (options) => {
|
|
60
|
+
const requested = options.limits ?? DEFAULT_REFERENCE_SOURCE_LIMITS;
|
|
61
|
+
const maximum = maximumLimits(options);
|
|
62
|
+
if (!validLimits(requested) || !validLimits(maximum))
|
|
63
|
+
return err(failure("invalid-limits", "Import limits must be positive integers"));
|
|
64
|
+
const root = await authorizeRoot(options.root, options.policy.roots);
|
|
65
|
+
if (!root.ok)
|
|
66
|
+
return root;
|
|
67
|
+
return ok({
|
|
68
|
+
root: root.value,
|
|
69
|
+
limits: clampLimits(requested, maximum),
|
|
70
|
+
ignored: await buildIgnored(root.value, options.excludePaths ?? []),
|
|
71
|
+
secrets: ignore().add([...options.policy.secretPatterns]),
|
|
72
|
+
});
|
|
73
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export const DEFAULT_REFERENCE_SOURCE_IGNORE_PATTERNS = [
|
|
2
|
+
".git/",
|
|
3
|
+
".git/hooks/",
|
|
4
|
+
"node_modules/",
|
|
5
|
+
"dist/",
|
|
6
|
+
"build/",
|
|
7
|
+
"coverage/",
|
|
8
|
+
"htmlcov/",
|
|
9
|
+
".coverage",
|
|
10
|
+
"*.log",
|
|
11
|
+
];
|
|
12
|
+
export const DEFAULT_REFERENCE_SOURCE_LIMITS = {
|
|
13
|
+
maxBytes: 16 * 1024 * 1024,
|
|
14
|
+
maxEntries: 10_000,
|
|
15
|
+
maxDepth: 32,
|
|
16
|
+
maxPathBytes: 4_096,
|
|
17
|
+
};
|
|
18
|
+
export const PARSEABLE_REFERENCE_SOURCE_LANGUAGES = new Set(["JavaScript", "TypeScript", "JSX", "TSX"]);
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { lstat } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { resolveRef } from "isomorphic-git";
|
|
5
|
+
/**
|
|
6
|
+
* Read Git metadata for a directory using isomorphic-git.
|
|
7
|
+
*
|
|
8
|
+
* No git subprocess or network is used; only the local `.git` object store is read.
|
|
9
|
+
*/
|
|
10
|
+
export const readReferenceSourceVcs = async (root, signal) => {
|
|
11
|
+
if (isAborted(signal))
|
|
12
|
+
return { kind: "unknown", head: null, dirty: null };
|
|
13
|
+
try {
|
|
14
|
+
await lstat(join(root, ".git"));
|
|
15
|
+
}
|
|
16
|
+
catch (cause) {
|
|
17
|
+
return errorCode(cause) === "ENOENT"
|
|
18
|
+
? { kind: "none", head: null, dirty: null }
|
|
19
|
+
: { kind: "unknown", head: null, dirty: null };
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
const head = await resolveRef({ fs, dir: root, ref: "HEAD" });
|
|
23
|
+
if (isAborted(signal))
|
|
24
|
+
return { kind: "unknown", head: null, dirty: null };
|
|
25
|
+
return { kind: "git", head, dirty: null };
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return { kind: "unknown", head: null, dirty: null };
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
const errorCode = (cause) => typeof cause === "object" && cause !== null && "code" in cause
|
|
32
|
+
? String(cause.code)
|
|
33
|
+
: undefined;
|
|
34
|
+
const isAborted = (signal) => signal?.aborted === true;
|
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
import { access, copyFile, mkdir, readFile, rename, rm, writeFile, } from "node:fs/promises";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
|
-
import { dirname, join } from "node:path";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
4
|
import { execFile } from "node:child_process";
|
|
5
5
|
import { promisify } from "node:util";
|
|
6
6
|
import { z } from "zod";
|
|
7
|
+
import writeFileAtomic from "write-file-atomic";
|
|
8
|
+
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
|
|
7
9
|
import { PRODUCT_IDENTITY } from "../identity.js";
|
|
8
10
|
import { runDoctor, systemDoctorHost } from "./Doctor.js";
|
|
9
11
|
import { probeHomebrew } from "./homebrew.js";
|
|
12
|
+
import { installLinuxHopper, readLinuxDistribution, } from "./LinuxHopper.js";
|
|
10
13
|
const execFileAsync = promisify(execFile);
|
|
14
|
+
const registrationCommand = () => process.env.npm_command === "exec"
|
|
15
|
+
? ["npx", "-y", PRODUCT_IDENTITY.packageName, "mcp"]
|
|
16
|
+
: [resolve(process.argv[1] ?? PRODUCT_IDENTITY.cliBinary), "mcp"];
|
|
11
17
|
/**
|
|
12
18
|
* Install prerequisites and configure detected clients idempotently.
|
|
13
19
|
* With `yes` false, mutations requiring consent are reported as `needs_human`;
|
|
@@ -23,14 +29,10 @@ export const runSetup = async (yes, host = systemSetupHost()) => {
|
|
|
23
29
|
doctor: await host.doctor(),
|
|
24
30
|
remediation,
|
|
25
31
|
});
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
if (
|
|
30
|
-
return fail("Upgrade to macOS 12 or newer.");
|
|
31
|
-
if (major(host.nodeVersion) < 22)
|
|
32
|
-
return fail("Install Node.js 22 or newer.");
|
|
33
|
-
if (!(await host.hasHomebrew())) {
|
|
32
|
+
const unsupported = await hostRemediation(host);
|
|
33
|
+
if (unsupported !== undefined)
|
|
34
|
+
return fail(unsupported);
|
|
35
|
+
if (host.platform === "darwin" && !(await host.hasHomebrew())) {
|
|
34
36
|
if (!yes)
|
|
35
37
|
return fail("Re-run with --yes to install Homebrew.");
|
|
36
38
|
if (!(await host.installHomebrew()))
|
|
@@ -49,7 +51,7 @@ export const runSetup = async (yes, host = systemSetupHost()) => {
|
|
|
49
51
|
return fail("Hopper installation completed but its launcher was not found.");
|
|
50
52
|
}
|
|
51
53
|
for (const client of await host.detectedClients()) {
|
|
52
|
-
const result = await host.configureClient(client, hopperPath);
|
|
54
|
+
const result = await host.configureClient(client, hopperPath, registrationCommand());
|
|
53
55
|
clients[client.name] = result;
|
|
54
56
|
if (result.status === "failed")
|
|
55
57
|
return fail(`${client.name} configuration ${result.reason} verification failed; no successful configuration was reported.`);
|
|
@@ -62,18 +64,37 @@ export const runSetup = async (yes, host = systemSetupHost()) => {
|
|
|
62
64
|
if (skill === "installed")
|
|
63
65
|
actions.push("installed_skill");
|
|
64
66
|
const doctor = await host.doctor();
|
|
67
|
+
const activationRequired = actions.includes("installed_hopper");
|
|
68
|
+
const ready = doctor.healthy && !activationRequired;
|
|
65
69
|
return {
|
|
66
|
-
status:
|
|
70
|
+
status: ready ? "ready" : "needs_human",
|
|
67
71
|
actions,
|
|
68
72
|
clients,
|
|
69
73
|
doctor,
|
|
70
|
-
...(
|
|
74
|
+
...(ready
|
|
71
75
|
? {}
|
|
72
76
|
: {
|
|
73
|
-
remediation:
|
|
77
|
+
remediation: activationRequired
|
|
78
|
+
? "Open Hopper, complete its one-time activation, then rerun rea doctor --json."
|
|
79
|
+
: "Run rea doctor and apply each reported remediation.",
|
|
74
80
|
}),
|
|
75
81
|
};
|
|
76
82
|
};
|
|
83
|
+
const hostRemediation = async (host) => {
|
|
84
|
+
if (host.platform !== "darwin" && host.platform !== "linux")
|
|
85
|
+
return "REA supports Hopper on macOS and selected 64-bit Linux distributions.";
|
|
86
|
+
if (major(host.nodeVersion) < 24)
|
|
87
|
+
return "Install Node.js 24.18 or newer.";
|
|
88
|
+
if (host.platform === "darwin") {
|
|
89
|
+
const version = await host.macosVersion();
|
|
90
|
+
return version === undefined || major(version) < 12
|
|
91
|
+
? "Upgrade to macOS 12 or newer."
|
|
92
|
+
: undefined;
|
|
93
|
+
}
|
|
94
|
+
return (await host.linuxDistribution())?.supported === true
|
|
95
|
+
? undefined
|
|
96
|
+
: "REA supports Hopper on Ubuntu 24.04+, Fedora 41+, and 64-bit Arch Linux.";
|
|
97
|
+
};
|
|
77
98
|
/** Production setup effects for macOS, Homebrew, JSON MCP clients, and the canonical skill directory. */
|
|
78
99
|
const systemSetupHost = () => {
|
|
79
100
|
const doctorHost = systemDoctorHost();
|
|
@@ -81,40 +102,81 @@ const systemSetupHost = () => {
|
|
|
81
102
|
platform: process.platform,
|
|
82
103
|
nodeVersion: process.versions.node,
|
|
83
104
|
macosVersion: () => doctorHost.macosVersion(),
|
|
105
|
+
linuxDistribution: readLinuxDistribution,
|
|
84
106
|
hasHomebrew: () => brewSucceeds(["--version"]),
|
|
85
107
|
installHomebrew: () => commandSucceeds("/bin/bash", [
|
|
86
108
|
"-c",
|
|
87
109
|
"$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)",
|
|
88
110
|
], { ...process.env, NONINTERACTIVE: "1" }),
|
|
89
111
|
hopperPath: async () => (await runDoctor(undefined, doctorHost)).hopperPath,
|
|
90
|
-
installHopper: () =>
|
|
91
|
-
|
|
92
|
-
|
|
112
|
+
installHopper: () => process.platform === "linux"
|
|
113
|
+
? installLinuxHopper().then(({ status }) => status === "installed")
|
|
114
|
+
: brewSucceeds(["install", "--cask", "hopper-disassembler"]),
|
|
115
|
+
detectedClients: () => detectClients(homedir()),
|
|
116
|
+
configureClient: (client, hopperPath, command) => client.format === "unsupported"
|
|
117
|
+
? Promise.resolve({ status: "skipped" })
|
|
118
|
+
: client.format === "toml"
|
|
119
|
+
? configureTomlClient(client, hopperPath, command)
|
|
120
|
+
: configureJsonClient(client, hopperPath, command),
|
|
93
121
|
installSkill: () => installCanonicalSkill(homedir()),
|
|
94
122
|
doctor: () => runDoctor(undefined, doctorHost),
|
|
95
123
|
};
|
|
96
124
|
};
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
{
|
|
100
|
-
name: "claude_desktop",
|
|
101
|
-
configPath: join(home, "Library/Application Support/Claude/claude_desktop_config.json"),
|
|
102
|
-
marker: join(home, "Library/Application Support/Claude"),
|
|
103
|
-
},
|
|
104
|
-
{
|
|
105
|
-
name: "cursor",
|
|
106
|
-
configPath: join(home, ".cursor/mcp.json"),
|
|
107
|
-
marker: join(home, ".cursor"),
|
|
108
|
-
},
|
|
109
|
-
];
|
|
125
|
+
/** Detect supported coding agents from stable per-user installation markers. */
|
|
126
|
+
export const detectClients = async (home) => {
|
|
110
127
|
const detected = [];
|
|
111
|
-
for (const candidate of
|
|
112
|
-
if (await exists(candidate.
|
|
113
|
-
detected.push(
|
|
128
|
+
for (const candidate of supportedClients(home))
|
|
129
|
+
if (await exists(candidate.markerPath ?? candidate.configPath))
|
|
130
|
+
detected.push(candidate);
|
|
114
131
|
return detected;
|
|
115
132
|
};
|
|
133
|
+
/** Describe every client location that setup or uninstall may own. */
|
|
134
|
+
export const supportedClients = (home) => [
|
|
135
|
+
{
|
|
136
|
+
name: "claude_code",
|
|
137
|
+
configPath: join(home, ".claude.json"),
|
|
138
|
+
markerPath: join(home, ".claude"),
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
name: "claude_desktop",
|
|
142
|
+
configPath: join(home, "Library/Application Support/Claude/claude_desktop_config.json"),
|
|
143
|
+
markerPath: join(home, "Library/Application Support/Claude"),
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
name: "codex",
|
|
147
|
+
configPath: join(home, ".codex/config.toml"),
|
|
148
|
+
markerPath: join(home, ".codex"),
|
|
149
|
+
format: "toml",
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
name: "cursor",
|
|
153
|
+
configPath: join(home, ".cursor/mcp.json"),
|
|
154
|
+
markerPath: join(home, ".cursor"),
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
name: "gemini_cli",
|
|
158
|
+
configPath: join(home, ".gemini/settings.json"),
|
|
159
|
+
markerPath: join(home, ".gemini"),
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
name: "windsurf",
|
|
163
|
+
configPath: join(home, ".codeium/windsurf/mcp_config.json"),
|
|
164
|
+
markerPath: join(home, ".codeium/windsurf"),
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
name: "devin",
|
|
168
|
+
configPath: join(home, ".devin"),
|
|
169
|
+
markerPath: join(home, ".devin"),
|
|
170
|
+
format: "unsupported",
|
|
171
|
+
},
|
|
172
|
+
];
|
|
116
173
|
/** Back up, atomically update, and semantically read back one JSON MCP configuration. */
|
|
117
|
-
export const configureJsonClient = async (client, hopperPath
|
|
174
|
+
export const configureJsonClient = async (client, hopperPath, command = [
|
|
175
|
+
"npx",
|
|
176
|
+
"-y",
|
|
177
|
+
PRODUCT_IDENTITY.packageName,
|
|
178
|
+
"mcp",
|
|
179
|
+
]) => {
|
|
118
180
|
let document = {};
|
|
119
181
|
let original;
|
|
120
182
|
try {
|
|
@@ -133,8 +195,8 @@ export const configureJsonClient = async (client, hopperPath) => {
|
|
|
133
195
|
return { status: "failed", reason: "readback" };
|
|
134
196
|
}
|
|
135
197
|
const desired = {
|
|
136
|
-
command:
|
|
137
|
-
args:
|
|
198
|
+
command: command[0] ?? PRODUCT_IDENTITY.cliBinary,
|
|
199
|
+
args: command.slice(1),
|
|
138
200
|
...(hopperPath === undefined
|
|
139
201
|
? {}
|
|
140
202
|
: { env: { HOPPER_LAUNCHER_PATH: hopperPath } }),
|
|
@@ -184,19 +246,108 @@ export const configureJsonClient = async (client, hopperPath) => {
|
|
|
184
246
|
...(backupPath === undefined ? {} : { backupPath }),
|
|
185
247
|
};
|
|
186
248
|
};
|
|
187
|
-
|
|
249
|
+
/** Back up, atomically update, and semantically read back Codex TOML configuration. */
|
|
250
|
+
export const configureTomlClient = async (client, hopperPath, command = [
|
|
251
|
+
"npx",
|
|
252
|
+
"-y",
|
|
253
|
+
PRODUCT_IDENTITY.packageName,
|
|
254
|
+
"mcp",
|
|
255
|
+
]) => {
|
|
256
|
+
let document = {};
|
|
257
|
+
let original;
|
|
258
|
+
try {
|
|
259
|
+
original = await readFile(client.configPath, "utf8");
|
|
260
|
+
document = objectSchema.parse(parseToml(original));
|
|
261
|
+
}
|
|
262
|
+
catch (cause) {
|
|
263
|
+
if (!isMissing(cause))
|
|
264
|
+
return { status: "failed", reason: "readback" };
|
|
265
|
+
}
|
|
266
|
+
let servers;
|
|
267
|
+
try {
|
|
268
|
+
servers = parseOptionalObject(document.mcp_servers);
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
return { status: "failed", reason: "readback" };
|
|
272
|
+
}
|
|
273
|
+
const desired = {
|
|
274
|
+
command: command[0] ?? PRODUCT_IDENTITY.cliBinary,
|
|
275
|
+
args: command.slice(1),
|
|
276
|
+
...(hopperPath === undefined
|
|
277
|
+
? {}
|
|
278
|
+
: { env: { HOPPER_LAUNCHER_PATH: hopperPath } }),
|
|
279
|
+
};
|
|
280
|
+
if (JSON.stringify(servers[PRODUCT_IDENTITY.mcpServerKey]) ===
|
|
281
|
+
JSON.stringify(desired))
|
|
282
|
+
return { status: "unchanged" };
|
|
283
|
+
const backupPath = original === undefined ? undefined : `${client.configPath}.rea.backup`;
|
|
284
|
+
try {
|
|
285
|
+
if (backupPath !== undefined)
|
|
286
|
+
await copyFile(client.configPath, backupPath);
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
return { status: "failed", reason: "backup" };
|
|
290
|
+
}
|
|
291
|
+
document.mcp_servers = {
|
|
292
|
+
...servers,
|
|
293
|
+
[PRODUCT_IDENTITY.mcpServerKey]: desired,
|
|
294
|
+
};
|
|
295
|
+
try {
|
|
296
|
+
await mkdir(dirname(client.configPath), { recursive: true });
|
|
297
|
+
await writeFileAtomic(client.configPath, stringifyToml(document), {
|
|
298
|
+
encoding: "utf8",
|
|
299
|
+
mode: 0o600,
|
|
300
|
+
});
|
|
301
|
+
const readback = objectSchema.parse(parseToml(await readFile(client.configPath, "utf8")));
|
|
302
|
+
if (JSON.stringify(parseOptionalObject(readback.mcp_servers)[PRODUCT_IDENTITY.mcpServerKey]) !== JSON.stringify(desired))
|
|
303
|
+
throw new Error("TOML readback mismatch");
|
|
304
|
+
}
|
|
305
|
+
catch {
|
|
306
|
+
await restoreConfig(client.configPath, original);
|
|
307
|
+
return { status: "failed", reason: "readback" };
|
|
308
|
+
}
|
|
309
|
+
return {
|
|
310
|
+
status: "configured",
|
|
311
|
+
...(backupPath === undefined ? {} : { backupPath }),
|
|
312
|
+
};
|
|
313
|
+
};
|
|
314
|
+
/** Transactionally install or upgrade the versioned canonical REA skill. */
|
|
315
|
+
export const installCanonicalSkill = async (home) => {
|
|
188
316
|
const destination = join(home, ".agents/skills", PRODUCT_IDENTITY.skillName, "SKILL.md");
|
|
317
|
+
const backup = `${destination}.rea.backup`;
|
|
318
|
+
let original;
|
|
189
319
|
try {
|
|
190
320
|
const content = await readFile(new URL(`../../skills/${PRODUCT_IDENTITY.skillName}/SKILL.md`, import.meta.url), "utf8");
|
|
191
|
-
|
|
321
|
+
original = await readFile(destination, "utf8").catch(() => undefined);
|
|
322
|
+
if (original === content)
|
|
192
323
|
return "unchanged";
|
|
193
324
|
await mkdir(dirname(destination), { recursive: true });
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
325
|
+
if (original !== undefined)
|
|
326
|
+
await writeFileAtomic(backup, original, {
|
|
327
|
+
encoding: "utf8",
|
|
328
|
+
mode: 0o600,
|
|
329
|
+
});
|
|
330
|
+
await writeFileAtomic(destination, content, {
|
|
331
|
+
encoding: "utf8",
|
|
332
|
+
mode: 0o600,
|
|
333
|
+
});
|
|
334
|
+
if ((await readFile(destination, "utf8")) !== content)
|
|
335
|
+
throw new Error("skill readback mismatch");
|
|
336
|
+
return "installed";
|
|
198
337
|
}
|
|
199
338
|
catch {
|
|
339
|
+
try {
|
|
340
|
+
if (original === undefined)
|
|
341
|
+
await rm(destination, { force: true });
|
|
342
|
+
else
|
|
343
|
+
await writeFileAtomic(destination, original, {
|
|
344
|
+
encoding: "utf8",
|
|
345
|
+
mode: 0o600,
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
catch {
|
|
349
|
+
// The backup remains beside the skill for explicit operator recovery.
|
|
350
|
+
}
|
|
200
351
|
return "failed";
|
|
201
352
|
}
|
|
202
353
|
};
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { copyFile, lstat, readFile, rm } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { basename, isAbsolute, join } from "node:path";
|
|
4
|
+
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
|
|
5
|
+
import writeFileAtomic from "write-file-atomic";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { PRODUCT_IDENTITY } from "../identity.js";
|
|
8
|
+
import { supportedClients } from "./Setup.js";
|
|
9
|
+
/** Remove only REA-owned registrations and managed files, optionally including local state. */
|
|
10
|
+
export const runUninstall = async (purgeData, host = systemUninstallHost()) => {
|
|
11
|
+
const items = [];
|
|
12
|
+
for (const client of await host.clients())
|
|
13
|
+
items.push(await host.removeClient(client));
|
|
14
|
+
items.push(await host.removeSkill());
|
|
15
|
+
if (purgeData)
|
|
16
|
+
items.push(...(await host.purgeData()));
|
|
17
|
+
items.push({
|
|
18
|
+
name: "analysis_engine",
|
|
19
|
+
status: "retained",
|
|
20
|
+
detail: "Hopper is not owned by REA uninstall.",
|
|
21
|
+
});
|
|
22
|
+
return {
|
|
23
|
+
status: items.some(({ status }) => status === "failed")
|
|
24
|
+
? "failed"
|
|
25
|
+
: "complete",
|
|
26
|
+
items,
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
/** Create uninstall effects contained to detected client configs and REA-owned paths. */
|
|
30
|
+
export const systemUninstallHost = (home = homedir()) => ({
|
|
31
|
+
clients: () => Promise.resolve(supportedClients(home)),
|
|
32
|
+
removeClient,
|
|
33
|
+
removeSkill: () => removeManagedPath(join(home, ".agents/skills", PRODUCT_IDENTITY.skillName), "skill"),
|
|
34
|
+
purgeData: async () => [
|
|
35
|
+
await removeManagedPath(join(home, ".rea/cache"), "cache"),
|
|
36
|
+
await removeManagedPath(join(home, ".rea/state"), "state"),
|
|
37
|
+
],
|
|
38
|
+
});
|
|
39
|
+
const removeClient = async (client) => {
|
|
40
|
+
if (client.format === "unsupported")
|
|
41
|
+
return item(client.name, "skipped", "This client has no documented local MCP configuration boundary.");
|
|
42
|
+
let original;
|
|
43
|
+
try {
|
|
44
|
+
original = await readFile(client.configPath, "utf8");
|
|
45
|
+
}
|
|
46
|
+
catch (cause) {
|
|
47
|
+
return isMissing(cause)
|
|
48
|
+
? item(client.name, "skipped", "Configuration does not exist.")
|
|
49
|
+
: item(client.name, "failed", "Configuration could not be read.");
|
|
50
|
+
}
|
|
51
|
+
let document;
|
|
52
|
+
let key;
|
|
53
|
+
let servers;
|
|
54
|
+
try {
|
|
55
|
+
document = objectSchema.parse(client.format === "toml" ? parseToml(original) : JSON.parse(original));
|
|
56
|
+
key = client.format === "toml" ? "mcp_servers" : "mcpServers";
|
|
57
|
+
servers = optionalObject(document[key]);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return item(client.name, "failed", "Malformed configuration was preserved without mutation.");
|
|
61
|
+
}
|
|
62
|
+
const registration = servers[PRODUCT_IDENTITY.mcpServerKey];
|
|
63
|
+
if (registration === undefined)
|
|
64
|
+
return item(client.name, "skipped", "REA registration is absent.");
|
|
65
|
+
if (!isOwnedRegistration(registration))
|
|
66
|
+
return item(client.name, "retained", "The rea-named registration is not REA-owned.");
|
|
67
|
+
const remaining = { ...servers };
|
|
68
|
+
delete remaining[PRODUCT_IDENTITY.mcpServerKey];
|
|
69
|
+
document[key] = remaining;
|
|
70
|
+
const backupPath = `${client.configPath}.rea.backup`;
|
|
71
|
+
try {
|
|
72
|
+
await copyFile(client.configPath, backupPath);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return item(client.name, "failed", "Configuration backup failed; no mutation was attempted.");
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
const encoded = client.format === "toml"
|
|
79
|
+
? stringifyToml(document)
|
|
80
|
+
: `${JSON.stringify(document, null, 2)}\n`;
|
|
81
|
+
await writeFileAtomic(client.configPath, encoded, { encoding: "utf8" });
|
|
82
|
+
const readback = objectSchema.parse(client.format === "toml"
|
|
83
|
+
? parseToml(await readFile(client.configPath, "utf8"))
|
|
84
|
+
: JSON.parse(await readFile(client.configPath, "utf8")));
|
|
85
|
+
if (PRODUCT_IDENTITY.mcpServerKey in optionalObject(readback[key]))
|
|
86
|
+
throw new Error("registration readback mismatch");
|
|
87
|
+
return item(client.name, "removed", `Removed registration from ${client.configPath}.`);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
await writeFileAtomic(client.configPath, original, {
|
|
91
|
+
encoding: "utf8",
|
|
92
|
+
}).catch(() => undefined);
|
|
93
|
+
return item(client.name, "failed", `Configuration update failed; the original was restored and ${backupPath} was retained.`);
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
/** Ownership rule for persistent MCP registrations created by REA setup. */
|
|
97
|
+
const isOwnedRegistration = (value) => {
|
|
98
|
+
const parsed = registrationSchema.safeParse(value);
|
|
99
|
+
if (!parsed.success)
|
|
100
|
+
return false;
|
|
101
|
+
const { command, args } = parsed.data;
|
|
102
|
+
return (((command === "rea" ||
|
|
103
|
+
(isAbsolute(command) && basename(command) === "rea")) &&
|
|
104
|
+
args.length === 1 &&
|
|
105
|
+
args[0] === "mcp") ||
|
|
106
|
+
(command === "npx" &&
|
|
107
|
+
JSON.stringify(args) ===
|
|
108
|
+
JSON.stringify(["-y", PRODUCT_IDENTITY.packageName, "mcp"])));
|
|
109
|
+
};
|
|
110
|
+
const removeManagedPath = async (path, name) => {
|
|
111
|
+
try {
|
|
112
|
+
const stats = await lstat(path);
|
|
113
|
+
if (stats.isSymbolicLink())
|
|
114
|
+
return item(name, "retained", `Refused to follow symlink ${path}.`);
|
|
115
|
+
await rm(path, { recursive: true });
|
|
116
|
+
return item(name, "removed", `Removed ${path}.`);
|
|
117
|
+
}
|
|
118
|
+
catch (cause) {
|
|
119
|
+
return isMissing(cause)
|
|
120
|
+
? item(name, "skipped", `${path} does not exist.`)
|
|
121
|
+
: item(name, "failed", `${path} could not be removed.`);
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
const item = (name, status, detail) => ({ name, status, detail });
|
|
125
|
+
const objectSchema = z.record(z.string(), z.unknown());
|
|
126
|
+
const optionalObject = (value) => value === undefined ? {} : objectSchema.parse(value);
|
|
127
|
+
const registrationSchema = z
|
|
128
|
+
.object({ command: z.string(), args: z.array(z.string()) })
|
|
129
|
+
.passthrough();
|
|
130
|
+
const isMissing = (cause) => cause instanceof Error && "code" in cause && cause.code === "ENOENT";
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { BinarySession } from "./BinarySession.js";
|
|
2
2
|
import { HopperProvider } from "../hopper/HopperProvider.js";
|
|
3
|
+
import { NativeMacOSProvider } from "../native/NativeMacOSProvider.js";
|
|
4
|
+
import { ArtifactProvider } from "../artifacts/ArtifactProvider.js";
|
|
5
|
+
import { CompositeProvider } from "./CompositeProvider.js";
|
|
3
6
|
import { silentLogger } from "../logger.js";
|
|
4
7
|
/**
|
|
5
8
|
* Compose the target-switching runtime shared directly by CLI and MCP adapters.
|
|
@@ -7,5 +10,9 @@ import { silentLogger } from "../logger.js";
|
|
|
7
10
|
* out to the other and both retain identical loader and bridge semantics.
|
|
8
11
|
*/
|
|
9
12
|
export const createBinarySession = (config, logger = silentLogger) => {
|
|
10
|
-
return new BinarySession(new
|
|
13
|
+
return new BinarySession(new CompositeProvider([
|
|
14
|
+
new ArtifactProvider(),
|
|
15
|
+
new NativeMacOSProvider(),
|
|
16
|
+
new HopperProvider(config, logger),
|
|
17
|
+
]));
|
|
11
18
|
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { Buffer } from "node:buffer";
|
|
2
|
+
import { posix } from "node:path";
|
|
3
|
+
import { ArtifactReaderFailure, } from "./ArtifactReader.js";
|
|
4
|
+
const DRIVE_OR_UNC = /^(?:[A-Za-z]:|\\|\/\/)/u;
|
|
5
|
+
/** Normalize an untrusted logical archive path without touching filesystem. */
|
|
6
|
+
export const normalizeArtifactPath = (input, limits) => {
|
|
7
|
+
if (input.includes("\0") ||
|
|
8
|
+
input.includes("\\") ||
|
|
9
|
+
input.startsWith("/") ||
|
|
10
|
+
DRIVE_OR_UNC.test(input))
|
|
11
|
+
throw new ArtifactReaderFailure("path", "Artifact path is absolute or unsafe");
|
|
12
|
+
const normalized = input.normalize("NFC").replace(/\/+$/u, "");
|
|
13
|
+
const parts = normalized.split("/");
|
|
14
|
+
if (normalized.length === 0 ||
|
|
15
|
+
parts.some((part) => part === "" || part === "." || part === "..") ||
|
|
16
|
+
parts.length > limits.maxDepth ||
|
|
17
|
+
Buffer.byteLength(normalized, "utf8") > limits.maxPathBytes ||
|
|
18
|
+
posix.normalize(normalized) !== normalized)
|
|
19
|
+
throw new ArtifactReaderFailure("path", "Artifact path is not normalized");
|
|
20
|
+
return normalized;
|
|
21
|
+
};
|
|
22
|
+
/** Reject exact, Unicode-normalized, case-folded, and prefix collisions. */
|
|
23
|
+
export class ArtifactPathRegistry {
|
|
24
|
+
#root = { kind: undefined, children: new Map() };
|
|
25
|
+
#folded = new Set();
|
|
26
|
+
add(path, kind) {
|
|
27
|
+
const folded = path.toLocaleLowerCase("en-US");
|
|
28
|
+
if (this.#folded.has(folded))
|
|
29
|
+
throw new ArtifactReaderFailure("path", `Artifact path collision: ${path}`);
|
|
30
|
+
const parts = path.split("/");
|
|
31
|
+
let node = this.#root;
|
|
32
|
+
for (const [index, part] of parts.entries()) {
|
|
33
|
+
if (node.kind !== undefined && node.kind !== "directory")
|
|
34
|
+
throw new ArtifactReaderFailure("path", `Artifact prefix conflict: ${path}`);
|
|
35
|
+
let child = node.children.get(part);
|
|
36
|
+
if (child === undefined) {
|
|
37
|
+
child = { kind: undefined, children: new Map() };
|
|
38
|
+
node.children.set(part, child);
|
|
39
|
+
}
|
|
40
|
+
node = child;
|
|
41
|
+
if (index === parts.length - 1) {
|
|
42
|
+
if (node.kind !== undefined)
|
|
43
|
+
throw new ArtifactReaderFailure("path", `Artifact path collision: ${path}`);
|
|
44
|
+
if (kind !== "directory" && node.children.size > 0)
|
|
45
|
+
throw new ArtifactReaderFailure("path", `Artifact prefix conflict: ${path}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
node.kind = kind;
|
|
49
|
+
this.#folded.add(folded);
|
|
50
|
+
}
|
|
51
|
+
}
|