Merge pull request #322 from starsstreaming/codex/request-help-safe-regex

fix(extension): 用线性正则匹配 request-help 的 URL 完成条件
This commit is contained in:
Zhang GH
2026-09-26 20:16:25 +08:00
committed by GitHub
11 changed files with 479 additions and 29 deletions
+1
View File
@@ -16,6 +16,7 @@
"@browser-skill/ui": "workspace:*",
"@browser-skill/vom": "workspace:*",
"@remixicon/react": "^4.9.0",
"re2js": "2.8.6",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
@@ -1,3 +1,7 @@
import { spawnSync } from "node:child_process";
import { createRequire } from "node:module";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, describe, expect, it, vi } from "vitest";
import { InteractionPreferenceStore } from "@/lib/interaction-preferences";
import { SessionManager } from "@/session-manager/manager";
@@ -544,6 +548,331 @@ describe("handleRequestHelp", () => {
expect(res).toMatchObject({ outcome: "completed", completed_by: "system", tab_id: 5 });
});
it.skipIf(process.env.BSK_URL_REGEX_CHILD === "1")(
"bounds dangerous URL regex checks in a child process",
() => {
const require = createRequire(import.meta.url);
const vitestCli = join(dirname(require.resolve("vitest/package.json")), "vitest.mjs");
const child = spawnSync(
process.execPath,
[
vitestCli,
"run",
fileURLToPath(import.meta.url),
"-t",
"isolated dangerous URL regex checks",
],
{
cwd: process.cwd(),
env: { ...process.env, BSK_URL_REGEX_CHILD: "1" },
encoding: "utf8",
timeout: 10_000,
},
);
expect(child.error, `${child.stdout}\n${child.stderr}`).toBeUndefined();
expect(child.status, `${child.stdout}\n${child.stderr}`).toBe(0);
},
);
it.skipIf(process.env.BSK_URL_REGEX_CHILD !== "1")(
"isolated dangerous URL regex checks",
async () => {
const url = `https://app.example/${"a".repeat(28)}!`;
const deps = baseDeps({
sendToTab: vi.fn(async () => ({ type: "bsk-help-ack", ok: true })),
tabsApi: {
get: vi.fn(async () => ({ id: 5, windowId: 99, active: true, url })) as never,
query: vi.fn(async () => [{ id: 5, windowId: 99, active: true }] as never),
},
});
const started = performance.now();
const res = await handleRequestHelp(
fakeManager("abcd", 99, 5),
baseParams({
completion_criteria: {
any: [{ url_matches: "(a+)+$" }, { url_matches: "^https://app.example/a+!$" }],
stable_for_ms: 0,
},
}),
deps,
);
expect(res).toMatchObject({ outcome: "completed", completed_by: "system" });
expect(performance.now() - started).toBeLessThan(1_000);
const expanded = handleRequestHelp(
fakeManager("abcd", 99, 5),
baseParams({
completion_criteria: { any: [{ url_matches: `(?:${"[ab]".repeat(20)}){1000}$` }] },
}),
deps,
);
await expect(expanded).resolves.toMatchObject({ code: "invalid_params" });
const nearLimit = handleRequestHelp(
fakeManager("abcd", 99, 5),
baseParams({
completion_criteria: { any: [{ url_matches: `(?:${"a".repeat(118)}){1000}` }] },
}),
deps,
);
await expect(nearLimit).resolves.toMatchObject({ code: "invalid_params" });
},
);
it.each([
{ label: "unsupported lookahead", pattern: "(?=a)" },
{ label: "unsupported lookbehind", pattern: "(?<=a)b" },
{ label: "unsupported Unicode escape", pattern: "\\u0061" },
{ label: "unsupported large repetition", pattern: "a{1001}" },
{ label: "unsupported empty negated class", pattern: "[^]" },
{ label: "unsupported backreference", pattern: "(a)\\1" },
{ label: "oversized pattern", pattern: "a".repeat(129) },
])("rejects $label before starting help", async ({ pattern }) => {
const deps = baseDeps();
const res = await handleRequestHelp(
fakeManager("abcd", 99, 5),
baseParams({ completion_criteria: { any: [{ url_matches: pattern }] } }),
deps,
);
expect(res).toMatchObject({ code: "invalid_params" });
expect(deps.activateTab).not.toHaveBeenCalled();
expect(deps.sendToTab).not.toHaveBeenCalled();
});
it("rejects too many conditions before starting help", async () => {
const deps = baseDeps();
const res = await handleRequestHelp(
fakeManager("abcd", 99, 5),
baseParams({
completion_criteria: {
all: Array.from({ length: 5 }, () => ({ url_contains: "/done" })),
any: Array.from({ length: 4 }, () => ({ selector_exists: "#done" })),
},
}),
deps,
);
expect(res).toMatchObject({ code: "invalid_params" });
expect(deps.tabsApi.get).not.toHaveBeenCalled();
expect(deps.sendToTab).not.toHaveBeenCalled();
});
it("rejects patterns whose total compiled program is too large", async () => {
const deps = baseDeps();
const res = await handleRequestHelp(
fakeManager("abcd", 99, 5),
baseParams({
completion_criteria: {
all: ["[ab]", "[ac]", "[ad]"].map((part) => ({
url_matches: `(?:${part.repeat(3)}){1000}`,
})),
},
}),
deps,
);
expect(res).toMatchObject({ code: "invalid_params" });
expect(deps.activateTab).not.toHaveBeenCalled();
});
it("charges repeated URL patterns once per condition", async () => {
const deps = baseDeps();
const pattern = "a.{1000}.{1000}.{1000}.{996}[#%]";
const single = await handleRequestHelp(
fakeManager("abcd", 99, 5),
baseParams({ completion_criteria: { all: [{ url_matches: pattern }] } }),
deps,
);
expect(single).toMatchObject({ outcome: "continued" });
const res = await handleRequestHelp(
fakeManager("abcd", 99, 5),
baseParams({
completion_criteria: {
all: Array.from({ length: 8 }, () => ({ url_matches: pattern })),
},
}),
deps,
);
expect(res).toMatchObject({ code: "invalid_params" });
expect(deps.activateTab).toHaveBeenCalledTimes(1);
});
it.each([
{ label: "null", urlMatches: null },
{ label: "empty", urlMatches: "" },
{ label: "omitted", urlMatches: undefined },
])("treats $label URL regex as absent", async ({ urlMatches }) => {
const deps = baseDeps({
sendToTab: vi.fn(async () => ({ type: "bsk-help-ack", ok: true })),
tabsApi: {
get: vi.fn(async () => ({
id: 5,
windowId: 99,
active: true,
url: "https://app.example/done",
})) as never,
query: vi.fn(async () => [{ id: 5, windowId: 99, active: true }] as never),
},
});
const res = await handleRequestHelp(
fakeManager("abcd", 99, 5),
baseParams({
completion_criteria: {
any: [{ url_matches: urlMatches, url_contains: "/done" }],
stable_for_ms: 0,
},
}),
deps,
);
expect(res).toMatchObject({ outcome: "completed" });
});
it.each([
{ label: "unanchored substring", pattern: "/done" },
{ label: "start and end anchors", pattern: "^https://app\\.example/done$" },
])("matches a normal URL with $label", async ({ pattern }) => {
const deps = baseDeps({
sendToTab: vi.fn(async () => ({ type: "bsk-help-ack", ok: true })),
tabsApi: {
get: vi.fn(async () => ({
id: 5,
windowId: 99,
active: true,
url: "https://app.example/done",
})) as never,
query: vi.fn(async () => [{ id: 5, windowId: 99, active: true }] as never),
},
});
const res = await handleRequestHelp(
fakeManager("abcd", 99, 5),
baseParams({ completion_criteria: { any: [{ url_matches: pattern }], stable_for_ms: 0 } }),
deps,
);
expect(res).toMatchObject({ outcome: "completed" });
});
it("does not match an anchored regex against a different URL", async () => {
const abort = new AbortController();
const tabsApi = {
get: vi.fn(async () => ({
id: 5,
windowId: 99,
active: true,
url: "https://app.example/done/next",
})) as never,
query: vi.fn(async () => [{ id: 5, windowId: 99, active: true }] as never),
};
const deps = baseDeps({
signal: abort.signal,
tabsApi,
sendToTab: vi.fn(async () => ({ type: "bsk-help-ack", ok: true })),
});
const pending = handleRequestHelp(
fakeManager("abcd", 99, 5),
baseParams({ completion_criteria: { any: [{ url_matches: "/done$" }], stable_for_ms: 0 } }),
deps,
);
await vi.waitFor(() => expect(tabsApi.get).toHaveBeenCalledTimes(2));
abort.abort();
await expect(pending).resolves.toMatchObject({ code: "cancelled" });
});
it.each([
{ label: "url_contains", condition: { url_contains: "/done" } },
{ label: "url_matches", condition: { url_matches: "/done" } },
])("keeps waiting for manual control when a URL exceeds the $label limit", async ({
condition,
}) => {
const abort = new AbortController();
const tabsApi = {
get: vi.fn(async () => ({
id: 5,
windowId: 99,
active: true,
url: `https://app.example/done?${"x".repeat(8_192)}`,
})) as never,
query: vi.fn(async () => [{ id: 5, windowId: 99, active: true }] as never),
};
const deps = baseDeps({
signal: abort.signal,
tabsApi,
sendToTab: vi.fn(async () => ({ type: "bsk-help-ack", ok: true })),
});
const pending = handleRequestHelp(
fakeManager("abcd", 99, 5),
baseParams({ completion_criteria: { any: [condition], stable_for_ms: 0 } }),
deps,
);
await vi.waitFor(() => expect(tabsApi.get).toHaveBeenCalledTimes(2));
abort.abort();
await expect(pending).resolves.toMatchObject({ code: "cancelled" });
});
it("handles cancellation after later URL completion polls", async () => {
vi.useFakeTimers();
const abort = new AbortController();
const get = vi.fn(async () => ({
id: 5,
windowId: 99,
active: true,
url: "https://app.example/wait",
}));
const tabsApi = {
get: get as never,
query: vi.fn(async () => [{ id: 5, windowId: 99, active: true }] as never),
};
const deps = baseDeps({
signal: abort.signal,
tabsApi,
sendToTab: vi.fn(async () => ({ type: "bsk-help-ack", ok: true })),
});
try {
const pending = handleRequestHelp(
fakeManager("abcd", 99, 5),
baseParams({ completion_criteria: { any: [{ url_matches: "/done$" }] } }),
deps,
);
await vi.advanceTimersByTimeAsync(1_100);
expect(get.mock.calls.length).toBeGreaterThanOrEqual(3);
abort.abort();
await expect(pending).resolves.toMatchObject({ code: "cancelled" });
} finally {
vi.useRealTimers();
}
});
it("does not overlap completion checks while an earlier check is pending", async () => {
vi.useFakeTimers();
const abort = new AbortController();
let releaseCheck!: (value: { result: { value: boolean } }) => void;
const evaluation = new Promise<{ result: { value: boolean } }>((resolve) => {
releaseCheck = resolve;
});
const send = vi.fn((_tabId: number, method: string) =>
method === "Runtime.evaluate" ? evaluation : Promise.resolve({}),
);
const deps = baseDeps({
signal: abort.signal,
cdp: { send } as unknown as RequestHelpDeps["cdp"],
sendToTab: vi.fn(async () => ({ type: "bsk-help-ack", ok: true })),
});
try {
const pending = handleRequestHelp(
fakeManager("abcd", 99, 5),
baseParams({ completion_criteria: { any: [{ selector_exists: "#done" }] } }),
deps,
);
await vi.advanceTimersByTimeAsync(0);
expect(send.mock.calls.filter(([, method]) => method === "Runtime.evaluate")).toHaveLength(1);
await vi.advanceTimersByTimeAsync(1_500);
expect(send.mock.calls.filter(([, method]) => method === "Runtime.evaluate")).toHaveLength(1);
abort.abort();
releaseCheck({ result: { value: true } });
await expect(pending).resolves.toMatchObject({ code: "cancelled" });
} finally {
vi.useRealTimers();
}
});
it("keeps user control active on new tabs without moving completion off the primary tab", async () => {
vi.useFakeTimers();
const chromeEvents = installHelpLifecycleChrome();
+80 -19
View File
@@ -1,5 +1,6 @@
// `tool.request_help` — pause automation and let the human control a tab.
import { RE2JS } from "re2js";
import {
HELP_ACK,
HELP_CANCEL,
@@ -48,6 +49,11 @@ const HELP_REARM_RETRY_DELAY_MS = 400;
const HELP_CLEANUP_TIMEOUT_MS = 1_000;
const DEFAULT_COMPLETION_STABLE_MS = 1_000;
const COMPLETION_POLL_MS = 500;
const MAX_COMPLETION_CONDITIONS = 8;
const MAX_URL_REGEX_LENGTH = 128;
const MAX_URL_REGEX_PROGRAM_SIZE = 4_096;
const MAX_TOTAL_URL_REGEX_PROGRAM_SIZE = 8_192;
const MAX_COMPLETION_URL_LENGTH = 8_192;
export interface RequestHelpNotifications {
create(id: string, options: chrome.notifications.NotificationOptions<true>): Promise<string>;
@@ -83,6 +89,7 @@ interface ActiveHelpRequest {
notificationId: string;
resolvedTargets?: ResolvedTarget[];
completionCriteria?: HelpCompletionCriteria;
urlMatchers: Map<string, RE2JS>;
deps: RequestHelpDeps;
settled: boolean;
unsubscribePreferences?: () => void;
@@ -597,13 +604,10 @@ async function evaluateCompletionCondition(
} catch {
return false;
}
if (url.length > MAX_COMPLETION_URL_LENGTH) return false;
if (condition.url_contains && !url.includes(condition.url_contains)) return false;
if (condition.url_matches) {
try {
if (!new RegExp(condition.url_matches).test(url)) return false;
} catch {
return false;
}
if (!help.urlMatchers.get(condition.url_matches)?.test(url)) return false;
}
}
@@ -667,22 +671,30 @@ async function completionCriteriaMatches(help: ActiveHelpRequest): Promise<boole
function startCompletionPolling(help: ActiveHelpRequest): void {
if (!help.completionCriteria) return;
const stableMs = help.completionCriteria.stable_for_ms ?? DEFAULT_COMPLETION_STABLE_MS;
// A check can await tab or CDP work longer than the polling interval.
let checking = false;
const check = async () => {
if (help.settled) return;
const matched = await completionCriteriaMatches(help);
const now = Date.now();
if (!matched) {
help.completionMatchedSince = null;
return;
if (help.settled || checking) return;
checking = true;
try {
const matched = await completionCriteriaMatches(help);
if (help.settled) return;
const now = Date.now();
if (!matched) {
help.completionMatchedSince = null;
return;
}
help.completionMatchedSince ??= now;
if (now - help.completionMatchedSince < stableMs) return;
void finishHelp(help, {
outcome: "completed",
completed_by: "system",
tab_id: help.primaryTabId,
resolved_targets: help.resolvedTargets,
});
} finally {
checking = false;
}
help.completionMatchedSince ??= now;
if (now - help.completionMatchedSince < stableMs) return;
void finishHelp(help, {
outcome: "completed",
completed_by: "system",
tab_id: help.primaryTabId,
resolved_targets: help.resolvedTargets,
});
};
help.completionTimer = setInterval(() => void check(), COMPLETION_POLL_MS);
void check();
@@ -715,6 +727,54 @@ export async function handleRequestHelp(
});
if (helpDisabled()) return disabledResult(params.tab_id ?? 0);
if (deps.signal?.aborted) return { code: "cancelled", message: "request_help aborted" };
const all = params.completion_criteria?.all ?? [];
const any = params.completion_criteria?.any ?? [];
if (
!Array.isArray(all) ||
!Array.isArray(any) ||
all.length + any.length > MAX_COMPLETION_CONDITIONS
) {
return { code: "invalid_params", message: "completion_criteria supports at most 8 conditions" };
}
const conditions = [...all, ...any];
for (const condition of conditions) {
if (!condition || typeof condition !== "object" || Array.isArray(condition)) {
return {
code: "invalid_params",
message: "completion_criteria contains an invalid condition",
};
}
const pattern = condition.url_matches;
if (pattern == null || pattern === "") continue;
if (typeof pattern !== "string" || pattern.length > MAX_URL_REGEX_LENGTH) {
return {
code: "invalid_params",
message: "url_matches must be a string of at most 128 characters",
};
}
}
const urlMatchers = new Map<string, RE2JS>();
let totalProgramSize = 0;
for (const condition of conditions) {
const pattern = condition.url_matches;
if (!pattern) continue;
try {
// Compilation is shared, but each condition runs a match on every poll.
const matcher = urlMatchers.get(pattern) ?? RE2JS.compile(pattern);
const size = matcher.programSize();
if (
size > MAX_URL_REGEX_PROGRAM_SIZE ||
totalProgramSize + size > MAX_TOTAL_URL_REGEX_PROGRAM_SIZE
) {
return { code: "invalid_params", message: "url_matches is too complex" };
}
totalProgramSize += size;
urlMatchers.set(pattern, matcher);
} catch {
return { code: "invalid_params", message: "url_matches uses unsupported regex syntax" };
}
}
if (deps.signal?.aborted) return { code: "cancelled", message: "request_help aborted" };
const target = await resolveTargetTab(manager, ctx, params.tab_id, deps.tabsApi);
if (isRpcError(target)) return target;
const denied = enforceAgentWindow(ctx, target, "request_help");
@@ -765,6 +825,7 @@ export async function handleRequestHelp(
notificationId,
resolvedTargets,
completionCriteria: params.completion_criteria,
urlMatchers,
deps,
settled: false,
resolve,
+1 -1
View File
@@ -787,7 +787,7 @@ export type HelpOutcome =
export interface HelpCompletionCondition {
url_contains?: string;
url_matches?: string;
url_matches?: string | null;
selector_exists?: string;
selector_missing?: string;
text_exists?: string;
@@ -8,7 +8,21 @@ bsk request-help --session <id> --prompt "Please complete sign-in" --target @e3
```
Use a precise prompt and fresh targets; omit `--target` when no control fits.
Use completion criteria only for a clear, stable success signal.
Use completion criteria only for a clear, stable success signal. `all` and `any`
support at most eight conditions in total. For URL completion, `url_matches`
accepts RE2-compatible regular expressions of at most 128 characters. A compiled
pattern may contain at most 4096 instructions, and all URL-regex conditions together
at most 8192, counting repeated patterns each time. Invalid or over-budget patterns
return `invalid_params` before help starts. URL conditions do not auto-complete on URLs
longer than 8192 characters;
the user can still finish the help request manually. Empty, null, and omitted
`url_matches` values impose no URL-regex condition.
RE2 rejects JavaScript patterns such as `\u0061`, `a{1001}`, `[^]`, lookahead,
lookbehind, and backreferences. Some accepted patterns differ in meaning: `\a`
is a control character in RE2 rather than `a`, and RE2's `\s` does not include
Unicode whitespace. Rewrite patterns in RE2 syntax; use `url_contains` when a
substring is enough.
| Result | Next step |
| --- | --- |
@@ -95,10 +95,12 @@
]
},
"url_matches": {
"description": "RE2-compatible URL regex (maximum 128 characters). Backtracking-only syntax is rejected.",
"type": [
"string",
"null"
]
],
"maxLength": 128
}
}
},
@@ -112,7 +114,8 @@
],
"items": {
"$ref": "#/definitions/HelpCompletionCondition"
}
},
"maxItems": 8
},
"any": {
"type": [
@@ -121,7 +124,8 @@
],
"items": {
"$ref": "#/definitions/HelpCompletionCondition"
}
},
"maxItems": 8
},
"stable_for_ms": {
"type": [
@@ -57,8 +57,10 @@ pub struct RequestHelpParams {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct HelpCompletionCriteria {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(length(max = 8))]
pub any: Option<Vec<HelpCompletionCondition>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(length(max = 8))]
pub all: Option<Vec<HelpCompletionCondition>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(range(min = 0))]
@@ -70,6 +72,8 @@ pub struct HelpCompletionCondition {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url_contains: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(length(max = 128))]
/// RE2-compatible URL regex (maximum 128 characters). Backtracking-only syntax is rejected.
pub url_matches: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub selector_exists: Option<String>,
@@ -3,6 +3,20 @@
With help enabled, use `browser_assist` action `request-help` for login, CAPTCHA,
OTP, payment confirmation, consent, or after two attempts without progress. Supply
a precise prompt and fresh targets; completion criteria need a stable success signal.
`all` and `any` support at most eight conditions in total. `urlMatches` accepts
RE2-compatible regular expressions of at most 128 characters. A compiled pattern
may contain at most 4096 instructions, and all URL-regex conditions together at most
8192, counting repeated patterns each time. Invalid or over-budget patterns return
`invalid_params` before help starts.
URL conditions do not auto-complete on URLs longer than 8192 characters; the user
can still finish manually. The protocol treats empty, null, and omitted
`url_matches` values as no URL-regex condition.
RE2 rejects JavaScript patterns such as `\u0061`, `a{1001}`, `[^]`, lookahead,
lookbehind, and backreferences. Some accepted patterns differ in meaning: `\a`
is a control character in RE2 rather than `a`, and RE2's `\s` does not include
Unicode whitespace. Rewrite patterns in RE2 syntax; use `urlContains` when a
substring is enough.
Resume only on `continued` / `completed`, then observe. Cancellation/timeout blocks
the step; do not repeat the request. Navigation alone is not success.
@@ -300,7 +300,8 @@ const BROWSER_TOOL_SPECS: BrowserToolSpec[] = [
completionCriteria: {
type: "object",
additionalProperties: false,
description: "Automatic completion detector for request-help.",
description:
"Automatic completion detector for request-help (at most 8 conditions across any and all).",
properties: {
any: {
type: "array",
@@ -309,7 +310,11 @@ const BROWSER_TOOL_SPECS: BrowserToolSpec[] = [
additionalProperties: false,
properties: {
urlContains: { type: "string" },
urlMatches: { type: "string" },
urlMatches: {
type: "string",
description:
"RE2-compatible URL regex (max 128 characters; compiled program max 4096 instructions).",
},
selectorExists: { type: "string" },
selectorMissing: { type: "string" },
textExists: { type: "string" },
@@ -324,7 +329,11 @@ const BROWSER_TOOL_SPECS: BrowserToolSpec[] = [
additionalProperties: false,
properties: {
urlContains: { type: "string" },
urlMatches: { type: "string" },
urlMatches: {
type: "string",
description:
"RE2-compatible URL regex (max 128 characters; compiled program max 4096 instructions).",
},
selectorExists: { type: "string" },
selectorMissing: { type: "string" },
textExists: { type: "string" },
@@ -65,7 +65,11 @@ const HELP_CONDITION_SCHEMA = {
additionalProperties: false,
properties: {
urlContains: { type: "string" },
urlMatches: { type: "string" },
urlMatches: {
type: "string",
description:
"RE2-compatible URL regex (max 128 characters; compiled program max 4096 instructions).",
},
selectorExists: { type: "string" },
selectorMissing: { type: "string" },
textExists: { type: "string" },
@@ -168,7 +172,8 @@ export function registerPhaseOneSupportTools(
completionCriteria: {
type: "object",
additionalProperties: false,
description: "Optional explicit success detector for automatic completion.",
description:
"Optional automatic completion detector (at most 8 conditions across any and all).",
properties: {
any: { type: "array", items: HELP_CONDITION_SCHEMA },
all: { type: "array", items: HELP_CONDITION_SCHEMA },
+9
View File
@@ -34,6 +34,9 @@ importers:
'@remixicon/react':
specifier: ^4.9.0
version: 4.9.0(react@19.2.6)
re2js:
specifier: 2.8.6
version: 2.8.6
react:
specifier: ^19.0.0
version: 19.2.6
@@ -3446,6 +3449,10 @@ packages:
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
hasBin: true
re2js@2.8.6:
resolution: {integrity: sha512-xLgQil4kIUCrAzVk9fRSkxkFNwmygLFjVxXrLc65aE1F0+Zsb8rxumFBy4XKyvgMCTL6kilDq3EZ0piE2dP/Dg==}
engines: {node: '>=18.0.0'}
react-dom@18.3.1:
resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==}
peerDependencies:
@@ -7178,6 +7185,8 @@ snapshots:
minimist: 1.2.8
strip-json-comments: 2.0.1
re2js@2.8.6: {}
react-dom@18.3.1(react@18.3.1):
dependencies:
loose-envify: 1.4.0