diff --git a/src/application/CapabilityInventory.ts b/src/application/CapabilityInventory.ts index a7077ec01f..4e369d041a 100644 --- a/src/application/CapabilityInventory.ts +++ b/src/application/CapabilityInventory.ts @@ -56,6 +56,8 @@ export type AvailabilityPolicy = { readonly signalRecordingEnabled?: boolean; readonly wasmArtifactEnabled?: boolean; readonly evmInterfaceEnabled?: boolean; + /** Fresh owner observation of the selected required EVM limiter. */ + readonly evmLimiterAvailability?: ProviderAvailability; readonly processCaptureEnabled: boolean; readonly optionalProviderLoadFailures?: OptionalProviderLoadFailures; readonly binaryLayoutEnabled?: boolean; @@ -495,14 +497,18 @@ const workflowAvailabilityFor = ({ }; if (name === "inspect_pe_resources") return { reason: "available", remediation: null }; - if (name === "inspect_evm_interface") - return policy.evmInterfaceEnabled === true - ? { reason: "available", remediation: null } - : { - reason: "provider_missing", - remediation: - "The bundled offline EVM interface profile is currently real-verified on Linux x64. No active binary target or chain endpoint is required.", - }; + if (name === "inspect_evm_interface") { + if (policy.evmInterfaceEnabled !== true) + return { + reason: "provider_missing", + remediation: + "The bundled offline EVM interface profile is currently real-verified on Linux x64. No active binary target or chain endpoint is required.", + }; + const limiter = policy.evmLimiterAvailability; + if (limiter?.status === "unavailable") + return { reason: "provider_missing", remediation: limiter.reason }; + return { reason: "available", remediation: null }; + } if (name === "inspect_recorded_crash") return policy.recordedCrashEnabled === true ? { reason: "available", remediation: null } diff --git a/src/composition/evm.ts b/src/composition/evm.ts index f51903a0f4..17d5d120cc 100644 --- a/src/composition/evm.ts +++ b/src/composition/evm.ts @@ -1,5 +1,8 @@ import { EvmInterfaceService } from "../application/evm/EvmInterfaceService.js"; -import { EvmoleInterfaceProvider } from "../evm/EvmoleInterfaceProvider.js"; +import { + EvmoleInterfaceProvider, + evmLimiterAvailability, +} from "../evm/EvmoleInterfaceProvider.js"; import { CLI_COMMANDS } from "../cliCommandNames.js"; import { INSPECT_EVM_INTERFACE_CONTRACT } from "../contracts/evm/evmToolContracts.js"; @@ -9,6 +12,8 @@ export const createEvmInterfaceService = ( ): EvmInterfaceService => new EvmInterfaceService(new EvmoleInterfaceProvider(environment)); +export { evmLimiterAvailability }; + /** * Static binding of the existing operation, command and fresh service factory. * The host owns injected service identity and its retryable cleanup lifetime. diff --git a/src/evm/EvmoleInterfaceProvider.ts b/src/evm/EvmoleInterfaceProvider.ts index ca44e732c2..1b853af655 100644 --- a/src/evm/EvmoleInterfaceProvider.ts +++ b/src/evm/EvmoleInterfaceProvider.ts @@ -7,6 +7,7 @@ import { z } from "zod"; import { createAnalysisExecution, type ExecutionOptions, + type ProviderAvailability, } from "../application/AnalysisProvider.js"; import type { EvmInterfaceExecution, @@ -69,6 +70,100 @@ import { const OPERATION = "inspect_evm_interface"; const HOST_REQUIREMENT = "Initial real-verified offline EVM interface profile supports Linux x64 only."; +export const EVM_PRLIMIT_COMMAND_DEFAULT = "/usr/bin/prlimit"; + +export interface EvmLimiterProbeFileSystem { + access(path: string, mode: number): Promise; + stat(path: string): Promise<{ isFile(): boolean }>; +} + +export interface EvmLimiterProbeOptions { + readonly platform?: NodeJS.Platform; + readonly arch?: string; + readonly fs?: EvmLimiterProbeFileSystem; + readonly signal?: AbortSignal; +} + +type EvmLimiterSelection = { + readonly path: string; + readonly diagnostics: Readonly>; +}; +type EvmLimiterUnavailable = EvmLimiterSelection & { + readonly reason: string; + readonly cause: unknown; +}; + +/** Select and validate the exact limiter using the same owner check at discovery and admission. */ +const selectEvmLimiter = async ( + environment: Readonly, + probes?: { + readonly platform?: NodeJS.Platform; + readonly arch?: string; + readonly fs?: EvmLimiterProbeFileSystem; + }, + signal?: AbortSignal, + operation = OPERATION, +): Promise | undefined> => { + const throwIfAborted = (): void => { + if (signal?.aborted) + throw new AnalysisCancelledError(operation, { cause: signal.reason }); + }; + throwIfAborted(); + const platform = probes?.platform ?? process.platform; + const arch = probes?.arch ?? process.arch; + if (platform !== "linux" || arch !== "x64") return undefined; + + const path = + environment.REA_EVM_PRLIMIT_COMMAND ?? EVM_PRLIMIT_COMMAND_DEFAULT; + const diagnostics = { limiter_path: path, platform, arch }; + try { + if (!isAbsolute(path)) + throw new Error( + "REA_EVM_PRLIMIT_COMMAND must be an absolute executable path.", + ); + const fs = probes?.fs ?? { access, stat }; + await fs.access(path, constants.X_OK); + throwIfAborted(); + if (!(await fs.stat(path)).isFile()) + throw new Error( + "Configured prlimit must be a regular executable file, not a directory.", + ); + throwIfAborted(); + return ok({ path, diagnostics }); + } catch (cause: unknown) { + throwIfAborted(); + const reason = `Offline EVM interface inspection requires caller-supplied util-linux prlimit: ${path}; ${cause instanceof Error ? cause.message : String(cause)}`; + return err({ path, diagnostics, reason, cause }); + } +}; + +/** Fresh, process-free observation of the limiter prerequisite used by EVM execution. */ +export const evmLimiterAvailability = async ( + environment: Readonly, + probes?: EvmLimiterProbeOptions, +): Promise => { + const selected = await selectEvmLimiter( + environment, + probes, + probes?.signal, + "binary_session", + ); + if (selected === undefined) return undefined; + return selected.ok + ? { + status: "available", + code: null, + reason: null, + diagnostics: selected.value.diagnostics, + } + : { + status: "unavailable", + code: "executable_missing", + reason: selected.error.reason, + diagnostics: selected.error.diagnostics, + }; +}; + const replySchema = z.discriminatedUnion("ok", [ z.strictObject({ ok: z.literal(true), @@ -307,8 +402,8 @@ export class EvmoleInterfaceProvider implements EvmInterfacePort { let retainedOutput: AnalysisCapturedOutput | undefined; let phase: "configuration" | "artifact-read" | "worker" = "configuration"; let workerLimits: EvmWorkerLimits | undefined; - const limiter = - this.environment.REA_EVM_PRLIMIT_COMMAND ?? "/usr/bin/prlimit"; + let limiter = + this.environment.REA_EVM_PRLIMIT_COMMAND ?? EVM_PRLIMIT_COMMAND_DEFAULT; let result: Result; try { if (options?.signal?.aborted) @@ -322,25 +417,31 @@ export class EvmoleInterfaceProvider implements EvmInterfacePort { HOST_REQUIREMENT, { userMessage: HOST_REQUIREMENT }, ); - try { - if (!isAbsolute(limiter)) - throw new Error( - "REA_EVM_PRLIMIT_COMMAND must be an absolute executable path.", - ); - await access(limiter, constants.X_OK); - if (!(await stat(limiter)).isFile()) - throw new Error( - "Configured prlimit must be a regular executable file, not a directory.", - ); - } catch (cause: unknown) { - const reason = `Offline EVM interface inspection requires caller-supplied util-linux prlimit: ${limiter}; ${cause instanceof Error ? cause.message : String(cause)}`; + const selectedLimiter = await selectEvmLimiter( + this.environment, + undefined, + options?.signal, + ); + if (selectedLimiter === undefined) throw new AnalysisCapabilityUnavailableError( EVMOLE_PROVIDER_IDENTITY.id, OPERATION, - reason, - { cause, userMessage: reason }, + HOST_REQUIREMENT, + { userMessage: HOST_REQUIREMENT }, + ); + if (!selectedLimiter.ok) { + limiter = selectedLimiter.error.path; + throw new AnalysisCapabilityUnavailableError( + EVMOLE_PROVIDER_IDENTITY.id, + OPERATION, + selectedLimiter.error.reason, + { + cause: selectedLimiter.error.cause, + userMessage: selectedLimiter.error.reason, + }, ); } + limiter = selectedLimiter.value.path; let inheritedLimits: string; try { inheritedLimits = await readFile("/proc/self/limits", "utf8"); diff --git a/src/server/createServer.ts b/src/server/createServer.ts index 02ae613a71..9cd6b719ff 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -14,7 +14,10 @@ import { createPeResourcesService } from "../composition/binaryDiagnostics.js"; import type { PeResourcesService } from "../application/binaryDiagnostics/PeResourcesService.js"; import { registerPeResourcesTool } from "./registerPeResourcesTool.js"; import type { EvmInterfaceService } from "../application/evm/EvmInterfaceService.js"; -import { evmInterfaceModule } from "../composition/evm.js"; +import { + evmInterfaceModule, + evmLimiterAvailability, +} from "../composition/evm.js"; import { registerEvmTools } from "./registerEvmTools.js"; import type { SqliteDatabaseService } from "../application/sqlite/SqliteDatabaseService.js"; import { createSqliteDatabaseService } from "../composition/sqlite.js"; @@ -252,6 +255,12 @@ const installSessionToolAvailability = ( }; return { policy, + ...(options.evmInterface !== undefined || !linuxX64 + ? {} + : { + evmLimiterAvailability: (signal: AbortSignal) => + evmLimiterAvailability(environment, { signal }), + }), }; }; @@ -617,6 +626,9 @@ export const createServer = ( ...(availability === undefined ? {} : { availabilityPolicy: availability.policy }), + ...(availability?.evmLimiterAvailability === undefined + ? {} + : { evmLimiterAvailability: availability.evmLimiterAvailability }), androidAnalysisAvailability: (signal) => android.inspectAvailability(signal), adbAnalysisAvailability: (signal) => diff --git a/src/server/registerSessionStatusTool.ts b/src/server/registerSessionStatusTool.ts index 455f1de482..f13d13bc56 100644 --- a/src/server/registerSessionStatusTool.ts +++ b/src/server/registerSessionStatusTool.ts @@ -35,6 +35,9 @@ export interface SessionAvailabilityProbes { readonly jebAnalysisAvailability?: ( signal: AbortSignal, ) => Promise; + readonly evmLimiterAvailability?: ( + signal: AbortSignal, + ) => Promise; } /** Inputs required to register the binary session status tool. */ @@ -46,21 +49,35 @@ export interface SessionStatusToolOptions extends SessionAvailabilityProbes { readonly availabilityPolicy: () => AvailabilityPolicy; } +const probeSessionAvailability = ( + probes: SessionAvailabilityProbes, + signal: AbortSignal, +): Promise<{ + readonly android: ProviderAvailability; + readonly adb: ProviderAvailability | undefined; + readonly apktool: ProviderAvailability | undefined; + readonly jeb: JebAnalysisAvailability | undefined; + readonly evmLimiter: ProviderAvailability | undefined; +}> => + Promise.all([ + probes.androidAnalysisAvailability(signal), + probes.adbAnalysisAvailability?.(signal) ?? undefined, + probes.apktoolAnalysisAvailability?.(signal) ?? undefined, + probes.jebAnalysisAvailability?.(signal) ?? undefined, + probes.evmLimiterAvailability?.(signal) ?? undefined, + ] as const).then(([android, adb, apktool, jeb, evmLimiter]) => ({ + android, + adb, + apktool, + jeb, + evmLimiter, + })); + /** Register the read-only provider and target status operation. */ export const registerSessionStatusTool = ( options: SessionStatusToolOptions, ): void => { - const { - server, - session, - contract, - startedAt, - availabilityPolicy, - androidAnalysisAvailability, - adbAnalysisAvailability, - apktoolAnalysisAvailability, - jebAnalysisAvailability, - } = options; + const { server, session, contract, startedAt, availabilityPolicy } = options; server.registerTool( contract.name, toolRegistrationOptions(contract), @@ -69,22 +86,15 @@ export const registerSessionStatusTool = ( context, server.server, ); - let androidAvailability: ProviderAvailability; - let adbAvailability: ProviderAvailability | undefined; - let apktoolAvailability: ProviderAvailability | undefined; - let jebAvailability: JebAnalysisAvailability | undefined; + let availabilityResults: Awaited< + ReturnType + >; let status: JsonValue; try { - const [android, adb, apktool, jeb] = await Promise.all([ - androidAnalysisAvailability(context.mcpReq.signal), - adbAnalysisAvailability?.(context.mcpReq.signal) ?? undefined, - apktoolAnalysisAvailability?.(context.mcpReq.signal) ?? undefined, - jebAnalysisAvailability?.(context.mcpReq.signal) ?? undefined, - ] as const); - androidAvailability = android; - adbAvailability = adb; - apktoolAvailability = apktool; - jebAvailability = jeb; + availabilityResults = await probeSessionAvailability( + options, + context.mcpReq.signal, + ); status = await session.status({ signal: context.mcpReq.signal }); } catch (cause) { if (!(cause instanceof AnalysisError)) throw cause; @@ -95,16 +105,19 @@ export const registerSessionStatusTool = ( status, { ...availabilityPolicy(), - androidAnalysisAvailability: androidAvailability, - ...(adbAvailability === undefined + androidAnalysisAvailability: availabilityResults.android, + ...(availabilityResults.adb === undefined ? {} - : { adbAnalysisAvailability: adbAvailability }), - ...(apktoolAvailability === undefined + : { adbAnalysisAvailability: availabilityResults.adb }), + ...(availabilityResults.apktool === undefined ? {} - : { apktoolAnalysisAvailability: apktoolAvailability }), - ...(jebAvailability === undefined + : { apktoolAnalysisAvailability: availabilityResults.apktool }), + ...(availabilityResults.jeb === undefined ? {} - : { jebAnalysisAvailability: jebAvailability }), + : { jebAnalysisAvailability: availabilityResults.jeb }), + ...(availabilityResults.evmLimiter === undefined + ? {} + : { evmLimiterAvailability: availabilityResults.evmLimiter }), }, clientFeatures, ); diff --git a/tests/boundary/evm/evmLimiterAvailability.test.ts b/tests/boundary/evm/evmLimiterAvailability.test.ts new file mode 100644 index 0000000000..696afb9338 --- /dev/null +++ b/tests/boundary/evm/evmLimiterAvailability.test.ts @@ -0,0 +1,265 @@ +import { expect, it } from "vitest"; + +import { buildCapabilityInventory } from "../../../src/application/CapabilityInventory.js"; +import { AnalysisCancelledError } from "../../../src/domain/analysisErrorCore.js"; +import { + EVM_PRLIMIT_COMMAND_DEFAULT, + evmLimiterAvailability, +} from "../../../src/evm/EvmoleInterfaceProvider.js"; + +const linux = { platform: "linux" as const, arch: "x64" }; +const status = { open: false, capabilities: [] }; +const basePolicy = { processCaptureEnabled: true, evmInterfaceEnabled: true }; + +it("probes the default limiter with executable and regular-file checks", async () => { + const calls: unknown[][] = []; + const availability = await evmLimiterAvailability( + {}, + { + ...linux, + fs: { + access: async (path, mode) => { + calls.push(["access", path, mode]); + }, + stat: async (path) => { + calls.push(["stat", path]); + return { isFile: () => true }; + }, + }, + }, + ); + + expect(availability).toMatchObject({ + status: "available", + diagnostics: { limiter_path: EVM_PRLIMIT_COMMAND_DEFAULT }, + }); + expect(calls).toEqual([ + ["access", EVM_PRLIMIT_COMMAND_DEFAULT, 1], + ["stat", EVM_PRLIMIT_COMMAND_DEFAULT], + ]); +}); + +it("uses the exact absolute override and reports a failed executable check", async () => { + const limiter = "/fixture/util-linux/prlimit"; + const availability = await evmLimiterAvailability( + { REA_EVM_PRLIMIT_COMMAND: limiter }, + { + ...linux, + fs: { + access: async (path) => { + expect(path).toBe(limiter); + throw Object.assign(new Error("permission denied"), { + code: "EACCES", + }); + }, + stat: async () => { + throw new Error("stat must not run after access failure"); + }, + }, + }, + ); + + expect(availability).toMatchObject({ + status: "unavailable", + code: "executable_missing", + reason: expect.stringContaining(limiter), + diagnostics: { limiter_path: limiter }, + }); +}); + +it("rejects a relative override and non-file without probing an alternate command", async () => { + const relative = await evmLimiterAvailability( + { REA_EVM_PRLIMIT_COMMAND: "prlimit" }, + { + ...linux, + fs: { + access: async () => { + throw new Error("relative paths must be rejected first"); + }, + stat: async () => ({ isFile: () => true }), + }, + }, + ); + expect(relative).toMatchObject({ + status: "unavailable", + diagnostics: { limiter_path: "prlimit" }, + reason: expect.stringContaining("absolute executable path"), + }); + + const directory = await evmLimiterAvailability( + {}, + { + ...linux, + fs: { + access: async () => undefined, + stat: async () => ({ isFile: () => false }), + }, + }, + ); + expect(directory).toMatchObject({ + status: "unavailable", + reason: expect.stringContaining("regular executable file"), + }); +}); + +it("rechecks on each observation so inventory follows the selected executable's current state", async () => { + let accessible = true; + const fs = { + access: async () => { + if (!accessible) throw new Error("ENOENT"); + }, + stat: async () => ({ isFile: () => true }), + }; + const first = await evmLimiterAvailability({}, { ...linux, fs }); + accessible = false; + const second = await evmLimiterAvailability({}, { ...linux, fs }); + + expect(first?.status).toBe("available"); + expect(second).toMatchObject({ + status: "unavailable", + reason: expect.stringContaining("ENOENT"), + }); +}); + +it("yields the event loop while the filesystem owner probe is pending", async () => { + let release!: () => void; + const pendingAccess = new Promise((resolve) => { + release = resolve; + }); + let accessStarted!: () => void; + const started = new Promise((resolve) => { + accessStarted = resolve; + }); + const probe = evmLimiterAvailability( + {}, + { + ...linux, + fs: { + access: async () => { + accessStarted(); + await pendingAccess; + }, + stat: async () => ({ isFile: () => true }), + }, + }, + ); + await started; + let timerRan = false; + setImmediate(() => { + timerRan = true; + }); + await new Promise((resolve) => setImmediate(resolve)); + expect(timerRan).toBe(true); + release(); + expect(await probe).toMatchObject({ status: "available" }); +}); + +it.each(["access", "stat"] as const)( + "preserves cancellation while the %s filesystem probe is pending", + async (stage) => { + let release!: () => void; + const pendingProbe = new Promise((resolve) => { + release = resolve; + }); + let probeStarted!: () => void; + const started = new Promise((resolve) => { + probeStarted = resolve; + }); + const controller = new AbortController(); + const abortReason = { source: "caller-stop" }; + const probe = evmLimiterAvailability( + {}, + { + ...linux, + signal: controller.signal, + fs: { + access: async () => { + if (stage !== "access") return; + probeStarted(); + await pendingProbe; + }, + stat: async () => { + if (stage === "access") + throw new Error("stat must not run after access cancellation"); + probeStarted(); + await pendingProbe; + return { isFile: () => true }; + }, + }, + }, + ); + + try { + await started; + controller.abort(abortReason); + release(); + const error = await probe.catch((cause: unknown) => cause); + expect(error).toBeInstanceOf(AnalysisCancelledError); + expect(error).toMatchObject({ operation: "binary_session" }); + expect((error as AnalysisCancelledError).cause).toBe(abortReason); + } finally { + release(); + } + }, +); + +it("leaves non-Linux-x64 host gates unchanged", async () => { + const fs = { + access: async () => { + throw new Error("unsupported hosts must not probe prlimit"); + }, + stat: async () => ({ isFile: () => true }), + }; + expect( + await evmLimiterAvailability({}, { platform: "darwin", arch: "arm64", fs }), + ).toBeUndefined(); + expect( + await evmLimiterAvailability({}, { platform: "linux", arch: "arm64", fs }), + ).toBeUndefined(); +}); + +it("projects owner prerequisite facts into capability availability", async () => { + const unavailable = await evmLimiterAvailability( + {}, + { + ...linux, + fs: { + access: async () => { + throw new Error("ENOENT"); + }, + stat: async () => ({ isFile: () => true }), + }, + }, + ); + const unavailableInventory = buildCapabilityInventory(status, { + ...basePolicy, + ...(unavailable === undefined + ? {} + : { evmLimiterAvailability: unavailable }), + }); + expect( + unavailableInventory.find(({ name }) => name === "inspect_evm_interface"), + ).toMatchObject({ + available: false, + reason: "provider_missing", + remediation: expect.stringContaining("caller-supplied util-linux prlimit"), + }); + + const available = await evmLimiterAvailability( + {}, + { + ...linux, + fs: { + access: async () => undefined, + stat: async () => ({ isFile: () => true }), + }, + }, + ); + const availableInventory = buildCapabilityInventory(status, { + ...basePolicy, + ...(available === undefined ? {} : { evmLimiterAvailability: available }), + }); + expect( + availableInventory.find(({ name }) => name === "inspect_evm_interface"), + ).toMatchObject({ available: true, reason: "available", remediation: null }); +}); diff --git a/tests/boundary/mcp/evmLimiterAvailability.test.ts b/tests/boundary/mcp/evmLimiterAvailability.test.ts new file mode 100644 index 0000000000..396b6c4551 --- /dev/null +++ b/tests/boundary/mcp/evmLimiterAvailability.test.ts @@ -0,0 +1,109 @@ +import { join } from "node:path"; + +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { expect, it } from "vitest"; +import { z } from "zod"; + +import { EvmInterfaceService } from "../../../src/application/evm/EvmInterfaceService.js"; +import { + expandToolAvailability, + sessionProvider, +} from "../../../src/contracts/toolOutputSchemaPrimitives.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { createTestBinarySession } from "../../fixtures/binarySession.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +const linuxX64 = process.platform === "linux" && process.arch === "x64"; + +const connect = async ( + options: Parameters[1], +): Promise<{ + readonly client: Client; + readonly server: ReturnType; + readonly availability: () => Promise | undefined>; + readonly close: () => Promise; +}> => { + const session = createTestBinarySession(() => { + throw new Error("binary analysis must not start during status discovery"); + }); + const server = createServer({ kind: "session", session }, options); + const client = new Client({ name: "evm-limiter-availability", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + return { + client, + server, + availability: async () => { + const response = await client.callTool({ + name: "binary_session", + arguments: {}, + }); + if (response.isError === true) throw new Error("binary_session failed"); + const parsed = z + .object({ result: sessionProvider }) + .parse(response.structuredContent).result; + return expandToolAvailability(parsed.tool_availability).find( + ({ name }) => name === "inspect_evm_interface", + ); + }, + close: async () => { + await Promise.allSettled([ + client.close(), + server.close(), + session.close(), + ]); + }, + }; +}; + +it.runIf(linuxX64)( + "reports a missing selected limiter in the live default-provider session inventory", + async () => { + const directory = await createTestTempDirectory("rea-evm-limiter-mcp-"); + const limiter = join(directory, "missing-prlimit"); + const harness = await connect({ + environment: { + REA_EVM_PRLIMIT_COMMAND: limiter, + REA_MCP_OUTPUT_SCHEMA_PROFILE: "off", + }, + }); + try { + expect(await harness.availability()).toMatchObject({ + available: false, + reason: "provider_missing", + remediation: expect.stringContaining(limiter), + }); + } finally { + await harness.close(); + } + }, +); + +it("keeps a caller-injected EVM service independent of the default prlimit prerequisite", async () => { + const directory = await createTestTempDirectory("rea-evm-limiter-injected-"); + const limiter = join(directory, "missing-prlimit"); + const service = new EvmInterfaceService({ + inspect: async () => { + throw new Error("injected service must not be invoked by status"); + }, + close: async () => {}, + }); + const harness = await connect({ + environment: { + REA_EVM_PRLIMIT_COMMAND: limiter, + REA_MCP_OUTPUT_SCHEMA_PROFILE: "off", + }, + evmInterface: service, + }); + try { + expect(await harness.availability()).toMatchObject({ + available: true, + reason: "available", + remediation: null, + }); + } finally { + await harness.close(); + } +});