diff --git a/package-lock.json b/package-lock.json index 873ddc8bb..d059a0ee0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,12 +7,13 @@ "": { "name": "rea-agents", "version": "1.3.0", + "hasInstallScript": true, "license": "MIT", "dependencies": { "@babel/parser": "8.0.4", "@babel/types": "8.0.4", "@electron/asar": "4.2.0", - "@modelcontextprotocol/server": "2.0.0-beta.3", + "@modelcontextprotocol/server": "2.0.0-beta.4", "@xterm/addon-serialize": "0.14.0", "@xterm/headless": "6.0.0", "@zip.js/zip.js": "2.8.26", @@ -36,7 +37,7 @@ }, "devDependencies": { "@fast-check/vitest": "0.4.1", - "@modelcontextprotocol/client": "2.0.0-beta.3", + "@modelcontextprotocol/client": "2.0.0-beta.4", "@types/node": "24.10.1", "@types/write-file-atomic": "4.0.3", "@types/ws": "8.18.1", @@ -245,12 +246,13 @@ } }, "node_modules/@modelcontextprotocol/client": { - "version": "2.0.0-beta.3", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.0.0-beta.3.tgz", - "integrity": "sha512-o9z9YCGNxWNdklPdjcD0tr8pocC9OgjdArqgx3OjPMlTe0M/SSPC2olOJs5ueq77HSpz36mND0f0KteqEhKF3A==", + "version": "2.0.0-beta.4", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.0.0-beta.4.tgz", + "integrity": "sha512-VNHA/UXDk7mCpVl+jOg5B4WMRRD2OEl+it360Lhi6HiCbrIB2f6pZ0cqSDKXQVUtZvqnhdQHzZAM9h8EKWhq/A==", "dev": true, "license": "MIT", "dependencies": { + "@modelcontextprotocol/core": "2.0.0-beta.4", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", @@ -262,10 +264,10 @@ "node": ">=20" } }, - "node_modules/@modelcontextprotocol/server": { - "version": "2.0.0-beta.3", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0-beta.3.tgz", - "integrity": "sha512-EQxVIWf2XMAgjR38ZRZnX1iiOWH42tT/YJG8N4dVjAnrEGSEfPirR6FYfDC72yuNSvVYbPDTD1H3KHLLHPd18A==", + "node_modules/@modelcontextprotocol/core": { + "version": "2.0.0-beta.4", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.0.0-beta.4.tgz", + "integrity": "sha512-nsMXd4wQBKzmph6r+WOhum+mXjDYljTAqwY/XUg3hLtvNOQ8+JVqBSJOVCMJvx9lXhTpTOPrGZ3BuNiaNPjSvg==", "license": "MIT", "dependencies": { "zod": "^4.2.0" @@ -274,6 +276,19 @@ "node": ">=20" } }, + "node_modules/@modelcontextprotocol/server": { + "version": "2.0.0-beta.4", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0-beta.4.tgz", + "integrity": "sha512-pjMZcNEt1dOq0aJCcY3b7w1Ayh+qmQN2xlfTELcGV9yiAjEGIczBPBLWWW0k0zzo5T8kiv15jtKPsWeHGxLvLg==", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/core": "2.0.0-beta.4", + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", diff --git a/package.json b/package.json index a2ecdda0f..1c198d47a 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "LICENSE" ], "scripts": { + "prebuild": "node scripts/generate-package-metadata.mjs", "build": "tsc -p tsconfig.build.json", "check": "npm run typecheck && npm run lint && npm run format:check && npm test", "format": "prettier --write .", @@ -76,7 +77,8 @@ "verify:package": "npm run build && node scripts/verify-package.mjs", "verify:published": "node scripts/verify-published-package.mjs", "config:print": "node scripts/print-mcp-config.mjs", - "docs:generate": "typedoc", + "docs:generate": "npm run build && typedoc && node scripts/generate-error-schema.mjs", + "docs:error-schema": "npm run build && node scripts/generate-error-schema.mjs", "prepack": "npm run build", "prepare": "husky", "rebuild:native": "node scripts/rebuild-native.mjs" @@ -89,7 +91,7 @@ "@babel/parser": "8.0.4", "@babel/types": "8.0.4", "@electron/asar": "4.2.0", - "@modelcontextprotocol/server": "2.0.0-beta.3", + "@modelcontextprotocol/server": "2.0.0-beta.4", "@xterm/addon-serialize": "0.14.0", "@xterm/headless": "6.0.0", "@zip.js/zip.js": "2.8.26", @@ -110,7 +112,7 @@ }, "devDependencies": { "@fast-check/vitest": "0.4.1", - "@modelcontextprotocol/client": "2.0.0-beta.3", + "@modelcontextprotocol/client": "2.0.0-beta.4", "@types/node": "24.10.1", "@types/write-file-atomic": "4.0.3", "@types/ws": "8.18.1", diff --git a/scripts/generate-package-metadata.mjs b/scripts/generate-package-metadata.mjs new file mode 100644 index 000000000..aa15cbe54 --- /dev/null +++ b/scripts/generate-package-metadata.mjs @@ -0,0 +1,35 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); +const packageJson = JSON.parse( + await readFile(join(root, "package.json"), "utf8"), +); +const lock = JSON.parse( + await readFile(join(root, "package-lock.json"), "utf8"), +); +const skill = await readFile( + join(root, "skills/rea-analysis/SKILL.md"), + "utf8", +); +const skillVersion = /^\s{2}version:\s*"([^"]+)"\s*$/mu.exec(skill)?.[1]; +if (skillVersion === undefined) + throw new Error("Missing rea-analysis skill metadata version"); +const versionOf = (name) => { + const value = lock.packages?.[`node_modules/${name}`]?.version; + if (typeof value !== "string") + throw new Error(`Missing locked ${name} version`); + return value; +}; +const source = `/** Generated from package.json and package-lock.json; do not edit. */ +export const PACKAGE_METADATA = { + name: ${JSON.stringify(packageJson.name)}, + version: ${JSON.stringify(packageJson.version)}, + serverSdkVersion: ${JSON.stringify(versionOf("@modelcontextprotocol/server"))}, + clientSdkVersion: ${JSON.stringify(versionOf("@modelcontextprotocol/client"))}, + coreSdkVersion: ${JSON.stringify(versionOf("@modelcontextprotocol/core"))}, + skillVersion: ${JSON.stringify(skillVersion)}, +} as const; +`; +await writeFile(join(root, "src/generatedPackageMetadata.ts"), source, "utf8"); diff --git a/src/application/CapabilityInventory.ts b/src/application/CapabilityInventory.ts new file mode 100644 index 000000000..46bdbab0b --- /dev/null +++ b/src/application/CapabilityInventory.ts @@ -0,0 +1,193 @@ +import { z } from "zod"; + +import { TOOL_CONTRACTS } from "../contracts/toolContracts.js"; +import type { JsonValue } from "../domain/jsonValue.js"; + +const statusSchema = z.object({ + open: z.boolean(), + kind: z.enum(["executable", "database", "archive", "artifact"]).optional(), + format: z.string().optional(), + capabilities: z.array( + z.object({ + operation: z.string(), + available: z.boolean(), + reason: z.string().nullable(), + }), + ), +}); + +export type ToolAvailabilityReason = + | "available" + | "target_required" + | "provider_missing" + | "provider_unavailable" + | "target_unsupported" + | "unsupported_host" + | "policy_disabled"; + +const ENHANCED_REQUIREMENTS: Readonly> = { + swift_classes: ["list_procedures"], + get_objc_classes: ["list_names"], + get_objc_protocols: ["list_names"], + batch_decompile: ["procedure_pseudo_code"], + get_call_graph: ["procedure_callees", "procedure_callers"], + analyze_swift_types: ["list_procedures"], + find_xrefs_to_name: ["list_names", "xrefs"], + binary_overview: [ + "list_segments", + "list_documents", + "list_procedures", + "list_strings", + ], + analyze_function: ["analyze_function"], + trace_feature: [ + "list_strings", + "list_procedures", + "xrefs", + "resolve_containing_procedure", + ], +}; + +/** Build stable per-operation availability without hiding familiar tools. */ +export const buildCapabilityInventory = ( + sessionStatus: JsonValue, + policy: { + readonly processCaptureEnabled: boolean; + readonly evidenceFileRoots: number; + }, +) => { + const status = statusSchema.parse(sessionStatus); + const descriptors = new Map( + status.capabilities.map((descriptor) => [descriptor.operation, descriptor]), + ); + return TOOL_CONTRACTS.map((contract) => { + const availability = availabilityFor( + contract.name, + contract.kind, + status.open, + status.kind, + descriptors, + policy, + ); + return { + name: contract.name, + surface: contract.kind, + available: availability.reason === "available", + reason: availability.reason, + remediation: availability.remediation, + annotations: { + read_only: contract.annotations.readOnlyHint ?? false, + destructive: contract.annotations.destructiveHint ?? false, + idempotent: contract.annotations.idempotentHint ?? false, + open_world: contract.annotations.openWorldHint ?? true, + }, + }; + }).sort((left, right) => left.name.localeCompare(right.name)); +}; + +const availabilityFor = ( + name: string, + kind: (typeof TOOL_CONTRACTS)[number]["kind"], + targetOpen: boolean, + targetKind: "executable" | "database" | "archive" | "artifact" | undefined, + descriptors: ReadonlyMap< + string, + { readonly available: boolean; readonly reason: string | null } + >, + policy: { + readonly processCaptureEnabled: boolean; + readonly evidenceFileRoots: number; + }, +): { + readonly reason: ToolAvailabilityReason; + readonly remediation: string | null; +} => { + if (name === "capture_process_scenario" && !policy.processCaptureEnabled) + return { + reason: "policy_disabled", + remediation: + "Enable or grant process_capture within the administrator ceiling.", + }; + if (name === "import_evidence_bundle" && policy.evidenceFileRoots === 0) + return { + reason: "policy_disabled", + remediation: "Configure or grant an evidence_read root.", + }; + if (kind === "session") return { reason: "available", remediation: null }; + if (!targetOpen) + return { + reason: "target_required", + remediation: "Call open_binary with a supported local target.", + }; + if ( + targetKind !== undefined && + targetKind !== "executable" && + targetKind !== "database" && + (kind === "official-proxy" || kind === "enhanced") + ) + return { + reason: "target_unsupported", + remediation: + "Inventory or extract a native executable, then call open_binary on that executable.", + }; + if (kind === "enhanced") return composedAvailability(name, descriptors); + const descriptor = descriptors.get(name); + if (descriptor === undefined) + return { + reason: "provider_missing", + remediation: + "Install or configure a provider that declares this operation.", + }; + if ( + !descriptor.available && + descriptor.reason?.toLowerCase().includes("require macos") === true + ) + return { + reason: "unsupported_host", + remediation: descriptor.reason, + }; + return descriptor.available + ? { reason: "available", remediation: null } + : { + reason: "provider_unavailable", + remediation: + descriptor.reason ?? "Choose another target or configured provider.", + }; +}; + +const composedAvailability = ( + name: string, + descriptors: ReadonlyMap< + string, + { readonly available: boolean; readonly reason: string | null } + >, +): { + readonly reason: ToolAvailabilityReason; + readonly remediation: string | null; +} => { + const requirements = ENHANCED_REQUIREMENTS[name]; + if (requirements === undefined) + return { + reason: "provider_missing", + remediation: "No provider composition is declared for this operation.", + }; + const missing = requirements.find((operation) => !descriptors.has(operation)); + if (missing !== undefined) + return { + reason: "provider_missing", + remediation: `Configure a provider for required operation ${missing}.`, + }; + const unavailable = requirements + .map((operation) => descriptors.get(operation)) + .find((descriptor) => descriptor?.available === false); + if (unavailable !== undefined) + return { + reason: + unavailable.reason?.toLowerCase().includes("require macos") === true + ? "unsupported_host" + : "provider_unavailable", + remediation: + unavailable.reason ?? "Choose another target or configured provider.", + }; + return { reason: "available", remediation: null }; +}; diff --git a/src/application/ClientRegistrationStatus.ts b/src/application/ClientRegistrationStatus.ts new file mode 100644 index 000000000..fccdb007e --- /dev/null +++ b/src/application/ClientRegistrationStatus.ts @@ -0,0 +1,119 @@ +import { access, readFile } from "node:fs/promises"; +import { resolve } from "node:path"; + +import { parse as parseToml } from "smol-toml"; +import { z } from "zod"; + +import { PRODUCT_IDENTITY } from "../identity.js"; +import { supportedClients } from "./SupportedClients.js"; + +export interface ClientRegistrationStatus { + readonly client: string; + readonly config_path: string; + readonly command: readonly string[]; + readonly state: "aligned" | "stale" | "missing" | "invalid"; + readonly remediation: string | null; +} + +const objectSchema = z.record(z.string(), z.unknown()); +const registrationSchema = z + .object({ command: z.string().min(1), args: z.array(z.string()).default([]) }) + .passthrough(); + +/** Inspect supported client registrations without reading their environment. */ +export const readClientRegistrationStatuses = async ( + home: string, + currentCommandPath: string = resolve(process.argv[1] ?? "unknown"), +): Promise => { + const statuses: ClientRegistrationStatus[] = []; + for (const client of supportedClients(home)) { + if (client.format === "unsupported" || !(await exists(client.markerPath))) + continue; + try { + const content = await readFile(client.configPath, "utf8"); + const document = objectSchema.parse( + client.format === "toml" ? parseToml(content) : JSON.parse(content), + ); + const servers = objectSchema.parse( + document[client.format === "toml" ? "mcp_servers" : "mcpServers"] ?? {}, + ); + const raw = servers[PRODUCT_IDENTITY.mcpServerKey]; + if (raw === undefined) { + statuses.push(status(client.name, client.configPath, [], "missing")); + continue; + } + const registration = registrationSchema.parse(raw); + const command = [registration.command, ...registration.args]; + statuses.push( + status( + client.name, + client.configPath, + command, + registrationAligned(command, currentCommandPath) + ? "aligned" + : "stale", + ), + ); + } catch (cause: unknown) { + statuses.push( + status( + client.name, + client.configPath, + [], + isMissing(cause) ? "missing" : "invalid", + ), + ); + } + } + return statuses.sort((left, right) => + left.client.localeCompare(right.client), + ); +}; + +const registrationAligned = ( + command: readonly string[], + currentCommandPath: string, +): boolean => { + if ( + command.length === 4 && + command[0] === "npx" && + command[1] === "-y" && + command[2] === PRODUCT_IDENTITY.packageName && + command[3] === "mcp" + ) + return true; + return ( + command.length === 2 && + command[1] === "mcp" && + resolve(command[0] ?? "") === currentCommandPath + ); +}; + +const status = ( + client: string, + configPath: string, + command: readonly string[], + state: ClientRegistrationStatus["state"], +): ClientRegistrationStatus => ({ + client, + config_path: configPath, + command, + state, + remediation: + state === "aligned" + ? null + : "Run rea setup to refresh this registration, then restart the client.", +}); + +const exists = async (path: string | undefined): Promise => { + if (path === undefined) return false; + try { + await access(path); + return true; + } catch { + return false; + } +}; + +const isMissing = (cause: unknown): boolean => + cause instanceof Error && "code" in cause && cause.code === "ENOENT"; diff --git a/src/application/Doctor.ts b/src/application/Doctor.ts index e76668bd4..0226aaa98 100644 --- a/src/application/Doctor.ts +++ b/src/application/Doctor.ts @@ -1,6 +1,5 @@ import { constants } from "node:fs"; -import { access } from "node:fs/promises"; -import { readdir } from "node:fs/promises"; +import { access, readFile, readdir } from "node:fs/promises"; import { homedir } from "node:os"; import { join } from "node:path"; import { execFile } from "node:child_process"; @@ -16,6 +15,12 @@ import { readLinuxDistribution, type LinuxDistribution, } from "./LinuxHopper.js"; +import { CATALOG_IDENTITY } from "../catalogIdentity.js"; +import { PRODUCT_IDENTITY, SDK_IDENTITY } from "../identity.js"; +import { + readClientRegistrationStatuses, + type ClientRegistrationStatus, +} from "./ClientRegistrationStatus.js"; const execFileAsync = promisify(execFile); const DEFAULT_HOPPER = @@ -48,6 +53,9 @@ export interface DoctorHost { linuxDemoRuntimeReady(): Promise; brewHopperPath(): Promise; manualHopperPaths(): Promise; + installationPaths?(): Promise; + installedSkillVersion?(): Promise; + clientRegistrations?(): Promise; } /** @@ -62,6 +70,26 @@ export const runDoctor = async ( readonly healthy: boolean; readonly hopperPath?: string; readonly checks: readonly DoctorCheck[]; + readonly identity?: { + readonly cli_package_version: string; + readonly expected_skill_version: string; + readonly sdk: typeof SDK_IDENTITY; + readonly catalog: typeof CATALOG_IDENTITY; + readonly live_server: { + readonly state: "unknown"; + readonly remediation: string; + }; + readonly installations: { + readonly paths: readonly string[]; + readonly state: "single" | "multiple" | "unknown"; + }; + readonly skill: { + readonly installed_version: string | null; + readonly state: "aligned" | "stale" | "unknown"; + readonly remediation: string | null; + }; + readonly registrations: readonly ClientRegistrationStatus[]; + }; }> => { const checks: DoctorCheck[] = []; checks.push( @@ -148,10 +176,59 @@ export const runDoctor = async ( classification: "config_drift", }), ); + const installationPaths = (await host.installationPaths?.()) ?? []; + const installedSkillVersion = await host.installedSkillVersion?.(); + const registrations = (await host.clientRegistrations?.()) ?? []; + for (const registration of registrations) + if (registration.state !== "aligned") + checks.push({ + name: `registration:${registration.client}`, + ok: false, + classification: "config_drift", + detail: registration.config_path, + remediation: + registration.remediation ?? + "Run rea setup, then restart the affected client.", + }); return { healthy: checks.every(({ ok }) => ok), ...(hopperPath === undefined ? {} : { hopperPath }), checks, + identity: { + cli_package_version: PRODUCT_IDENTITY.packageVersion, + expected_skill_version: PRODUCT_IDENTITY.skillVersion, + sdk: SDK_IDENTITY, + catalog: CATALOG_IDENTITY, + live_server: { + state: "unknown", + remediation: + "Compare rea://server/identity or binary_session from the active client; an on-disk registration cannot prove the running server version.", + }, + installations: { + paths: installationPaths, + state: + installationPaths.length === 0 + ? "unknown" + : installationPaths.length === 1 + ? "single" + : "multiple", + }, + skill: { + installed_version: installedSkillVersion ?? null, + state: + installedSkillVersion === undefined + ? "unknown" + : installedSkillVersion === PRODUCT_IDENTITY.skillVersion + ? "aligned" + : "stale", + remediation: + installedSkillVersion === undefined || + installedSkillVersion === PRODUCT_IDENTITY.skillVersion + ? null + : "Run rea setup to update the installed REA skill.", + }, + registrations, + }, }; }; @@ -241,10 +318,44 @@ export const systemDoctorHost = (): DoctorHost => ({ } return paths; }, + async installationPaths() { + try { + const command = process.platform === "win32" ? "where" : "which"; + const arguments_ = process.platform === "win32" ? ["rea"] : ["-a", "rea"]; + return uniqueLines((await execFileAsync(command, arguments_)).stdout); + } catch { + return []; + } + }, + async installedSkillVersion() { + try { + const content = await readFile( + join( + homedir(), + ".agents/skills", + PRODUCT_IDENTITY.skillName, + "SKILL.md", + ), + "utf8", + ); + return /^\s{2}version:\s*"([^"]+)"\s*$/mu.exec(content)?.[1]; + } catch { + return undefined; + } + }, + clientRegistrations: () => readClientRegistrationStatuses(homedir()), }); const parseMajor = (version: string): number => Number.parseInt(version.split(".")[0] ?? "0", 10); +const uniqueLines = (value: string): string[] => [ + ...new Set( + value + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter((line) => line.length > 0), + ), +]; const check = ( name: string, ok: boolean, diff --git a/src/application/Setup.ts b/src/application/Setup.ts index e7ca57446..639f71d72 100644 --- a/src/application/Setup.ts +++ b/src/application/Setup.ts @@ -8,7 +8,7 @@ import { writeFile, } from "node:fs/promises"; import { homedir } from "node:os"; -import { dirname, join, resolve } from "node:path"; +import { dirname, resolve } from "node:path"; import { z } from "zod"; import writeFileAtomic from "write-file-atomic"; import { parse as parseToml, stringify as stringifyToml } from "smol-toml"; @@ -23,6 +23,8 @@ import { } from "./LinuxHopper.js"; import { installMacHopper } from "./MacHopper.js"; import { configureDetectedClients } from "./SetupClients.js"; +import { supportedClients, type SetupClient } from "./SupportedClients.js"; +export type { SetupClient } from "./SupportedClients.js"; import { installCanonicalSkill } from "./SetupSkill.js"; import { setupPlan } from "./SetupPlan.js"; import { @@ -39,13 +41,6 @@ const registrationCommand = (): readonly string[] => ? ["npx", "-y", PRODUCT_IDENTITY.packageName, "mcp"] : [resolve(process.argv[1] ?? PRODUCT_IDENTITY.cliBinary), "mcp"]; -/** A detected agent configuration owned by setup. */ -export interface SetupClient { - readonly name: string; - readonly configPath: string; - readonly markerPath?: string; - readonly format?: "json" | "toml" | "unsupported"; -} /** Result of one backup/write/readback transaction. */ export type ClientConfigurationResult = | { @@ -282,50 +277,6 @@ export const detectClients = async ( return detected; }; -/** Describe every client location that setup or uninstall may own. */ -export const supportedClients = (home: string): readonly SetupClient[] => [ - { - name: "claude_code", - configPath: join(home, ".claude.json"), - markerPath: join(home, ".claude"), - }, - { - name: "claude_desktop", - configPath: join( - home, - "Library/Application Support/Claude/claude_desktop_config.json", - ), - markerPath: join(home, "Library/Application Support/Claude"), - }, - { - name: "codex", - configPath: join(home, ".codex/config.toml"), - markerPath: join(home, ".codex"), - format: "toml" as const, - }, - { - name: "cursor", - configPath: join(home, ".cursor/mcp.json"), - markerPath: join(home, ".cursor"), - }, - { - name: "gemini_cli", - configPath: join(home, ".gemini/settings.json"), - markerPath: join(home, ".gemini"), - }, - { - name: "windsurf", - configPath: join(home, ".codeium/windsurf/mcp_config.json"), - markerPath: join(home, ".codeium/windsurf"), - }, - { - name: "devin", - configPath: join(home, ".devin"), - markerPath: join(home, ".devin"), - format: "unsupported" as const, - }, -]; - /** Back up, atomically update, and semantically read back one JSON MCP configuration. */ export const configureJsonClient = async ( client: SetupClient, diff --git a/src/application/SupportedClients.ts b/src/application/SupportedClients.ts new file mode 100644 index 000000000..1c281bb6b --- /dev/null +++ b/src/application/SupportedClients.ts @@ -0,0 +1,53 @@ +import { join } from "node:path"; + +/** One supported client configuration location. */ +export interface SetupClient { + readonly name: string; + readonly configPath: string; + readonly markerPath?: string; + readonly format?: "json" | "toml" | "unsupported"; +} + +/** Describe every client location that setup, doctor, or uninstall may inspect. */ +export const supportedClients = (home: string): readonly SetupClient[] => [ + { + name: "claude_code", + configPath: join(home, ".claude.json"), + markerPath: join(home, ".claude"), + }, + { + name: "claude_desktop", + configPath: join( + home, + "Library/Application Support/Claude/claude_desktop_config.json", + ), + markerPath: join(home, "Library/Application Support/Claude"), + }, + { + name: "codex", + configPath: join(home, ".codex/config.toml"), + markerPath: join(home, ".codex"), + format: "toml", + }, + { + name: "cursor", + configPath: join(home, ".cursor/mcp.json"), + markerPath: join(home, ".cursor"), + }, + { + name: "gemini_cli", + configPath: join(home, ".gemini/settings.json"), + markerPath: join(home, ".gemini"), + }, + { + name: "windsurf", + configPath: join(home, ".codeium/windsurf/mcp_config.json"), + markerPath: join(home, ".codeium/windsurf"), + }, + { + name: "devin", + configPath: join(home, ".devin"), + markerPath: join(home, ".devin"), + format: "unsupported", + }, +]; diff --git a/src/application/Uninstall.ts b/src/application/Uninstall.ts index ee8b3ee56..d9a15f7b9 100644 --- a/src/application/Uninstall.ts +++ b/src/application/Uninstall.ts @@ -7,7 +7,7 @@ import writeFileAtomic from "write-file-atomic"; import { z } from "zod"; import { PRODUCT_IDENTITY } from "../identity.js"; -import { supportedClients, type SetupClient } from "./Setup.js"; +import { supportedClients, type SetupClient } from "./SupportedClients.js"; interface ManagedPathStats { isSymbolicLink(): boolean; diff --git a/src/application/Upgrade.ts b/src/application/Upgrade.ts index 71428f268..f8a8b9704 100644 --- a/src/application/Upgrade.ts +++ b/src/application/Upgrade.ts @@ -52,6 +52,8 @@ export type UpgradeResult = readonly versionCheck: "available" | "unavailable"; readonly installMethod: "npm"; readonly command: typeof UPGRADE_COMMAND; + readonly clientRestartRequired: true; + readonly remediation: string; } | { readonly status: "failed"; @@ -102,6 +104,9 @@ export const runUpgrade = async ( versionCheck: latestVersion === undefined ? "unavailable" : "available", installMethod: "npm", command: UPGRADE_COMMAND, + clientRestartRequired: true, + remediation: + "Rerun rea setup to refresh registrations and the skill, then restart clients that may retain an older MCP server.", }; }; diff --git a/src/catalogIdentity.ts b/src/catalogIdentity.ts new file mode 100644 index 000000000..8af3269d2 --- /dev/null +++ b/src/catalogIdentity.ts @@ -0,0 +1,129 @@ +import { createHash } from "node:crypto"; + +import canonicalize from "canonicalize"; +import { z } from "zod"; + +import { PROMPT_CONTRACTS } from "./contracts/promptContracts.js"; +import { TOOL_CONTRACTS } from "./contracts/toolContracts.js"; + +/** Explicit CLI command inventory, kept separate from MCP tool counts. */ +export const CLI_COMMAND_NAMES = [ + "analyze", + "inspect", + "decompile", + "setup", + "doctor", + "uninstall", + "upgrade", + "xrefs", + "trace", + "capabilities", + "providers", + "function", + "search", + "import-reference-source", + "inventory-artifact", + "extract-artifact", + "inspect-macho", + "inspect-signature", + "list-architectures", + "inspect-plist", + "demangle-swift", + "evidence-import", + "evidence-export", + "compare", + "investigate-versions", + "capture-process", + "compare-process-captures", + "policy", +] as const; + +const toolCatalog = TOOL_CONTRACTS.map((contract) => ({ + name: contract.name, + surface: contract.kind, + description: contract.description, + annotations: contract.annotations, + input_schema: z.toJSONSchema(contract.inputSchema, { + unrepresentable: "any", + }), + output_schema: z.toJSONSchema(contract.outputSchema, { + unrepresentable: "any", + }), +})).sort((left, right) => left.name.localeCompare(right.name)); + +const promptCatalog = PROMPT_CONTRACTS.map((contract) => ({ + name: contract.name, + title: contract.title, + description: contract.description, + arguments: contract.arguments, + steps: contract.steps, +})).sort((left, right) => left.name.localeCompare(right.name)); + +const resourceCatalog = [ + { name: "server-identity", uri: "rea://server/identity" }, + { name: "active-residual-unknowns", uri: "rea://unknowns/active" }, +] as const; +const resourceTemplateCatalog = [ + { name: "session-evidence", uri_template: "rea://evidence/{evidenceId}" }, + { + name: "evidence-section", + uri_template: "rea://evidence/{evidenceId}/section/{section}", + }, + { name: "residual-unknown", uri_template: "rea://unknown/{unknownId}" }, + { + name: "analysis-snapshot", + uri_template: "rea://snapshot/{snapshotDigest}", + }, + { + name: "artifact-page", + uri_template: "rea://artifact/{manifestId}/{collection}", + }, + { + name: "function-dossier", + uri_template: "rea://function/{targetSha256}/{address}", + }, + { + name: "investigation-workspace-revision", + uri_template: "rea://workspace/{workspaceId}/revision/{revision}", + }, +] as const; + +const digest = (value: unknown): string => { + const encoded = canonicalize(value); + if (encoded === undefined) + throw new TypeError("Catalog is not canonical JSON"); + return createHash("sha256").update(encoded).digest("hex"); +}; + +/** Stable, schema-sensitive identity for every public REA surface. */ +export const CATALOG_IDENTITY = { + counts: { + cli_commands: CLI_COMMAND_NAMES.length, + mcp_tools: toolCatalog.length, + mcp_prompts: promptCatalog.length, + mcp_resources: resourceCatalog.length, + mcp_resource_templates: resourceTemplateCatalog.length, + }, + digests: { + tools_sha256: digest(toolCatalog), + prompts_sha256: digest(promptCatalog), + resources_sha256: digest({ resourceCatalog, resourceTemplateCatalog }), + combined_sha256: digest({ + cli: CLI_COMMAND_NAMES, + tools: toolCatalog, + prompts: promptCatalog, + resources: resourceCatalog, + resource_templates: resourceTemplateCatalog, + }), + }, + tools: toolCatalog.map(({ name, surface, annotations }) => ({ + name, + surface, + annotations: { + read_only: annotations.readOnlyHint ?? false, + destructive: annotations.destructiveHint ?? false, + idempotent: annotations.idempotentHint ?? false, + open_world: annotations.openWorldHint ?? true, + }, + })), +} as const; diff --git a/src/generatedPackageMetadata.ts b/src/generatedPackageMetadata.ts new file mode 100644 index 000000000..ebca249c2 --- /dev/null +++ b/src/generatedPackageMetadata.ts @@ -0,0 +1,9 @@ +/** Generated from package.json and package-lock.json; do not edit. */ +export const PACKAGE_METADATA = { + name: "rea-agents", + version: "1.3.0", + serverSdkVersion: "2.0.0-beta.4", + clientSdkVersion: "2.0.0-beta.4", + coreSdkVersion: "2.0.0-beta.4", + skillVersion: "12", +} as const; diff --git a/src/identity.ts b/src/identity.ts index 474cdc35d..6a56007e2 100644 --- a/src/identity.ts +++ b/src/identity.ts @@ -5,12 +5,20 @@ */ export const PRODUCT_IDENTITY = { displayName: "REA", - packageName: "rea-agents", - packageVersion: "1.1.0", + packageName: PACKAGE_METADATA.name, + packageVersion: PACKAGE_METADATA.version, cliBinary: "rea", mcpCommand: "npx -y rea-agents mcp", mcpServerKey: "rea", skillName: "rea-analysis", - skillVersion: "10", + skillVersion: PACKAGE_METADATA.skillVersion, configFileName: "rea.json", } as const; + +/** Exact SDK package identities, distinct from negotiated protocol version. */ +export const SDK_IDENTITY = { + server: PACKAGE_METADATA.serverSdkVersion, + client_test: PACKAGE_METADATA.clientSdkVersion, + core: PACKAGE_METADATA.coreSdkVersion, +} as const; +import { PACKAGE_METADATA } from "./generatedPackageMetadata.js"; diff --git a/src/serverIdentity.ts b/src/serverIdentity.ts new file mode 100644 index 000000000..fe1ff7871 --- /dev/null +++ b/src/serverIdentity.ts @@ -0,0 +1,100 @@ +import { dirname, basename, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { CATALOG_IDENTITY } from "./catalogIdentity.js"; +import { PRODUCT_IDENTITY, SDK_IDENTITY } from "./identity.js"; + +/** Optional observations supplied by a client comparing its expected server. */ +export interface ExpectedServerIdentity { + readonly package_version?: string; + readonly catalog_digest?: string; + readonly server_path?: string; +} + +export interface ConnectedClientIdentity { + readonly name: string; + readonly version: string; +} + +const packageRoot = (): string => { + const moduleDirectory = dirname(fileURLToPath(import.meta.url)); + return basename(moduleDirectory) === "src" && + basename(dirname(moduleDirectory)) === "dist" + ? resolve(moduleDirectory, "../..") + : resolve(moduleDirectory, ".."); +}; + +/** Build one runtime identity without claiming alignment from absent observations. */ +export const createServerIdentity = (input: { + readonly startedAt: string; + readonly expected?: ExpectedServerIdentity; + readonly client?: ConnectedClientIdentity; + readonly protocolVersion?: string | undefined; +}) => { + const serverPath = resolve(process.argv[1] ?? "unknown"); + const reasons: string[] = []; + if ( + input.expected?.package_version !== undefined && + input.expected.package_version !== PRODUCT_IDENTITY.packageVersion + ) + reasons.push("package_version_mismatch"); + if ( + input.expected?.catalog_digest !== undefined && + input.expected.catalog_digest !== CATALOG_IDENTITY.digests.combined_sha256 + ) + reasons.push("catalog_digest_mismatch"); + if ( + input.expected?.server_path !== undefined && + resolve(input.expected.server_path) !== serverPath + ) + reasons.push("registration_path_mismatch"); + const observedExpectation = + input.expected !== undefined && + Object.values(input.expected).some((value) => value !== undefined); + return { + package: { + name: PRODUCT_IDENTITY.packageName, + version: PRODUCT_IDENTITY.packageVersion, + root_path: packageRoot(), + build_commit: process.env.REA_BUILD_COMMIT ?? null, + }, + server: { + name: PRODUCT_IDENTITY.mcpServerKey, + version: PRODUCT_IDENTITY.packageVersion, + started_at: input.startedAt, + command_path: serverPath, + }, + sdk: SDK_IDENTITY, + negotiated_protocol_version: input.protocolVersion ?? null, + client: + input.client === undefined + ? null + : { name: input.client.name, version: input.client.version }, + skill: { + name: PRODUCT_IDENTITY.skillName, + expected_version: PRODUCT_IDENTITY.skillVersion, + }, + catalog: CATALOG_IDENTITY, + protocol_features: { + progress: true, + cancellation: true, + evidence_resources: true, + elicitation: false, + }, + alignment: { + state: + reasons.length > 0 + ? "mcp_server_restart_required" + : observedExpectation + ? "aligned" + : "unknown", + reasons, + remediation: + reasons.length > 0 + ? "Restart the registered MCP server/client, then compare identity again." + : observedExpectation + ? null + : "Supply expected package, catalog, or registration identity to compare the live server.", + }, + } as const; +}; diff --git a/tests/clientRegistrationStatus.test.ts b/tests/clientRegistrationStatus.test.ts new file mode 100644 index 000000000..c7ed5fc4a --- /dev/null +++ b/tests/clientRegistrationStatus.test.ts @@ -0,0 +1,47 @@ +import { mkdir, mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { readClientRegistrationStatuses } from "../src/application/ClientRegistrationStatus.js"; + +describe("client registration status", () => { + it("distinguishes aligned, stale, missing, and invalid registrations", async () => { + const home = await mkdtemp(join(tmpdir(), "rea-registrations-")); + await Promise.all([ + mkdir(join(home, ".codex")), + mkdir(join(home, ".cursor")), + mkdir(join(home, ".gemini")), + mkdir(join(home, ".claude")), + ]); + await writeFile( + join(home, ".codex/config.toml"), + '[mcp_servers.rea]\ncommand = "npx"\nargs = ["-y", "rea-agents", "mcp"]\n', + ); + await writeFile( + join(home, ".cursor/mcp.json"), + JSON.stringify({ + mcpServers: { + rea: { command: "/old/rea", args: ["mcp"], env: { SECRET: "x" } }, + }, + }), + ); + await writeFile(join(home, ".gemini/settings.json"), "not-json"); + + const statuses = await readClientRegistrationStatuses(home, "/current/rea"); + expect(statuses).toEqual( + expect.arrayContaining([ + expect.objectContaining({ client: "codex", state: "aligned" }), + expect.objectContaining({ + client: "cursor", + state: "stale", + command: ["/old/rea", "mcp"], + }), + expect.objectContaining({ client: "gemini_cli", state: "invalid" }), + expect.objectContaining({ client: "claude_code", state: "missing" }), + ]), + ); + expect(JSON.stringify(statuses)).not.toContain("SECRET"); + }); +}); diff --git a/tests/doctor.test.ts b/tests/doctor.test.ts index 3a958aacb..f9d0ab02f 100644 --- a/tests/doctor.test.ts +++ b/tests/doctor.test.ts @@ -59,6 +59,57 @@ describe("doctor", () => { expect(result.hopperPath).toBe("/manual/Hopper"); expect(result.healthy).toBe(true); }); + + it("distinguishes stale skills, path shadowing, and unobservable live state", async () => { + const result = await runDoctor( + undefined, + host({ + installationPaths: () => + Promise.resolve(["/usr/local/bin/rea", "/home/user/bin/rea"]), + installedSkillVersion: () => Promise.resolve("10"), + }), + ); + + expect(result.identity).toMatchObject({ + live_server: { state: "unknown" }, + installations: { state: "multiple" }, + skill: { + installed_version: "10", + state: "stale", + remediation: "Run rea setup to update the installed REA skill.", + }, + }); + }); + it("reports stale client registration paths without claiming live state", async () => { + const result = await runDoctor( + undefined, + host({ + clientRegistrations: () => + Promise.resolve([ + { + client: "codex", + config_path: "/home/user/.codex/config.toml", + command: ["/old/rea", "mcp"], + state: "stale", + remediation: + "Run rea setup to refresh this registration, then restart the client.", + }, + ]), + }), + ); + + expect(result.healthy).toBe(false); + expect(result.identity).toMatchObject({ + live_server: { state: "unknown" }, + registrations: [{ client: "codex", state: "stale" }], + }); + expect(result.checks).toContainEqual( + expect.objectContaining({ + name: "registration:codex", + classification: "config_drift", + }), + ); + }); it("accepts an officially supported Linux distribution", async () => { const path = "/home/user/.local/share/rea/hopper/bin/Hopper"; const result = await runDoctor( diff --git a/tests/serverIdentity.test.ts b/tests/serverIdentity.test.ts new file mode 100644 index 000000000..1530f98bb --- /dev/null +++ b/tests/serverIdentity.test.ts @@ -0,0 +1,198 @@ +import { readFile } from "node:fs/promises"; + +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { describe, expect, it } from "vitest"; + +import { BinarySession } from "../src/application/BinarySession.js"; +import { CATALOG_IDENTITY, CLI_COMMAND_NAMES } from "../src/catalogIdentity.js"; +import { PACKAGE_METADATA } from "../src/generatedPackageMetadata.js"; +import { PRODUCT_IDENTITY, SDK_IDENTITY } from "../src/identity.js"; +import { createServer } from "../src/server/createServer.js"; +import { createServerIdentity } from "../src/serverIdentity.js"; +import { observed } from "./fixtures/analysisExecution.js"; +import { buildCapabilityInventory } from "../src/application/CapabilityInventory.js"; + +describe("server and catalog identity", () => { + it("derives package and SDK versions from canonical package metadata", async () => { + const packageJson = JSON.parse(await readFile("package.json", "utf8")); + expect(PACKAGE_METADATA).toMatchObject({ + name: packageJson.name, + version: packageJson.version, + serverSdkVersion: "2.0.0-beta.4", + clientSdkVersion: "2.0.0-beta.4", + coreSdkVersion: "2.0.0-beta.4", + }); + expect(PRODUCT_IDENTITY.packageVersion).toBe(packageJson.version); + expect(SDK_IDENTITY.server).toBe("2.0.0-beta.4"); + expect(CLI_COMMAND_NAMES).toHaveLength(28); + expect(new Set(CLI_COMMAND_NAMES).size).toBe(28); + expect(CATALOG_IDENTITY.counts).toEqual({ + cli_commands: 28, + mcp_tools: 68, + mcp_prompts: 6, + mcp_resources: 2, + mcp_resource_templates: 7, + }); + expect(CATALOG_IDENTITY.digests.combined_sha256).toMatch(/^[a-f0-9]{64}$/u); + }); + + it("reports unknown without a live comparison and distinguishes aligned from stale", () => { + const unknown = createServerIdentity({ + startedAt: "2026-07-13T00:00:00.000Z", + }); + expect(unknown.alignment.state).toBe("unknown"); + const aligned = createServerIdentity({ + startedAt: "2026-07-13T00:00:00.000Z", + expected: { + package_version: PRODUCT_IDENTITY.packageVersion, + catalog_digest: CATALOG_IDENTITY.digests.combined_sha256, + server_path: process.argv[1] ?? "unknown", + }, + }); + expect(aligned.alignment).toMatchObject({ state: "aligned", reasons: [] }); + const stale = createServerIdentity({ + startedAt: "2026-07-13T00:00:00.000Z", + expected: { + package_version: "1.2.0", + catalog_digest: "0".repeat(64), + }, + }); + expect(stale.alignment).toMatchObject({ + state: "mcp_server_restart_required", + reasons: ["package_version_mismatch", "catalog_digest_mismatch"], + }); + }); + + it("reports composed, host, and target-specific availability truthfully", () => { + const policy = { processCaptureEnabled: true, evidenceFileRoots: 1 }; + const composed = buildCapabilityInventory( + { + open: true, + kind: "executable", + format: "mach-o", + capabilities: [ + "list_segments", + "list_documents", + "list_procedures", + "list_strings", + ].map((operation) => ({ operation, available: true, reason: null })), + }, + policy, + ); + expect(composed).toContainEqual( + expect.objectContaining({ name: "binary_overview", available: true }), + ); + const artifactTarget = buildCapabilityInventory( + { + open: true, + kind: "artifact", + format: "javascript", + capabilities: [ + { operation: "current_address", available: true, reason: null }, + ], + }, + policy, + ); + expect(artifactTarget).toContainEqual( + expect.objectContaining({ + name: "current_address", + available: false, + reason: "target_unsupported", + }), + ); + const unsupportedHost = buildCapabilityInventory( + { + open: true, + kind: "executable", + format: "elf", + capabilities: [ + { + operation: "inspect_macho", + available: false, + reason: "Native macOS utilities require macOS.", + }, + ], + }, + policy, + ); + expect(unsupportedHost).toContainEqual( + expect.objectContaining({ + name: "inspect_macho", + available: false, + reason: "unsupported_host", + }), + ); + }); + + it("exposes live identity, labeled inventories, availability, and list changes", async () => { + const session = new BinarySession(() => ({ + health: () => Promise.resolve(), + execute: () => Promise.resolve(observed(null)), + close: () => Promise.resolve(), + })); + const server = createServer(session, session); + const client = new Client({ name: "identity-test", version: "9" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + let toolListChanges = 0; + client.setNotificationHandler("notifications/tools/list_changed", () => { + toolListChanges += 1; + }); + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + const resource = await client.readResource({ + uri: "rea://server/identity", + }); + const content = resource.contents[0]; + expect(content).toBeDefined(); + if (content === undefined || !("text" in content)) return; + const live = JSON.parse(content.text); + expect(live).toMatchObject({ + package: { version: PRODUCT_IDENTITY.packageVersion }, + server: { version: PRODUCT_IDENTITY.packageVersion }, + sdk: { server: "2.0.0-beta.4", client_test: "2.0.0-beta.4" }, + client: { name: "identity-test", version: "9" }, + alignment: { state: "unknown" }, + }); + const status = await client.callTool({ + name: "binary_session", + arguments: { expected_package_version: "1.2.0" }, + }); + expect(status.structuredContent).toMatchObject({ + result: { + server_identity: { + catalog: { counts: { mcp_tools: 68, cli_commands: 28 } }, + alignment: { state: "mcp_server_restart_required" }, + }, + tool_availability: expect.arrayContaining([ + expect.objectContaining({ + name: "current_address", + available: false, + reason: "target_required", + }), + expect.objectContaining({ + name: "capture_process_scenario", + available: false, + reason: "policy_disabled", + }), + ]), + client_features: { + elicitation_form: false, + elicitation_url: false, + roots: false, + sampling: false, + }, + }, + }); + await client.callTool({ name: "close_binary", arguments: {} }); + await expect.poll(() => toolListChanges).toBeGreaterThan(0); + } finally { + await Promise.allSettled([ + client.close(), + server.close(), + session.close(), + ]); + } + }); +}); diff --git a/tests/upgrade.test.ts b/tests/upgrade.test.ts index 42de4190f..9344d186a 100644 --- a/tests/upgrade.test.ts +++ b/tests/upgrade.test.ts @@ -54,6 +54,9 @@ describe("CLI upgrade", () => { versionCheck: "available", installMethod: "npm", command: "npm install --global rea-agents@latest", + clientRestartRequired: true, + remediation: + "Rerun rea setup to refresh registrations and the skill, then restart clients that may retain an older MCP server.", }); expect(host.installs).toBe(1); });