mirror of
https://github.com/morluto/rea.git
synced 2026-10-12 02:19:24 +00:00
test(linux): verify setup through CLI and MCP
This commit is contained in:
@@ -16,6 +16,27 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
clean-setup:
|
||||
name: Clean setup, CLI, and MCP
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- name: Pack CLI for setup verification
|
||||
shell: bash
|
||||
run: |
|
||||
tarball="$(npm pack --silent)"
|
||||
prefix="${RUNNER_TEMP}/rea-prefix"
|
||||
npm install --global --prefix "${prefix}" "./${tarball}"
|
||||
echo "REA_VERIFY_SERVER_COMMAND=${prefix}/bin/rea" >> "${GITHUB_ENV}"
|
||||
- name: Verify clean Linux setup through CLI and MCP
|
||||
run: sudo --preserve-env=REA_VERIFY_SERVER_COMMAND,PATH npm run verify:setup:linux
|
||||
|
||||
verify:
|
||||
name: Installed package against real Linux Hopper
|
||||
runs-on: [self-hosted, Linux, hopper]
|
||||
|
||||
@@ -70,6 +70,7 @@
|
||||
"typecheck": "tsc --noEmit",
|
||||
"verify:hopper": "npm run build && node scripts/verify-real-hopper.mjs",
|
||||
"verify:hopper:linux": "npm run build && node scripts/verify-real-hopper-linux.mjs",
|
||||
"verify:setup:linux": "npm run build && node scripts/verify-linux-setup-e2e.mjs",
|
||||
"build:fixtures": "node scripts/build-conformance-fixtures.mjs",
|
||||
"verify:fixtures": "npm run build:fixtures && node scripts/verify-conformance-fixtures.mjs",
|
||||
"verify:package": "npm run build && node scripts/verify-package.mjs",
|
||||
|
||||
+64
-31
@@ -21,6 +21,22 @@ SUPPORTED_HOPPER_SHA256 = {
|
||||
EXPECTED_SCREEN = (1280, 1024)
|
||||
EXPECTED_DIALOG = (189, 370, 901, 284)
|
||||
DEMO_CLICK = (305, 632)
|
||||
PRIVATE_DISPLAY_UNAVAILABLE = 70
|
||||
X11_AUTHORIZATION_FAILED = 71
|
||||
UNSUPPORTED_HOPPER_BUILD = 72
|
||||
INVALID_LAUNCH_COMMAND = 73
|
||||
PROCESS_OWNERSHIP_MISMATCH = 74
|
||||
HOPPER_EXITED_DURING_STARTUP = 75
|
||||
UNSUPPORTED_DEMO_DIALOG = 76
|
||||
UNEXPECTED_DISPLAY_GEOMETRY = 77
|
||||
X11_INPUT_FAILED = 78
|
||||
RUNTIME_DEPENDENCY_UNAVAILABLE = 79
|
||||
|
||||
|
||||
class AdapterFailure(Exception):
|
||||
def __init__(self, code: int, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
|
||||
|
||||
class XWindowAttributes(ctypes.Structure):
|
||||
@@ -181,20 +197,20 @@ def start_xvfb(session: Path) -> tuple[subprocess.Popen[bytes], str, Path]:
|
||||
if Path(f"/tmp/.X11-unix/X{display_number}").exists():
|
||||
continue
|
||||
cookie = secrets.token_hex(16)
|
||||
subprocess.run(
|
||||
[
|
||||
"/usr/bin/xauth",
|
||||
"-f",
|
||||
str(authority),
|
||||
"add",
|
||||
display_name,
|
||||
".",
|
||||
cookie,
|
||||
],
|
||||
check=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
try:
|
||||
subprocess.run(
|
||||
[
|
||||
"/usr/bin/xauth", "-f", str(authority), "add",
|
||||
display_name, ".", cookie,
|
||||
],
|
||||
check=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
except (OSError, subprocess.CalledProcessError) as error:
|
||||
raise AdapterFailure(
|
||||
X11_AUTHORIZATION_FAILED, "private X authorization failed"
|
||||
) from error
|
||||
authority.chmod(0o600)
|
||||
process = subprocess.Popen(
|
||||
[
|
||||
@@ -220,7 +236,7 @@ def start_xvfb(session: Path) -> tuple[subprocess.Popen[bytes], str, Path]:
|
||||
if process.poll() is None:
|
||||
process.terminate()
|
||||
process.wait()
|
||||
raise SystemExit("private X display unavailable")
|
||||
raise AdapterFailure(PRIVATE_DISPLAY_UNAVAILABLE, "private X display unavailable")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
@@ -228,10 +244,10 @@ def main() -> int:
|
||||
hopper = Path(args.hopper)
|
||||
socket = Path(args.socket)
|
||||
if digest(hopper) not in SUPPORTED_HOPPER_SHA256:
|
||||
raise SystemExit("unsupported Hopper binary")
|
||||
raise AdapterFailure(UNSUPPORTED_HOPPER_BUILD, "unsupported Hopper binary")
|
||||
command = args.command[1:] if args.command[:1] == ["--"] else args.command
|
||||
if not command or Path(command[0]).resolve() != hopper.resolve():
|
||||
raise SystemExit("invalid Hopper launch command")
|
||||
raise AdapterFailure(INVALID_LAUNCH_COMMAND, "invalid Hopper launch command")
|
||||
xvfb, display_name, authority = start_xvfb(socket.parent)
|
||||
child: subprocess.Popen[bytes] | None = None
|
||||
display: int | None = None
|
||||
@@ -252,18 +268,25 @@ def main() -> int:
|
||||
leader = os.getpid()
|
||||
os.environ["XAUTHORITY"] = str(authority)
|
||||
|
||||
x11 = ctypes.CDLL("libX11.so.6")
|
||||
xtst = ctypes.CDLL("libXtst.so.6")
|
||||
try:
|
||||
x11 = ctypes.CDLL("libX11.so.6")
|
||||
xtst = ctypes.CDLL("libXtst.so.6")
|
||||
except OSError as error:
|
||||
raise AdapterFailure(
|
||||
RUNTIME_DEPENDENCY_UNAVAILABLE, "X11 runtime unavailable"
|
||||
) from error
|
||||
configure_x11(x11, xtst)
|
||||
display = x11.XOpenDisplay(display_name.encode())
|
||||
if not display:
|
||||
raise SystemExit("private X display unavailable")
|
||||
raise AdapterFailure(PRIVATE_DISPLAY_UNAVAILABLE, "private X display unavailable")
|
||||
root = x11.XDefaultRootWindow(display)
|
||||
if (
|
||||
x11.XDisplayWidth(display, 0),
|
||||
x11.XDisplayHeight(display, 0),
|
||||
) != EXPECTED_SCREEN:
|
||||
raise SystemExit("unexpected private display geometry")
|
||||
raise AdapterFailure(
|
||||
UNEXPECTED_DISPLAY_GEOMETRY, "unexpected private display geometry"
|
||||
)
|
||||
|
||||
deadline = time.monotonic() + 10
|
||||
observed: list[tuple[int, int, int, int, int]] = []
|
||||
@@ -276,17 +299,29 @@ def main() -> int:
|
||||
if child.poll() is None:
|
||||
time.sleep(0.1)
|
||||
continue
|
||||
raise SystemExit("owned Hopper process exited")
|
||||
raise AdapterFailure(
|
||||
HOPPER_EXITED_DURING_STARTUP, "owned Hopper process exited"
|
||||
)
|
||||
observed = windows(x11, display, root)
|
||||
if any(window[1:] == EXPECTED_DIALOG for window in observed):
|
||||
break
|
||||
time.sleep(0.1)
|
||||
else:
|
||||
raise SystemExit(f"expected Hopper demo dialog not found: {observed}")
|
||||
if not hopper_is_owned(leader, hopper):
|
||||
raise AdapterFailure(
|
||||
PROCESS_OWNERSHIP_MISMATCH, "Hopper process ownership mismatch"
|
||||
)
|
||||
raise AdapterFailure(
|
||||
UNSUPPORTED_DEMO_DIALOG, "expected Hopper demo dialog not found"
|
||||
)
|
||||
|
||||
xtst.XTestFakeMotionEvent(display, -1, DEMO_CLICK[0], DEMO_CLICK[1], 0)
|
||||
xtst.XTestFakeButtonEvent(display, 1, True, 0)
|
||||
xtst.XTestFakeButtonEvent(display, 1, False, 0)
|
||||
input_results = (
|
||||
xtst.XTestFakeMotionEvent(display, -1, DEMO_CLICK[0], DEMO_CLICK[1], 0),
|
||||
xtst.XTestFakeButtonEvent(display, 1, True, 0),
|
||||
xtst.XTestFakeButtonEvent(display, 1, False, 0),
|
||||
)
|
||||
if not all(input_results):
|
||||
raise AdapterFailure(X11_INPUT_FAILED, "private X input failed")
|
||||
x11.XFlush(display)
|
||||
x11.XCloseDisplay(display)
|
||||
display = None
|
||||
@@ -309,8 +344,6 @@ def main() -> int:
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except SystemExit as error:
|
||||
if isinstance(error.code, str):
|
||||
print(error.code, file=os.sys.stderr)
|
||||
raise SystemExit(78) from None
|
||||
raise
|
||||
except AdapterFailure as error:
|
||||
print(str(error), file=os.sys.stderr)
|
||||
raise SystemExit(error.code) from None
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import {
|
||||
access,
|
||||
appendFile,
|
||||
chmod,
|
||||
copyFile,
|
||||
mkdtemp,
|
||||
rm,
|
||||
} from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const command = process.env.REA_VERIFY_SERVER_COMMAND;
|
||||
if (process.platform !== "linux" || process.getuid?.() !== 0)
|
||||
throw new Error("Linux setup verification requires a root Linux runner");
|
||||
if (!command) throw new Error("REA_VERIFY_SERVER_COMMAND is required");
|
||||
try {
|
||||
await access("/opt/hopper/bin/Hopper");
|
||||
throw new Error("Linux setup verification requires a clean runner");
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes("clean runner"))
|
||||
throw error;
|
||||
}
|
||||
|
||||
const runJson = async (args) => {
|
||||
const { stdout } = await execFileAsync(command, args, {
|
||||
env: process.env,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
return JSON.parse(stdout);
|
||||
};
|
||||
|
||||
const plan = await runJson(["setup", "--json"]);
|
||||
if (plan.status !== "needs_confirmation")
|
||||
throw new Error("setup did not produce a read-only confirmation plan");
|
||||
await access("/opt/hopper/bin/Hopper").then(
|
||||
() => {
|
||||
throw new Error("setup plan mutated the clean runner");
|
||||
},
|
||||
() => undefined,
|
||||
);
|
||||
|
||||
const installed = await runJson([
|
||||
"setup",
|
||||
"--yes",
|
||||
"--install-hopper",
|
||||
"--json",
|
||||
]);
|
||||
if (
|
||||
installed.status !== "ready" ||
|
||||
!installed.appliedActions.includes("installed_hopper")
|
||||
)
|
||||
throw new Error(
|
||||
`approved setup did not become ready: ${JSON.stringify(installed)}`,
|
||||
);
|
||||
const doctor = await runJson(["doctor", "--json"]);
|
||||
if (doctor.healthy !== true)
|
||||
throw new Error(`doctor is unhealthy: ${JSON.stringify(doctor)}`);
|
||||
|
||||
await execFileAsync(command, ["analyze", "/usr/bin/true"], {
|
||||
env: process.env,
|
||||
timeout: 180_000,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
process.env.HOPPER_TARGET_PATH = "/usr/bin/true";
|
||||
process.env.HOPPER_SECOND_TARGET_PATH = "/usr/bin/false";
|
||||
process.env.REA_VERIFY_SERVER_ARGS_JSON = '["mcp"]';
|
||||
await import("./verify-real-hopper-linux.mjs");
|
||||
|
||||
const modifiedDirectory = await mkdtemp(join(tmpdir(), "rea-modified-hopper-"));
|
||||
try {
|
||||
const modifiedHopper = join(modifiedDirectory, "Hopper");
|
||||
await copyFile("/opt/hopper/bin/Hopper", modifiedHopper);
|
||||
await appendFile(modifiedHopper, new Uint8Array([0]));
|
||||
await chmod(modifiedHopper, 0o700);
|
||||
try {
|
||||
await execFileAsync(command, ["analyze", "/usr/bin/true"], {
|
||||
env: { ...process.env, HOPPER_LAUNCHER_PATH: modifiedHopper },
|
||||
timeout: 30_000,
|
||||
});
|
||||
throw new Error("modified Hopper build was not rejected");
|
||||
} catch (error) {
|
||||
const output = `${error?.stdout ?? ""}\n${error?.stderr ?? ""}`;
|
||||
if (!output.includes("unsupported_hopper_build"))
|
||||
throw new Error(
|
||||
"modified Hopper build did not return its stable failure code",
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await rm(modifiedDirectory, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
const repeated = await runJson([
|
||||
"setup",
|
||||
"--yes",
|
||||
"--install-hopper",
|
||||
"--json",
|
||||
]);
|
||||
if (
|
||||
repeated.status !== "ready" ||
|
||||
repeated.appliedActions.includes("installed_hopper")
|
||||
)
|
||||
throw new Error("a second setup was not idempotent");
|
||||
const { stdout: processes } = await execFileAsync("ps", [
|
||||
"-ax",
|
||||
"-o",
|
||||
"command=",
|
||||
]);
|
||||
if (/\/opt\/hopper\/bin\/Hopper|\bXvfb\b|hopper-demo-x11\.py/u.test(processes))
|
||||
throw new Error("Linux setup verification leaked a Hopper demo session");
|
||||
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
status: "verified",
|
||||
setup: true,
|
||||
cli: true,
|
||||
mcp: true,
|
||||
idempotent: true,
|
||||
}),
|
||||
);
|
||||
@@ -121,8 +121,9 @@ export const runDoctor = async (
|
||||
await host.supportedLinuxHopper(hopperPath),
|
||||
hopperPath,
|
||||
{
|
||||
remediation:
|
||||
"Install the Hopper build supported by this REA release, or update REA for a newer Hopper build.",
|
||||
remediation: unsupportedHopperRemediation(
|
||||
host.configuredHopperPath === hopperPath,
|
||||
),
|
||||
classification: "config_drift",
|
||||
},
|
||||
),
|
||||
@@ -135,7 +136,7 @@ export const runDoctor = async (
|
||||
undefined,
|
||||
{
|
||||
remediation:
|
||||
"Rerun rea setup to install the Xvfb, Python, X11, and XTEST packages required for Linux demo sessions.",
|
||||
"Rerun rea setup to install the Xvfb, xauth, Python, X11, and XTEST packages required for Linux demo sessions.",
|
||||
classification: "missing_dependency",
|
||||
},
|
||||
),
|
||||
@@ -154,6 +155,11 @@ export const runDoctor = async (
|
||||
};
|
||||
};
|
||||
|
||||
const unsupportedHopperRemediation = (configured: boolean): string =>
|
||||
configured
|
||||
? "Unset or update HOPPER_LAUNCHER_PATH, install the Hopper build supported by this REA release, or update REA."
|
||||
: "Install the Hopper build supported by this REA release, or update REA for a newer Hopper build.";
|
||||
|
||||
/** Create diagnostics backed by the current process and host commands. */
|
||||
export const systemDoctorHost = (): DoctorHost => ({
|
||||
platform: process.platform,
|
||||
|
||||
@@ -62,7 +62,7 @@ export interface LinuxHopperInstallHost {
|
||||
createTemporaryDirectory(): Promise<string>;
|
||||
writeArchive(path: string, bytes: Uint8Array): Promise<void>;
|
||||
installPackage(family: LinuxPackageFamily, archive: string): Promise<boolean>;
|
||||
launcherReady(path: string): Promise<boolean>;
|
||||
launcherStatus(path: string): Promise<LinuxHopperLauncherStatus>;
|
||||
cleanup(path: string): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -78,9 +78,17 @@ export type LinuxHopperInstallResult =
|
||||
| "integrity"
|
||||
| "authorization_or_package_manager"
|
||||
| "launcher_missing"
|
||||
| "runtime_dependencies"
|
||||
| "unsupported_hopper_build"
|
||||
| "cancelled";
|
||||
};
|
||||
|
||||
export type LinuxHopperLauncherStatus =
|
||||
| "ready"
|
||||
| "missing"
|
||||
| "runtime_dependencies"
|
||||
| "unsupported_hopper_build";
|
||||
|
||||
/** Parse an os-release document without executing its shell syntax. */
|
||||
export const parseLinuxDistribution = (text: string): LinuxDistribution => {
|
||||
const fields = new Map<string, string>();
|
||||
@@ -155,8 +163,13 @@ export const installLinuxHopper = async (
|
||||
if (!(await host.installPackage(distribution.packageFamily, archive)))
|
||||
return { status: "failed", reason: "authorization_or_package_manager" };
|
||||
const launcherPath = "/opt/hopper/bin/Hopper";
|
||||
if (!(await host.launcherReady(launcherPath)))
|
||||
return { status: "failed", reason: "launcher_missing" };
|
||||
const launcherStatus = await host.launcherStatus(launcherPath);
|
||||
if (launcherStatus !== "ready")
|
||||
return {
|
||||
status: "failed",
|
||||
reason:
|
||||
launcherStatus === "missing" ? "launcher_missing" : launcherStatus,
|
||||
};
|
||||
return { status: "installed", launcherPath };
|
||||
} catch (cause: unknown) {
|
||||
if (isAbortCause(cause)) return { status: "failed", reason: "cancelled" };
|
||||
@@ -185,16 +198,17 @@ const systemLinuxHopperInstallHost = (): LinuxHopperInstallHost => ({
|
||||
createTemporaryDirectory: () => mkdtemp(join(tmpdir(), "rea-hopper-")),
|
||||
writeArchive: (path, bytes) => writeFile(path, bytes, { mode: 0o600 }),
|
||||
installPackage: installSystemPackage,
|
||||
async launcherReady(path) {
|
||||
async launcherStatus(path) {
|
||||
try {
|
||||
await access(path);
|
||||
const linked = await execFileAsync("ldd", [path]);
|
||||
return (
|
||||
linuxSharedLibrariesAvailable(`${linked.stdout}\n${linked.stderr}`) &&
|
||||
(await linuxHopperBinarySupported(path))
|
||||
);
|
||||
if (!linuxSharedLibrariesAvailable(`${linked.stdout}\n${linked.stderr}`))
|
||||
return "runtime_dependencies";
|
||||
return (await linuxHopperBinarySupported(path))
|
||||
? "ready"
|
||||
: "unsupported_hopper_build";
|
||||
} catch {
|
||||
return false;
|
||||
return "missing";
|
||||
}
|
||||
},
|
||||
cleanup: (path) => rm(path, { recursive: true, force: true }),
|
||||
|
||||
+19
-45
@@ -24,6 +24,16 @@ import {
|
||||
import { installMacHopper } from "./MacHopper.js";
|
||||
import { configureDetectedClients } from "./SetupClients.js";
|
||||
import { installCanonicalSkill } from "./SetupSkill.js";
|
||||
import { setupPlan } from "./SetupPlan.js";
|
||||
import {
|
||||
setupInstallFailure,
|
||||
type SetupFailureCode,
|
||||
type SetupHopperInstallResult,
|
||||
} from "./SetupInstallFailure.js";
|
||||
export type {
|
||||
SetupFailureCode,
|
||||
SetupHopperInstallResult,
|
||||
} from "./SetupInstallFailure.js";
|
||||
|
||||
export { installCanonicalSkill } from "./SetupSkill.js";
|
||||
|
||||
@@ -60,7 +70,7 @@ export interface SetupHost {
|
||||
macosVersion(): Promise<string | undefined>;
|
||||
linuxDistribution(): Promise<LinuxDistribution | undefined>;
|
||||
hopperPath(): Promise<string | undefined>;
|
||||
installHopper(): Promise<string | undefined>;
|
||||
installHopper(): Promise<SetupHopperInstallResult>;
|
||||
detectedClients(): Promise<readonly SetupClient[]>;
|
||||
configureClient(
|
||||
client: SetupClient,
|
||||
@@ -82,6 +92,7 @@ export interface SetupResult {
|
||||
readonly clients: Readonly<Record<string, ClientConfigurationResult>>;
|
||||
readonly doctor: Awaited<ReturnType<typeof runDoctor>>;
|
||||
readonly remediation?: string;
|
||||
readonly code?: SetupFailureCode;
|
||||
}
|
||||
|
||||
/** One concrete setup mutation disclosed before approval. */
|
||||
@@ -104,45 +115,6 @@ export type SetupConfirmation = (
|
||||
actions: readonly SetupAction[],
|
||||
) => Promise<boolean>;
|
||||
|
||||
const setupPlan = (
|
||||
platform: NodeJS.Platform,
|
||||
installHopper: boolean,
|
||||
clients: readonly SetupClient[],
|
||||
): readonly SetupAction[] => [
|
||||
...(installHopper
|
||||
? [
|
||||
{
|
||||
kind: "install_hopper" as const,
|
||||
target:
|
||||
platform === "darwin"
|
||||
? "~/Applications/Hopper Disassembler.app"
|
||||
: "system package manager",
|
||||
detail:
|
||||
platform === "linux"
|
||||
? "Download, verify, and install Hopper plus its Xvfb demo-session dependencies. For the supported demo build, REA uses a private display and selects Hopper's offered demo mode for each analysis session."
|
||||
: "Download the official Hopper package, verify it, and install it. Hopper may show its demo or license prompt when first opened.",
|
||||
external: true,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...clients
|
||||
.filter(({ format }) => format !== "unsupported")
|
||||
.map(
|
||||
(client): SetupAction => ({
|
||||
kind: "configure_client",
|
||||
target: client.configPath,
|
||||
detail: `Add the REA MCP registration for ${client.name}; preserve unrelated configuration.`,
|
||||
external: false,
|
||||
}),
|
||||
),
|
||||
{
|
||||
kind: "install_skill",
|
||||
target: "~/.agents/skills/rea-analysis/SKILL.md",
|
||||
detail: "Install or update the bundled REA analysis skill.",
|
||||
external: false,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Install prerequisites and configure detected clients idempotently.
|
||||
* Discovery always precedes mutation. Interactive confirmation or explicit
|
||||
@@ -195,17 +167,18 @@ export const runSetup = async (
|
||||
};
|
||||
|
||||
if (installHopper && (interactiveApproval || options.installHopper)) {
|
||||
hopperPath = await host.installHopper();
|
||||
if (hopperPath === undefined)
|
||||
const installed = await host.installHopper();
|
||||
if (installed.status === "failed")
|
||||
return {
|
||||
status: "needs_human",
|
||||
plannedActions,
|
||||
appliedActions,
|
||||
clients,
|
||||
doctor: await host.doctor(),
|
||||
remediation:
|
||||
"Hopper installation failed; install Hopper manually or rerun setup after resolving the reported system error.",
|
||||
code: installed.code,
|
||||
remediation: installed.remediation,
|
||||
};
|
||||
hopperPath = installed.launcherPath;
|
||||
appliedActions.push("installed_hopper");
|
||||
}
|
||||
const clientFailure = await configureDetectedClients({
|
||||
@@ -286,7 +259,8 @@ const systemSetupHost = (): SetupHost => {
|
||||
process.platform === "linux"
|
||||
? await installLinuxHopper()
|
||||
: await installMacHopper();
|
||||
return result.status === "installed" ? result.launcherPath : undefined;
|
||||
if (result.status === "installed") return result;
|
||||
return setupInstallFailure(result.reason);
|
||||
},
|
||||
detectedClients: () => detectClients(homedir()),
|
||||
configureClient: (client, hopperPath, command) =>
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
export type SetupFailureCode =
|
||||
| "unsupported_host"
|
||||
| "download_failed"
|
||||
| "integrity_mismatch"
|
||||
| "authorization_or_package_manager_failed"
|
||||
| "launcher_missing"
|
||||
| "runtime_dependency_unavailable"
|
||||
| "unsupported_hopper_build"
|
||||
| "setup_cancelled";
|
||||
|
||||
export type SetupHopperInstallResult =
|
||||
| { readonly status: "installed"; readonly launcherPath: string }
|
||||
| {
|
||||
readonly status: "failed";
|
||||
readonly code: SetupFailureCode;
|
||||
readonly remediation: string;
|
||||
};
|
||||
|
||||
export type HopperInstallFailureReason =
|
||||
| "unsupported_host"
|
||||
| "release_metadata"
|
||||
| "download"
|
||||
| "integrity"
|
||||
| "authorization_or_package_manager"
|
||||
| "launcher_missing"
|
||||
| "runtime_dependencies"
|
||||
| "unsupported_hopper_build"
|
||||
| "cancelled"
|
||||
| "destination_exists"
|
||||
| "mount"
|
||||
| "bundle"
|
||||
| "copy";
|
||||
|
||||
/** Map provider installer failures to stable setup codes and safe recovery. */
|
||||
export const setupInstallFailure = (
|
||||
reason: HopperInstallFailureReason,
|
||||
): SetupHopperInstallResult => {
|
||||
switch (reason) {
|
||||
case "unsupported_host":
|
||||
return {
|
||||
status: "failed",
|
||||
code: "unsupported_host",
|
||||
remediation: "Use a supported host, then rerun rea setup.",
|
||||
};
|
||||
case "integrity":
|
||||
return {
|
||||
status: "failed",
|
||||
code: "integrity_mismatch",
|
||||
remediation:
|
||||
"The Hopper download failed integrity verification. Retry setup; if it repeats, update REA before installing.",
|
||||
};
|
||||
case "authorization_or_package_manager":
|
||||
return {
|
||||
status: "failed",
|
||||
code: "authorization_or_package_manager_failed",
|
||||
remediation:
|
||||
"Allow the system package-manager operation or install the planned Hopper package and Linux runtime dependencies, then rerun rea setup.",
|
||||
};
|
||||
case "launcher_missing":
|
||||
return {
|
||||
status: "failed",
|
||||
code: "launcher_missing",
|
||||
remediation:
|
||||
"The package operation completed but Hopper is not runnable. Run rea doctor and repair the reported launcher issue.",
|
||||
};
|
||||
case "runtime_dependencies":
|
||||
return {
|
||||
status: "failed",
|
||||
code: "runtime_dependency_unavailable",
|
||||
remediation:
|
||||
"Hopper was installed but required shared libraries are missing. Rerun rea setup, then apply the hopper-demo-runtime remediation from rea doctor.",
|
||||
};
|
||||
case "unsupported_hopper_build":
|
||||
return {
|
||||
status: "failed",
|
||||
code: "unsupported_hopper_build",
|
||||
remediation:
|
||||
"The installed Hopper launcher does not match the build supported by this REA release. Unset HOPPER_LAUNCHER_PATH, reinstall through rea setup, or update REA.",
|
||||
};
|
||||
case "cancelled":
|
||||
return {
|
||||
status: "failed",
|
||||
code: "setup_cancelled",
|
||||
remediation:
|
||||
"Setup was cancelled before Hopper was installed. Rerun rea setup when ready.",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
status: "failed",
|
||||
code: "download_failed",
|
||||
remediation:
|
||||
"REA could not download or unpack the official Hopper package. Check network and filesystem access, then retry setup.",
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { SetupAction, SetupClient } from "./Setup.js";
|
||||
|
||||
/** Build the complete setup mutation plan before approval. */
|
||||
export const setupPlan = (
|
||||
platform: NodeJS.Platform,
|
||||
installHopper: boolean,
|
||||
clients: readonly SetupClient[],
|
||||
): readonly SetupAction[] => [
|
||||
...(installHopper
|
||||
? [
|
||||
{
|
||||
kind: "install_hopper" as const,
|
||||
target:
|
||||
platform === "darwin"
|
||||
? "~/Applications/Hopper Disassembler.app"
|
||||
: "system package manager",
|
||||
detail:
|
||||
platform === "linux"
|
||||
? "Download, verify, and install Hopper plus its Xvfb demo-session dependencies. For the supported demo build, REA uses a private display and selects Hopper's offered demo mode for each analysis session."
|
||||
: "Download the official Hopper package, verify it, and install it. Hopper may show its demo or license prompt when first opened.",
|
||||
external: true,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...clients
|
||||
.filter(({ format }) => format !== "unsupported")
|
||||
.map(
|
||||
(client): SetupAction => ({
|
||||
kind: "configure_client",
|
||||
target: client.configPath,
|
||||
detail: `Add the REA MCP registration for ${client.name}; preserve unrelated configuration.`,
|
||||
external: false,
|
||||
}),
|
||||
),
|
||||
{
|
||||
kind: "install_skill",
|
||||
target: "~/.agents/skills/rea-analysis/SKILL.md",
|
||||
detail: "Install or update the bundled REA analysis skill.",
|
||||
external: false,
|
||||
},
|
||||
];
|
||||
@@ -191,6 +191,20 @@ const analysisErrorProjectionSchema = z
|
||||
"execution_failure",
|
||||
]),
|
||||
message: z.string(),
|
||||
code: z
|
||||
.enum([
|
||||
"private_display_unavailable",
|
||||
"x11_authorization_failed",
|
||||
"unsupported_hopper_build",
|
||||
"invalid_launch_command",
|
||||
"process_ownership_mismatch",
|
||||
"hopper_exited_during_startup",
|
||||
"unsupported_demo_dialog",
|
||||
"unexpected_display_geometry",
|
||||
"x11_input_failed",
|
||||
"runtime_dependency_unavailable",
|
||||
])
|
||||
.optional(),
|
||||
})
|
||||
.strict();
|
||||
const graphNode = z.discriminatedUnion("status", [
|
||||
|
||||
+14
-4
@@ -1,4 +1,10 @@
|
||||
import type { JsonValue } from "./jsonValue.js";
|
||||
import {
|
||||
hopperStartupFailure,
|
||||
type HopperStartupFailureCode,
|
||||
} from "./hopperStartupFailure.js";
|
||||
export { hopperStartupFailure } from "./hopperStartupFailure.js";
|
||||
export type { HopperStartupFailureCode } from "./hopperStartupFailure.js";
|
||||
|
||||
/** Stable tags exposed by safe analysis-error projections. */
|
||||
const ANALYSIS_ERROR_TAGS = [
|
||||
@@ -282,14 +288,14 @@ export class HopperRemoteError extends HopperError {
|
||||
/** The owned Hopper bridge stopped before its client was closed. */
|
||||
export class HopperProcessError extends HopperError {
|
||||
readonly _tag = "HopperProcessError";
|
||||
readonly failureCode: HopperStartupFailureCode | undefined;
|
||||
override readonly userMessage: string | undefined;
|
||||
|
||||
constructor(readonly exitCode: number | null) {
|
||||
super(`Hopper bridge stopped unexpectedly with code ${String(exitCode)}`);
|
||||
this.userMessage =
|
||||
exitCode === 78
|
||||
? "This Hopper demo build or dialog is not supported for unattended Linux analysis. Update REA or use a supported Hopper build."
|
||||
: undefined;
|
||||
const failure = hopperStartupFailure(exitCode);
|
||||
this.failureCode = failure?.code;
|
||||
this.userMessage = failure?.message;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -342,6 +348,7 @@ export interface AnalysisErrorProjection
|
||||
| "unavailable"
|
||||
| "execution_failure";
|
||||
readonly message: string;
|
||||
readonly code?: HopperStartupFailureCode;
|
||||
}
|
||||
|
||||
/** Project expected failures into exhaustive, secret-safe caller fields. */
|
||||
@@ -349,9 +356,12 @@ export const projectAnalysisError = (
|
||||
error: AnalysisError,
|
||||
): AnalysisErrorProjection => {
|
||||
assertKnownTag(error._tag);
|
||||
const code =
|
||||
error instanceof HopperProcessError ? error.failureCode : undefined;
|
||||
return {
|
||||
category: errorCategory(error),
|
||||
message: userMessage(error),
|
||||
...(code === undefined ? {} : { code }),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
export type HopperStartupFailureCode =
|
||||
| "private_display_unavailable"
|
||||
| "x11_authorization_failed"
|
||||
| "unsupported_hopper_build"
|
||||
| "invalid_launch_command"
|
||||
| "process_ownership_mismatch"
|
||||
| "hopper_exited_during_startup"
|
||||
| "unsupported_demo_dialog"
|
||||
| "unexpected_display_geometry"
|
||||
| "x11_input_failed"
|
||||
| "runtime_dependency_unavailable";
|
||||
|
||||
const failures = [
|
||||
[
|
||||
70,
|
||||
"private_display_unavailable",
|
||||
"REA could not start its private Linux display. Install Xvfb, then rerun rea doctor.",
|
||||
],
|
||||
[
|
||||
71,
|
||||
"x11_authorization_failed",
|
||||
"REA could not authorize its private Linux display. Install xauth, then rerun rea doctor.",
|
||||
],
|
||||
[
|
||||
72,
|
||||
"unsupported_hopper_build",
|
||||
"This Hopper build is not supported for unattended Linux analysis. Unset HOPPER_LAUNCHER_PATH and rerun rea setup --yes --install-hopper, or update REA.",
|
||||
],
|
||||
[
|
||||
73,
|
||||
"invalid_launch_command",
|
||||
"REA refused an unexpected Hopper launch command. Check HOPPER_LAUNCHER_PATH and rerun rea doctor.",
|
||||
],
|
||||
[
|
||||
74,
|
||||
"process_ownership_mismatch",
|
||||
"REA could not verify ownership of the launched Hopper process and stopped safely. Close other Hopper sessions and retry.",
|
||||
],
|
||||
[
|
||||
75,
|
||||
"hopper_exited_during_startup",
|
||||
"Hopper exited before the REA bridge was ready. Run rea doctor and retry the analysis.",
|
||||
],
|
||||
[
|
||||
76,
|
||||
"unsupported_demo_dialog",
|
||||
"This Hopper demo dialog is not supported for unattended Linux analysis. Update REA or use a supported Hopper build.",
|
||||
],
|
||||
[
|
||||
77,
|
||||
"unexpected_display_geometry",
|
||||
"REA's private Linux display did not match the validated layout. Close custom display overrides and retry.",
|
||||
],
|
||||
[
|
||||
78,
|
||||
"x11_input_failed",
|
||||
"REA could not activate Hopper's validated demo control. Install the XTEST runtime, then rerun rea doctor.",
|
||||
],
|
||||
[
|
||||
79,
|
||||
"runtime_dependency_unavailable",
|
||||
"A Linux demo-session dependency is unavailable. Rerun rea setup --yes --install-hopper, then rea doctor.",
|
||||
],
|
||||
] as const;
|
||||
|
||||
const HOPPER_STARTUP_FAILURES = new Map<
|
||||
number,
|
||||
{ readonly code: HopperStartupFailureCode; readonly message: string }
|
||||
>(failures.map(([exitCode, code, message]) => [exitCode, { code, message }]));
|
||||
|
||||
/** Return the stable public failure associated with a Linux demo adapter exit. */
|
||||
export const hopperStartupFailure = (exitCode: number | null) =>
|
||||
exitCode === null ? undefined : HOPPER_STARTUP_FAILURES.get(exitCode);
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
HopperProcessError,
|
||||
HopperProtocolError,
|
||||
HopperStartError,
|
||||
hopperStartupFailure,
|
||||
HopperTimeoutError,
|
||||
} from "../domain/errors.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
@@ -212,7 +213,10 @@ export class HopperClient {
|
||||
while (Date.now() < deadline) {
|
||||
if (signal?.aborted === true) return err(new HopperCancelledError());
|
||||
if (this.#closing) return err(new HopperProcessError(null));
|
||||
if (this.#launcherExitCode === 78)
|
||||
if (
|
||||
this.#launcherExitCode !== undefined &&
|
||||
hopperStartupFailure(this.#launcherExitCode) !== undefined
|
||||
)
|
||||
return err(new HopperProcessError(this.#launcherExitCode));
|
||||
try {
|
||||
await access(socketPath);
|
||||
|
||||
+27
-1
@@ -99,7 +99,33 @@ describe("doctor", () => {
|
||||
expect(result.healthy).toBe(false);
|
||||
expect(
|
||||
result.checks.find(({ name }) => name === "hopper-demo-runtime"),
|
||||
).toMatchObject({ ok: false, classification: "missing_dependency" });
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
classification: "missing_dependency",
|
||||
remediation: expect.stringContaining("xauth"),
|
||||
});
|
||||
});
|
||||
it("explains how to recover from an unsupported configured launcher", async () => {
|
||||
const path = "/custom/Hopper";
|
||||
const result = await runDoctor(
|
||||
undefined,
|
||||
host({
|
||||
platform: "linux",
|
||||
configuredHopperPath: path,
|
||||
linuxDistribution: () =>
|
||||
Promise.resolve({
|
||||
id: "ubuntu",
|
||||
versionId: "24.04",
|
||||
packageFamily: "deb",
|
||||
supported: true,
|
||||
}),
|
||||
executable: (candidate) => Promise.resolve(candidate === path),
|
||||
supportedLinuxHopper: () => Promise.resolve(false),
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
result.checks.find(({ name }) => name === "hopper-version")?.remediation,
|
||||
).toContain("Unset or update HOPPER_LAUNCHER_PATH");
|
||||
});
|
||||
it("detects a Homebrew cask installed outside /Applications", async () => {
|
||||
const path =
|
||||
|
||||
+19
-5
@@ -37,11 +37,25 @@ describe("analysis error projection", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("projects a safe explanation for an unsupported Linux demo dialog", () => {
|
||||
expect(projectAnalysisError(new HopperProcessError(78))).toMatchObject({
|
||||
message:
|
||||
"This Hopper demo build or dialog is not supported for unattended Linux analysis. Update REA or use a supported Hopper build.",
|
||||
});
|
||||
it("projects stable, safe Linux startup failure codes", () => {
|
||||
const expected = [
|
||||
[70, "private_display_unavailable"],
|
||||
[71, "x11_authorization_failed"],
|
||||
[72, "unsupported_hopper_build"],
|
||||
[73, "invalid_launch_command"],
|
||||
[74, "process_ownership_mismatch"],
|
||||
[75, "hopper_exited_during_startup"],
|
||||
[76, "unsupported_demo_dialog"],
|
||||
[77, "unexpected_display_geometry"],
|
||||
[78, "x11_input_failed"],
|
||||
[79, "runtime_dependency_unavailable"],
|
||||
] as const;
|
||||
for (const [exitCode, code] of expected) {
|
||||
const projected = projectAnalysisError(new HopperProcessError(exitCode));
|
||||
expect(projected.code).toBe(code);
|
||||
expect(projected.message.length).toBeGreaterThan(20);
|
||||
expect(JSON.stringify(projected)).not.toContain("/proc/");
|
||||
}
|
||||
});
|
||||
|
||||
it("projects every typed failure without internal details", () => {
|
||||
|
||||
+18
-15
@@ -198,22 +198,25 @@ describe("HopperClient", () => {
|
||||
expect(Date.now() - startedAt).toBeLessThan(500);
|
||||
});
|
||||
|
||||
it("reports an owned launcher exit during bridge startup", async () => {
|
||||
const client = new HopperClient({
|
||||
launcher: new ExitingLauncher(78),
|
||||
startupTimeoutMs: 10_000,
|
||||
});
|
||||
clients.push(client);
|
||||
const startedAt = Date.now();
|
||||
const result = await client.start();
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok)
|
||||
expect(result.error).toMatchObject({
|
||||
_tag: "HopperProcessError",
|
||||
exitCode: 78,
|
||||
it.each([70, 71, 72, 73, 74, 75, 76, 77, 78, 79])(
|
||||
"reports Linux adapter exit %i during bridge startup",
|
||||
async (exitCode) => {
|
||||
const client = new HopperClient({
|
||||
launcher: new ExitingLauncher(exitCode),
|
||||
startupTimeoutMs: 10_000,
|
||||
});
|
||||
expect(Date.now() - startedAt).toBeLessThan(500);
|
||||
});
|
||||
clients.push(client);
|
||||
const startedAt = Date.now();
|
||||
const result = await client.start();
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok)
|
||||
expect(result.error).toMatchObject({
|
||||
_tag: "HopperProcessError",
|
||||
exitCode,
|
||||
});
|
||||
expect(Date.now() - startedAt).toBeLessThan(500);
|
||||
},
|
||||
);
|
||||
|
||||
it("allows a short-lived launcher to hand off bridge startup", async () => {
|
||||
const client = new HopperClient({
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type LinuxDistribution,
|
||||
type LinuxHopperDownload,
|
||||
type LinuxHopperInstallHost,
|
||||
type LinuxHopperLauncherStatus,
|
||||
type LinuxPackageFamily,
|
||||
} from "../src/application/LinuxHopper.js";
|
||||
|
||||
@@ -22,7 +23,7 @@ class RecordingLinuxHost implements LinuxHopperInstallHost {
|
||||
archive = new Uint8Array([1, 2, 3]);
|
||||
archiveOk = true;
|
||||
installSucceeds = true;
|
||||
launcherExists = true;
|
||||
launcherStatusValue: LinuxHopperLauncherStatus = "ready";
|
||||
downloads: string[] = [];
|
||||
installed: Array<{ family: LinuxPackageFamily; archive: string }> = [];
|
||||
cleaned: string[] = [];
|
||||
@@ -43,7 +44,8 @@ class RecordingLinuxHost implements LinuxHopperInstallHost {
|
||||
this.installed.push({ family, archive });
|
||||
return Promise.resolve(this.installSucceeds);
|
||||
};
|
||||
launcherReady = (): Promise<boolean> => Promise.resolve(this.launcherExists);
|
||||
launcherStatus = (): Promise<LinuxHopperLauncherStatus> =>
|
||||
Promise.resolve(this.launcherStatusValue);
|
||||
cleanup = (path: string): Promise<void> => {
|
||||
this.cleaned.push(path);
|
||||
return Promise.resolve();
|
||||
@@ -165,6 +167,8 @@ describe("Linux Hopper installation", () => {
|
||||
["checksum mismatch", "integrity"],
|
||||
["package manager failure", "authorization_or_package_manager"],
|
||||
["missing launcher", "launcher_missing"],
|
||||
["missing runtime", "runtime_dependencies"],
|
||||
["unsupported build", "unsupported_hopper_build"],
|
||||
] as const)("classifies %s", async (scenario, reason) => {
|
||||
const host = new RecordingLinuxHost();
|
||||
if (scenario === "unsupported host")
|
||||
@@ -176,7 +180,11 @@ describe("Linux Hopper installation", () => {
|
||||
if (scenario === "package request failure") host.archiveOk = false;
|
||||
if (scenario === "checksum mismatch") host.archive = new Uint8Array([9]);
|
||||
if (scenario === "package manager failure") host.installSucceeds = false;
|
||||
if (scenario === "missing launcher") host.launcherExists = false;
|
||||
if (scenario === "missing launcher") host.launcherStatusValue = "missing";
|
||||
if (scenario === "missing runtime")
|
||||
host.launcherStatusValue = "runtime_dependencies";
|
||||
if (scenario === "unsupported build")
|
||||
host.launcherStatusValue = "unsupported_hopper_build";
|
||||
expect(await installFixture(host)).toEqual({
|
||||
status: "failed",
|
||||
reason,
|
||||
|
||||
+16
-6
@@ -6,6 +6,7 @@ import {
|
||||
type SetupClient,
|
||||
type SetupHost,
|
||||
type SetupOptions,
|
||||
type SetupHopperInstallResult,
|
||||
} from "../src/application/Setup.js";
|
||||
import type { DoctorCheck } from "../src/application/Doctor.js";
|
||||
import type { LinuxDistribution } from "../src/application/LinuxHopper.js";
|
||||
@@ -35,11 +36,21 @@ class FakeSetupHost implements SetupHost {
|
||||
linuxDistribution = (): Promise<LinuxDistribution | undefined> =>
|
||||
Promise.resolve(this.distribution);
|
||||
hopperPath = (): Promise<string | undefined> => Promise.resolve(this.hopper);
|
||||
installHopper = (): Promise<string | undefined> => {
|
||||
installHopper = (): Promise<SetupHopperInstallResult> => {
|
||||
this.hopperInstalls += 1;
|
||||
this.linuxDemoRuntimeMissing = false;
|
||||
if (this.hopperInstallSucceeds) this.hopper = "/manual/Hopper";
|
||||
return Promise.resolve(this.hopper);
|
||||
if (this.hopperInstallSucceeds) {
|
||||
this.hopper = "/manual/Hopper";
|
||||
return Promise.resolve({
|
||||
status: "installed",
|
||||
launcherPath: this.hopper,
|
||||
});
|
||||
}
|
||||
return Promise.resolve({
|
||||
status: "failed",
|
||||
code: "download_failed",
|
||||
remediation: "Download failed.",
|
||||
});
|
||||
};
|
||||
detectedClients = (): Promise<readonly SetupClient[]> =>
|
||||
Promise.resolve(this.clients);
|
||||
@@ -257,9 +268,8 @@ describe("setup workflow", () => {
|
||||
host.clients = [{ name: "cursor", configPath: "/cursor.json" }];
|
||||
const result = await runSetup(options(true, true), host);
|
||||
expect(result.status).toBe("needs_human");
|
||||
expect(result.remediation).toBe(
|
||||
"Hopper installation failed; install Hopper manually or rerun setup after resolving the reported system error.",
|
||||
);
|
||||
expect(result.code).toBe("download_failed");
|
||||
expect(result.remediation).toBe("Download failed.");
|
||||
expect(host.configurations).toBe(0);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
setupInstallFailure,
|
||||
type HopperInstallFailureReason,
|
||||
} from "../src/application/SetupInstallFailure.js";
|
||||
|
||||
describe("setup installer failure projection", () => {
|
||||
it.each([
|
||||
["unsupported_host", "unsupported_host"],
|
||||
["download", "download_failed"],
|
||||
["release_metadata", "download_failed"],
|
||||
["integrity", "integrity_mismatch"],
|
||||
[
|
||||
"authorization_or_package_manager",
|
||||
"authorization_or_package_manager_failed",
|
||||
],
|
||||
["launcher_missing", "launcher_missing"],
|
||||
["runtime_dependencies", "runtime_dependency_unavailable"],
|
||||
["unsupported_hopper_build", "unsupported_hopper_build"],
|
||||
["cancelled", "setup_cancelled"],
|
||||
] as const)("maps %s to %s", (reason, code) => {
|
||||
const result = setupInstallFailure(reason as HopperInstallFailureReason);
|
||||
expect(result).toMatchObject({ status: "failed", code });
|
||||
if (result.status === "failed")
|
||||
expect(result.remediation.length).toBeGreaterThan(20);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,8 @@ import { z } from "zod";
|
||||
|
||||
import type { ToolContract } from "../src/contracts/toolContracts.js";
|
||||
import { ok } from "../src/domain/result.js";
|
||||
import { err } from "../src/domain/result.js";
|
||||
import { HopperProcessError } from "../src/domain/errors.js";
|
||||
import { toCallToolResult } from "../src/server/toolResult.js";
|
||||
|
||||
const contract: ToolContract = {
|
||||
@@ -21,6 +23,16 @@ const contract: ToolContract = {
|
||||
};
|
||||
|
||||
describe("tool result projection", () => {
|
||||
it("exposes an actionable adapter code to MCP callers", () => {
|
||||
const result = toCallToolResult(err(new HopperProcessError(76)), contract);
|
||||
expect(result.structuredContent).toMatchObject({
|
||||
error: {
|
||||
code: "unsupported_demo_dialog",
|
||||
category: "execution_failure",
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(result)).not.toContain("expected Hopper");
|
||||
});
|
||||
it("classifies output contract failures without naming a provider", () => {
|
||||
expect(toCallToolResult(ok({ value: 42 }), contract)).toEqual({
|
||||
content: [
|
||||
|
||||
Reference in New Issue
Block a user