fix(hopper)!: isolate regex matching from the native API thread

BREAKING CHANGE: Hopper regex mode uses ECMAScript Unicode syntax instead of Python re. Matching runs in a cancellable worker with a five-second deadline; literal mode keeps Unicode casefold matching.
This commit is contained in:
morluto
2026-10-09 05:06:48 +08:00
parent 246170238e
commit f201b04fbb
10 changed files with 365 additions and 14 deletions
+8 -2
View File
@@ -9,7 +9,10 @@ import addFormats from "ajv-formats";
import { mcpTextValue, requireMcpResult } from "./mcp-verifier-results.mjs";
import { verifyHopperNavigationAndText } from "./real-hopper-navigation.mjs";
import { verifyHopperWorkflows } from "./real-hopper-workflows.mjs";
import { verifyHopperSearch } from "./real-hopper-search.mjs";
import {
verifyHopperSearch,
verifyHopperRegexIsolation,
} from "./real-hopper-search.mjs";
/** Exercise real Hopper annotation and malformed-input boundaries over MCP. */
export async function verifyHopperBoundaryContracts(
@@ -75,7 +78,10 @@ export async function verifyHopperBoundaryContracts(
address,
);
const workflows = await verifyHopperWorkflows(call, invalid, procedure);
const search = await verifyHopperSearch(call, invalid, procedure);
const search = {
...(await verifyHopperSearch(call, invalid, procedure)),
...(await verifyHopperRegexIsolation(client, options, call, address)),
};
try {
if (foreignDocument !== undefined) {
await invalid(
+57
View File
@@ -1,4 +1,5 @@
import assert from "node:assert/strict";
import { mcpTextValue } from "./mcp-verifier-results.mjs";
/** Exercise literal/regex search, Unicode identities and cache invalidation in Hopper. */
export async function verifyHopperSearch(call, invalid, procedure) {
@@ -228,3 +229,59 @@ export async function verifyHopperStringObjects(call, expectations) {
displayEscapesExcludedFromMatching: true,
};
}
/** A failed or cancelled pathological regex must leave the real Hopper API usable. */
export async function verifyHopperRegexIsolation(
client,
options,
call,
address,
) {
const original = await call("address_name", { address });
const pathological = "a".repeat(40) + "!";
try {
await call("set_address_name", { address, name: pathological });
const reply = await client.callTool(
{
name: "search_procedures",
arguments: { pattern: "(a+)+$", mode: "regex", case_sensitive: true },
},
options,
);
assert.equal(reply.isError, true);
const { error } = JSON.parse(mcpTextValue(reply));
assert.equal(error.code, "resource_constraint");
assert.equal(error.details.operation, "search_procedures");
assert.equal(error.details.resource, "cpu");
assert.equal(await call("address_name", { address }), pathological);
const controller = new AbortController();
const pending = client.callTool(
{
name: "search_procedures",
arguments: { pattern: "(a+)+$", mode: "regex", case_sensitive: true },
},
{ ...options, signal: controller.signal },
);
const cancelled = pending.catch((cause) => cause);
// The same small inventory has already completed once; leave the pathological
// matching worker running before cancelling the second caller.
await new Promise((resolve) => setTimeout(resolve, 750));
controller.abort();
await cancelled;
assert.equal(await call("address_name", { address }), pathological);
assert.deepEqual(
await call("search_procedures", {
pattern: pathological,
mode: "literal",
case_sensitive: true,
}),
[{ address, value: pathological }],
);
} finally {
await call("set_address_name", { address, name: original });
}
return {
regexDeadlinePreservesSession: true,
regexCancellationPreservesSession: true,
};
}