mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
fix(ghidra): enforce request limits before encoding (#6917)
* fix(ghidra): enforce the bridge request ceiling before writing
Hopper and Cutter both measure an oversized payload and refuse it before the
request reaches its bridge, using the shared REA_BRIDGE_MAX_REQUEST_BYTES
ceiling. Ghidra had no ceiling on either end: GhidraWire.request wrote the
stringified request straight to the socket, and the Java bridge reads request
lines with an unbounded readLine() after its bounded 64 KiB handshake.
Serialize once, measure the encoded payload, and refuse before the operation
is registered as pending and before any socket write, so an oversized request
never reaches the bridge. Carry the typed constraint as the cause and
re-attribute it in projectPrimarySessionError, so callers see the same
resource constraint Hopper and Cutter already report.
The Ghidra bridge reads no ceiling of its own, so its recovery text names that
only the REA client applies the setting instead of asking for a bridge restart.
Validation: src/ghidra/GhidraClientWire.test.ts (3 cases, red then green),
src/config/bridgeMaxRequestBytes.test.ts (16), src/ghidra/GhidraRequestQueue.test.ts
(25); tsc --noEmit over the changed set exits 0; oxlint reports 0 errors;
oxfmt --check clean; verify:module-boundaries passes. The two failures in the
wider src/config+src/ghidra+src/hopper+src/cutter sweep
(captureDecoderHeap, GhidraAnalysisSeeds symlink EPERM) reproduce identically
on pristine bded9c628 with this change stashed. The Ghidra fixture suite
cannot run on this Windows host: fakeGhidra.mjs renames bridge-endpoint.json
and hits EBUSY on 3 of 3 runs, so the fixture-level test was not added.
* fix(ghidra): preflight request bytes before encoding
---------
Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
This commit is contained in:
@@ -36,6 +36,18 @@ provider stays bound to the target; runtime failure never selects another
|
||||
provider automatically. The contracts below describe Ghidra observations,
|
||||
not equivalence with another engine's pseudocode or analysis database.
|
||||
|
||||
## Request payload ceiling
|
||||
|
||||
Ghidra requests use `REA_BRIDGE_MAX_REQUEST_BYTES`: default 16 MiB, with a
|
||||
configurable range of 7,464–536,870,887 bytes. REA measures UTF-8 JSON, including
|
||||
escaping, before allocating the serialized request or sending it. The framing
|
||||
newline is excluded. An oversized request returns `resource_constraint` with
|
||||
the effective setting, its source, and the exact payload byte count.
|
||||
|
||||
Set the value in the environment that starts REA, then restart REA to apply it.
|
||||
This setting is enforced by the REA client; the Java bridge has no corresponding
|
||||
request ceiling.
|
||||
|
||||
## Admitted inventory semantics
|
||||
|
||||
| Concern | Ghidra contract |
|
||||
|
||||
@@ -55,6 +55,13 @@ export const DEFAULT_BRIDGE_MAX_REQUEST: BridgeMaxRequestBytes = {
|
||||
source: "default",
|
||||
};
|
||||
|
||||
/**
|
||||
* Peer a ceiling refusal is reported for. Ghidra differs from the other two:
|
||||
* its Java bridge reads no request ceiling, so only the REA client enforces
|
||||
* this setting for Ghidra and recovery must not ask for a bridge restart.
|
||||
*/
|
||||
export type BridgePeer = "Hopper" | "Cutter" | "Ghidra";
|
||||
|
||||
export const bridgeMaxRequestBytesConstraint = `${BRIDGE_MAX_REQUEST_BYTES_SETTING} must be a positive decimal integer from ${String(MINIMUM_BRIDGE_MAX_REQUEST_BYTES)} to ${String(MAXIMUM_BRIDGE_MAX_REQUEST_BYTES)} bytes; the default is ${String(DEFAULT_BRIDGE_MAX_REQUEST_BYTES)} bytes (16 MiB). A value below ${String(MINIMUM_BRIDGE_MAX_REQUEST_BYTES)} bytes cannot carry the largest admitted symbol-name command, and a larger value is refused so the encoded payload plus its framing LF stays one string under Node's single-string ceiling.`;
|
||||
|
||||
const decimalInteger = /^[1-9]\d*$/u;
|
||||
@@ -114,11 +121,14 @@ export const resolveBridgeMaxRequestBytes = (
|
||||
/** How to read and change the effective bridge request ceiling. */
|
||||
export const bridgeMaxRequestBytesRecovery = (
|
||||
limit: BridgeMaxRequestBytes,
|
||||
peer: BridgePeer = "Hopper",
|
||||
): string =>
|
||||
[
|
||||
`The effective ${BRIDGE_MAX_REQUEST_BYTES_SETTING} value is ${String(limit.maximumBytes)} bytes (source ${limit.source}; default ${String(DEFAULT_BRIDGE_MAX_REQUEST_BYTES)}, floor ${String(MINIMUM_BRIDGE_MAX_REQUEST_BYTES)}, maximum ${String(MAXIMUM_BRIDGE_MAX_REQUEST_BYTES)}).`,
|
||||
`Set ${BRIDGE_MAX_REQUEST_BYTES_SETTING} in the environment that starts REA to a decimal integer from ${String(MINIMUM_BRIDGE_MAX_REQUEST_BYTES)} to ${String(MAXIMUM_BRIDGE_MAX_REQUEST_BYTES)}, then restart REA.`,
|
||||
"Restart Hopper or Cutter from that same environment so the bridge uses this ceiling.",
|
||||
peer === "Ghidra"
|
||||
? `The Ghidra bridge enforces no request ceiling of its own, so only the REA client applies ${BRIDGE_MAX_REQUEST_BYTES_SETTING} to Ghidra requests.`
|
||||
: "Restart Hopper or Cutter from that same environment so the bridge uses this ceiling.",
|
||||
].join(" ");
|
||||
|
||||
const limitFacts = (
|
||||
@@ -132,13 +142,13 @@ export const bridgeRequestOversizeError = (
|
||||
operation: string,
|
||||
limit: BridgeMaxRequestBytes,
|
||||
payloadBytes: number,
|
||||
peer: "Hopper" | "Cutter",
|
||||
peer: BridgePeer,
|
||||
): AnalysisResourceConstraintError => {
|
||||
const recovery = bridgeMaxRequestBytesRecovery(limit);
|
||||
const recovery = bridgeMaxRequestBytesRecovery(limit, peer);
|
||||
const refusal =
|
||||
peer === "Cutter"
|
||||
? `Cutter request exceeds its authenticated frame limit of ${String(limit.maximumBytes)} bytes; no command was sent.`
|
||||
: `Hopper request exceeds its ${String(limit.maximumBytes)}-byte payload limit; no request was sent.`;
|
||||
: `${peer} request exceeds its ${String(limit.maximumBytes)}-byte payload limit; no request was sent.`;
|
||||
return new AnalysisResourceConstraintError(
|
||||
operation,
|
||||
"transport",
|
||||
@@ -153,10 +163,10 @@ export const bridgeRequestLimitMismatchError = (
|
||||
operation: string,
|
||||
limit: BridgeMaxRequestBytes,
|
||||
bridgeBytes: number,
|
||||
peer: "Hopper" | "Cutter",
|
||||
peer: BridgePeer,
|
||||
sent = peer === "Cutter" ? "No command was sent." : "No request was sent.",
|
||||
): AnalysisResourceConstraintError => {
|
||||
const recovery = bridgeMaxRequestBytesRecovery(limit);
|
||||
const recovery = bridgeMaxRequestBytesRecovery(limit, peer);
|
||||
return new AnalysisResourceConstraintError(
|
||||
operation,
|
||||
"transport",
|
||||
@@ -173,7 +183,7 @@ export const bridgeRequestLimitMismatchError = (
|
||||
export const bridgeResponseOversizeError = (
|
||||
limit: BridgeMaxRequestBytes,
|
||||
): AnalysisResourceConstraintError => {
|
||||
const recovery = bridgeMaxRequestBytesRecovery(limit);
|
||||
const recovery = bridgeMaxRequestBytesRecovery(limit, "Cutter");
|
||||
return new AnalysisResourceConstraintError(
|
||||
"cutter_command",
|
||||
"transport",
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
ProviderProcessSupervisor,
|
||||
} from "../process/ProviderProcess.js";
|
||||
import { DEFAULT_GHIDRA_STARTUP_TIMEOUT_MS } from "../config/providerStartupTimeout.js";
|
||||
import { DEFAULT_BRIDGE_MAX_REQUEST } from "../config/bridgeMaxRequestBytes.js";
|
||||
import type {
|
||||
GhidraClientOptions,
|
||||
GhidraRequestOptions,
|
||||
@@ -199,6 +200,8 @@ export class GhidraClient {
|
||||
pending: this.#pending,
|
||||
logger: this.#logger,
|
||||
failure: this.#failure,
|
||||
bridgeMaxRequestBytes:
|
||||
options.bridgeMaxRequestBytes ?? DEFAULT_BRIDGE_MAX_REQUEST,
|
||||
});
|
||||
this.#requestQueue = new GhidraRequestQueue(
|
||||
async (method, parameters, requestOptions) => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Logger } from "pino";
|
||||
import type { BridgeMaxRequestBytes } from "../config/bridgeMaxRequestBytes.js";
|
||||
import type { Result } from "../domain/result.js";
|
||||
import type { LinuxCgroupMemoryState } from "../process/LinuxCgroupMemory.js";
|
||||
import type { GhidraLauncher } from "./GhidraLauncher.js";
|
||||
@@ -49,4 +50,6 @@ export interface GhidraClientOptions {
|
||||
readonly logger?: Logger;
|
||||
/** Observes REA's memory cgroup around a run; defaults to the Linux cgroup v2 reader. */
|
||||
readonly readMemoryCgroup?: () => Promise<LinuxCgroupMemoryState | undefined>;
|
||||
/** Effective request ceiling; defaults to the configured bridge ceiling. */
|
||||
readonly bridgeMaxRequestBytes?: BridgeMaxRequestBytes;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import type { Socket } from "node:net";
|
||||
|
||||
import pino from "pino";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { BridgeMaxRequestBytes } from "../config/bridgeMaxRequestBytes.js";
|
||||
import { AnalysisResourceConstraintError } from "../domain/analysisErrorCore.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import { ok, type Result } from "../domain/result.js";
|
||||
import { PendingOperations } from "../process/PendingOperations.js";
|
||||
import { GhidraWire } from "./GhidraClientWire.js";
|
||||
import {
|
||||
bindGhidraSessionFailure,
|
||||
type GhidraSessionError,
|
||||
} from "./GhidraSessionError.js";
|
||||
|
||||
const failure = bindGhidraSessionFailure(() => ({
|
||||
target_path: "/tmp/wire-fixture",
|
||||
}));
|
||||
|
||||
const limit = (maximumBytes: number): BridgeMaxRequestBytes => ({
|
||||
maximumBytes,
|
||||
source: "setting",
|
||||
});
|
||||
|
||||
type WireHarness = {
|
||||
readonly writes: string[];
|
||||
readonly pending: PendingOperations<
|
||||
number,
|
||||
Result<JsonValue, GhidraSessionError>
|
||||
>;
|
||||
readonly wire: GhidraWire;
|
||||
};
|
||||
|
||||
const harness = (ceiling: BridgeMaxRequestBytes): WireHarness => {
|
||||
const writes: string[] = [];
|
||||
const socket = {
|
||||
destroyed: false,
|
||||
write: (payload: string, callback?: (cause?: Error | null) => void) => {
|
||||
writes.push(payload);
|
||||
callback?.(null);
|
||||
return true;
|
||||
},
|
||||
} as unknown as Socket;
|
||||
const pending = new PendingOperations<
|
||||
number,
|
||||
Result<JsonValue, GhidraSessionError>
|
||||
>();
|
||||
return {
|
||||
writes,
|
||||
pending,
|
||||
wire: new GhidraWire({
|
||||
getSocket: () => socket,
|
||||
getToken: () => "b".repeat(64),
|
||||
nextId: () => 1,
|
||||
pending,
|
||||
logger: pino({ level: "silent" }),
|
||||
failure,
|
||||
bridgeMaxRequestBytes: ceiling,
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
describe("GhidraWire request ceiling", () => {
|
||||
it("refuses escaped oversized input before encoding, pending registration or write", async () => {
|
||||
const { writes, pending, wire } = harness(limit(8192));
|
||||
const params = { pattern: "\u0000".repeat(1500) };
|
||||
const payloadBytes = Buffer.byteLength(
|
||||
JSON.stringify({
|
||||
id: 1,
|
||||
token: "b".repeat(64),
|
||||
method: "search_strings",
|
||||
params,
|
||||
}),
|
||||
);
|
||||
expect(Buffer.byteLength(params.pattern)).toBeLessThan(8192);
|
||||
expect(payloadBytes).toBeGreaterThan(8192);
|
||||
const stringify = vi.spyOn(JSON, "stringify");
|
||||
try {
|
||||
const refused = await wire.request("search_strings", params, {});
|
||||
if (refused.ok) throw new Error("Expected a request ceiling refusal");
|
||||
expect(refused.error.message).toContain("REA_BRIDGE_MAX_REQUEST_BYTES");
|
||||
expect(refused.error.message).toContain("8192");
|
||||
expect(refused.error.message).toContain("Ghidra request exceeds");
|
||||
const constraint = refused.error.cause;
|
||||
if (!(constraint instanceof AnalysisResourceConstraintError))
|
||||
throw new Error("Expected the typed transport constraint");
|
||||
expect(constraint.reportedLimits).toMatchObject({
|
||||
max_request_bytes: 8192,
|
||||
payload_bytes: payloadBytes,
|
||||
});
|
||||
expect(constraint.retryable).toBe(false);
|
||||
expect(writes.length).toBe(0);
|
||||
expect(pending.has(1)).toBe(false);
|
||||
const encodings = stringify.mock.calls.filter(
|
||||
([value]: readonly unknown[]) =>
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"params" in value &&
|
||||
value.params === params,
|
||||
).length;
|
||||
expect(encodings).toBe(0);
|
||||
} finally {
|
||||
stringify.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([true, false])(
|
||||
"enforces the exact UTF-8 JSON payload boundary (admitted: %s)",
|
||||
async (admitted) => {
|
||||
const params = { pattern: '前缀"\\\u0000😀\ud800'.repeat(1024) };
|
||||
const encoded = JSON.stringify({
|
||||
id: 1,
|
||||
token: "b".repeat(64),
|
||||
method: "search_strings",
|
||||
params,
|
||||
});
|
||||
const payloadBytes = Buffer.byteLength(encoded);
|
||||
const { writes, pending, wire } = harness(
|
||||
limit(payloadBytes - (admitted ? 0 : 1)),
|
||||
);
|
||||
const dispatched = wire.request("search_strings", params, {});
|
||||
pending.settle(1, ok("ok"));
|
||||
const result = await dispatched;
|
||||
if (admitted) {
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value).toBe("ok");
|
||||
expect(writes.length).toBe(1);
|
||||
expect(Buffer.byteLength(writes[0] ?? "")).toBe(payloadBytes + 1);
|
||||
expect(writes[0] === encoded + "\n").toBe(true);
|
||||
} else {
|
||||
if (result.ok) throw new Error("Expected a request ceiling refusal");
|
||||
expect(result.error.cause).toBeInstanceOf(
|
||||
AnalysisResourceConstraintError,
|
||||
);
|
||||
expect(writes.length).toBe(0);
|
||||
}
|
||||
expect(pending.has(1)).toBe(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { Socket } from "node:net";
|
||||
|
||||
import {
|
||||
bridgeRequestOversizeError,
|
||||
type BridgeMaxRequestBytes,
|
||||
} from "../config/bridgeMaxRequestBytes.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import { jsonValueByteLength } from "../domain/jsonValueByteLength.js";
|
||||
import { err, type Result } from "../domain/result.js";
|
||||
import type { Logger } from "pino";
|
||||
import type { PendingOperations } from "../process/PendingOperations.js";
|
||||
@@ -21,6 +26,8 @@ export interface GhidraWireOptions {
|
||||
>;
|
||||
readonly logger: Logger;
|
||||
readonly failure: GhidraSessionFailureFactory;
|
||||
/** Effective Ghidra request ceiling; the bridge itself enforces none. */
|
||||
readonly bridgeMaxRequestBytes: BridgeMaxRequestBytes;
|
||||
}
|
||||
|
||||
/** Owns the socket write and pending-reply lifecycle for one bridge session. */
|
||||
@@ -56,6 +63,22 @@ export class GhidraWire {
|
||||
),
|
||||
);
|
||||
const timeoutMs = options.timeoutMs;
|
||||
const payloadValue = { id, token, method, params };
|
||||
const payloadBytes = jsonValueByteLength(payloadValue);
|
||||
// Refuse before registering the operation and before any socket write, so
|
||||
// an oversized request never reaches the bridge or occupies a pending key.
|
||||
if (payloadBytes > this.#options.bridgeMaxRequestBytes.maximumBytes) {
|
||||
const oversize = bridgeRequestOversizeError(
|
||||
method,
|
||||
this.#options.bridgeMaxRequestBytes,
|
||||
payloadBytes,
|
||||
"Ghidra",
|
||||
);
|
||||
return err(
|
||||
this.#options.failure("admission", oversize.message, oversize),
|
||||
);
|
||||
}
|
||||
const payload = JSON.stringify(payloadValue);
|
||||
const response = this.#options.pending.wait(id, {
|
||||
...(timeoutMs === undefined
|
||||
? {}
|
||||
@@ -74,22 +97,19 @@ export class GhidraWire {
|
||||
...(options.signal === undefined ? {} : { signal: options.signal }),
|
||||
cancelledValue: () => err(this.#cancelled(options.signal)),
|
||||
});
|
||||
socket.write(
|
||||
`${JSON.stringify({ id, token, method, params })}\n`,
|
||||
(cause) => {
|
||||
if (cause !== undefined && cause !== null)
|
||||
this.#options.pending.settle(
|
||||
id,
|
||||
err(
|
||||
this.#options.failure(
|
||||
"process",
|
||||
"Ghidra bridge write failed",
|
||||
cause,
|
||||
),
|
||||
socket.write(`${payload}\n`, (cause) => {
|
||||
if (cause !== undefined && cause !== null)
|
||||
this.#options.pending.settle(
|
||||
id,
|
||||
err(
|
||||
this.#options.failure(
|
||||
"process",
|
||||
"Ghidra bridge write failed",
|
||||
cause,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
),
|
||||
);
|
||||
});
|
||||
const result = await response;
|
||||
this.#options.logger[result.ok ? "debug" : "warn"](
|
||||
{ method, status: result.ok ? "ok" : "error" },
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
AnalysisInputError,
|
||||
AnalysisResourceConstraintError,
|
||||
AnalysisTimeoutError,
|
||||
resourceConstraintRecovery,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import type { EvidenceLocation } from "../domain/evidence.js";
|
||||
@@ -349,6 +350,7 @@ export const createGhidraProviderClient = (input: {
|
||||
);
|
||||
const client = clientFactory({
|
||||
startupTimeoutMs: config.ghidraStartupTimeoutMs,
|
||||
bridgeMaxRequestBytes: config.bridgeMaxRequestBytes,
|
||||
platform: installation.platform,
|
||||
launcher: new GhidraHeadlessLauncher({
|
||||
environment: input.environment,
|
||||
@@ -897,6 +899,17 @@ const projectPrimarySessionError = (
|
||||
const { cause: physicalCause, origin } = artifactFilePrimaryFacts(
|
||||
failure.cause,
|
||||
);
|
||||
// A client-side ceiling refusal travels as the cause; re-attribute it to this
|
||||
// operation so callers see a typed resource constraint rather than a generic
|
||||
// session failure, matching how Hopper and Cutter report the same ceiling.
|
||||
if (physicalCause instanceof AnalysisResourceConstraintError)
|
||||
return new AnalysisResourceConstraintError(
|
||||
operation,
|
||||
physicalCause.resource,
|
||||
physicalCause.reason,
|
||||
physicalCause.reportedLimits,
|
||||
{ ...context, ...resourceConstraintRecovery(physicalCause) },
|
||||
);
|
||||
if (
|
||||
origin === "owned-or-classified" &&
|
||||
physicalCause instanceof AnalysisAccessDeniedError
|
||||
|
||||
@@ -20,6 +20,7 @@ const MAX_COMMAND_OUTPUT_BYTES = 8 * 1024;
|
||||
|
||||
/** Stable failure phase for one private Ghidra headless session. */
|
||||
export type GhidraSessionFailureKind =
|
||||
| "admission"
|
||||
| "cancelled"
|
||||
| "timeout"
|
||||
| "start"
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { access } from "node:fs/promises";
|
||||
|
||||
import { expect, it, vi } from "vitest";
|
||||
|
||||
import { AnalysisProviderRegistry } from "../../../src/application/binary/AnalysisProviderRegistry.js";
|
||||
import { composeBinarySession } from "../../../src/application/binary/BinarySessionComposition.js";
|
||||
import { parseConfig } from "../../../src/config/parseConfig.js";
|
||||
import { GhidraClient } from "../../../src/ghidra/GhidraClient.js";
|
||||
import { ghidraProfileInstallationHost } from "../../../src/ghidra/GhidraInstallation.fixture.js";
|
||||
import { GhidraProvider } from "../../../src/ghidra/GhidraProvider.js";
|
||||
import type { GhidraProviderClientFactory } from "../../../src/ghidra/GhidraProviderClient.js";
|
||||
import { silentLogger } from "../../../src/logger.js";
|
||||
import { createApplicationMcpHarness } from "../../fixtures/applicationMcpHarness.js";
|
||||
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
|
||||
import { GhidraFixtureLauncher } from "../providers/ghidra/ghidraClient.fixture.js";
|
||||
|
||||
it("reports local Ghidra admission through SDK MCP without dispatching or losing the warm session", async () => {
|
||||
const launcher = new GhidraFixtureLauncher();
|
||||
let raw: GhidraClient | undefined;
|
||||
const factory: GhidraProviderClientFactory = (options) => {
|
||||
raw = new GhidraClient({
|
||||
...options,
|
||||
platform: process.platform,
|
||||
transport: "authenticated-loopback-tcp",
|
||||
launcher,
|
||||
});
|
||||
return raw;
|
||||
};
|
||||
const config = parseConfig({
|
||||
GHIDRA_INSTALL_DIR: "/fixture/ghidra",
|
||||
REA_GHIDRA_STARTUP_TIMEOUT_MS: "10000",
|
||||
REA_BRIDGE_MAX_REQUEST_BYTES: "8192",
|
||||
});
|
||||
if (!config.ok) throw config.error;
|
||||
const provider = new GhidraProvider(
|
||||
config.value,
|
||||
silentLogger,
|
||||
{},
|
||||
ghidraProfileInstallationHost,
|
||||
factory,
|
||||
);
|
||||
const session = composeBinarySession(
|
||||
new AnalysisProviderRegistry([provider]),
|
||||
);
|
||||
const harness = await createApplicationMcpHarness({
|
||||
session,
|
||||
serverOptions: { logger: silentLogger },
|
||||
clientIdentity: { name: "ghidra-request-ceiling", version: "1" },
|
||||
});
|
||||
try {
|
||||
const opened = await harness.client.callTool({
|
||||
name: "open_binary",
|
||||
arguments: { path: process.execPath, provider_id: "ghidra" },
|
||||
});
|
||||
expect(opened.isError).not.toBe(true);
|
||||
const first = await harness.client.callTool({
|
||||
name: "list_strings",
|
||||
arguments: {},
|
||||
});
|
||||
expect(first.isError).not.toBe(true);
|
||||
const refused = await harness.client.callTool({
|
||||
name: "search_strings",
|
||||
arguments: { pattern: "\u0000".repeat(1500) },
|
||||
});
|
||||
expect(refused.isError).toBe(true);
|
||||
const projected = parseMcpToolError(refused);
|
||||
expect(projected).toMatchObject({
|
||||
error: {
|
||||
code: "resource_constraint",
|
||||
details: {
|
||||
operation: "search_strings",
|
||||
resource: "transport",
|
||||
reported_limits: { max_request_bytes: 8192 },
|
||||
diagnostics: { failure_kind: "admission" },
|
||||
},
|
||||
remediation: {
|
||||
action: expect.stringContaining("REA_BRIDGE_MAX_REQUEST_BYTES"),
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(projected.error.remediation?.action).toContain(
|
||||
"only the REA client applies",
|
||||
);
|
||||
expect(
|
||||
projected.error.remediation?.action.includes("Restart Hopper or Cutter"),
|
||||
).toBe(false);
|
||||
expect(raw?.isReady()).toBe(true);
|
||||
const after = await harness.client.callTool({
|
||||
name: "list_strings",
|
||||
arguments: {},
|
||||
});
|
||||
expect(after.isError).not.toBe(true);
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
launcher.requests.filter((method) => method === "list_strings"),
|
||||
).toHaveLength(2),
|
||||
);
|
||||
expect(
|
||||
launcher.requests.filter((method) => method === "search_strings"),
|
||||
).toHaveLength(0);
|
||||
expect(launcher.processes).toHaveLength(1);
|
||||
for (const token of launcher.tokens)
|
||||
expect(JSON.stringify(refused).includes(token)).toBe(false);
|
||||
} finally {
|
||||
await harness.close();
|
||||
for (const root of launcher.runtimeRoots)
|
||||
await expect(access(root)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
for (const child of launcher.processes)
|
||||
expect(child.exitCode !== null || child.signalCode !== null).toBe(true);
|
||||
}
|
||||
}, 30_000);
|
||||
Reference in New Issue
Block a user