@trusty-squire/mcp 0.5.9 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +128 -68
  2. package/dist/api-client.d.ts +1 -0
  3. package/dist/api-client.d.ts.map +1 -1
  4. package/dist/api-client.js +27 -0
  5. package/dist/api-client.js.map +1 -1
  6. package/dist/bot/agent.d.ts +8 -0
  7. package/dist/bot/agent.d.ts.map +1 -1
  8. package/dist/bot/agent.js +492 -57
  9. package/dist/bot/agent.js.map +1 -1
  10. package/dist/bot/browser.d.ts +11 -1
  11. package/dist/bot/browser.d.ts.map +1 -1
  12. package/dist/bot/browser.js +365 -20
  13. package/dist/bot/browser.js.map +1 -1
  14. package/dist/bot/debug.d.ts.map +1 -1
  15. package/dist/bot/debug.js +19 -8
  16. package/dist/bot/debug.js.map +1 -1
  17. package/dist/bot/google-login.d.ts +4 -0
  18. package/dist/bot/google-login.d.ts.map +1 -1
  19. package/dist/bot/google-login.js +86 -7
  20. package/dist/bot/google-login.js.map +1 -1
  21. package/dist/bot/index.d.ts +3 -0
  22. package/dist/bot/index.d.ts.map +1 -1
  23. package/dist/bot/index.js +3 -0
  24. package/dist/bot/index.js.map +1 -1
  25. package/dist/bot/xvfb.d.ts +10 -0
  26. package/dist/bot/xvfb.d.ts.map +1 -0
  27. package/dist/bot/xvfb.js +75 -0
  28. package/dist/bot/xvfb.js.map +1 -0
  29. package/dist/install/agents.d.ts.map +1 -1
  30. package/dist/install/agents.js +37 -4
  31. package/dist/install/agents.js.map +1 -1
  32. package/dist/install/cli.d.ts +1 -0
  33. package/dist/install/cli.d.ts.map +1 -1
  34. package/dist/install/cli.js +148 -33
  35. package/dist/install/cli.js.map +1 -1
  36. package/dist/tools/provision-any.d.ts +23 -0
  37. package/dist/tools/provision-any.d.ts.map +1 -1
  38. package/dist/tools/provision-any.js +135 -9
  39. package/dist/tools/provision-any.js.map +1 -1
  40. package/package.json +1 -1
@@ -25,6 +25,7 @@ import { chromium as baseChromium } from "playwright";
25
25
  import { createRequire } from "node:module";
26
26
  import { detectAsn } from "./asn.js";
27
27
  import { CHROME_PROFILE_DIR } from "./profile.js";
28
+ import { startXvfb, xvfbAvailable } from "./xvfb.js";
28
29
  // Lazy registration: installing the plugin mutates the chromium singleton
29
30
  // from playwright-extra so we only do it once per process. We require()
30
31
  // the CJS modules lazily (the stealth toolchain only ships CJS) and treat
@@ -170,6 +171,19 @@ export class BrowserController {
170
171
  // parked here so settleAfterOAuth() can switch back to it once the
171
172
  // Google handshake completes.
172
173
  oauthProductPage = null;
174
+ // F13 — on-demand Xvfb. Set when start() determined the host has no
175
+ // display surface but Xvfb is available, so Chrome can run with
176
+ // `headless: false` against a virtual display (Cloudflare/Stytch et
177
+ // al. detect Chromium-headless and block their signup forms). Torn
178
+ // down by close().
179
+ xvfb = null;
180
+ // F13 — which launch path start() took. Surfaced via .launchMode so
181
+ // the agent can push it into the run's step trail and we can see
182
+ // (from outside the box) whether the bot ran headed.
183
+ launchedMode = "unknown";
184
+ get launchMode() {
185
+ return this.launchedMode;
186
+ }
173
187
  constructor(opts = {}) {
174
188
  this.humanize = opts.humanize ?? true;
175
189
  this.profileDir = opts.profileDir ?? CHROME_PROFILE_DIR;
@@ -214,13 +228,71 @@ export class BrowserController {
214
228
  ? ` loc=${geo.geolocation.latitude},${geo.geolocation.longitude}`
215
229
  : ""));
216
230
  }
231
+ // F13 — decide whether to spin up Xvfb and run Chrome headed.
232
+ // Modern SaaS signups (Cloudflare/Stytch, Clerk, Auth0) detect
233
+ // Chromium-headless via JS fingerprints and gate their forms
234
+ // behind the check. Running headed against Xvfb defeats the gate
235
+ // — the user never sees the display.
236
+ //
237
+ // The decision matrix:
238
+ // - UNIVERSAL_BOT_HEADLESS=true (explicit opt-in): keep true
239
+ // headless. CI / Codespaces that lack Xvfb.
240
+ // - UNIVERSAL_BOT_HEADLESS=false (explicit opt-out): the
241
+ // current pre-F13 behavior — DISPLAY must exist already.
242
+ // - default + DISPLAY set: run headed against the existing
243
+ // display (laptop/desktop install).
244
+ // - default + no DISPLAY + Xvfb on PATH: spawn Xvfb, run
245
+ // headed against it (the headless-server install — what
246
+ // Cloudflare needed).
247
+ // - default + no DISPLAY + no Xvfb: fall back to true
248
+ // headless with a clear stderr warning.
249
+ let chromeEnv;
250
+ let chromeHeadless;
251
+ const explicitHeadless = process.env.UNIVERSAL_BOT_HEADLESS;
252
+ const hostHasDisplay = process.platform === "darwin" ||
253
+ process.platform === "win32" ||
254
+ (typeof process.env.DISPLAY === "string" && process.env.DISPLAY.length > 0);
255
+ if (explicitHeadless === "true") {
256
+ chromeHeadless = true;
257
+ this.launchedMode = "headless";
258
+ }
259
+ else if (explicitHeadless === "false") {
260
+ chromeHeadless = false;
261
+ this.launchedMode = "display";
262
+ }
263
+ else if (hostHasDisplay) {
264
+ chromeHeadless = false;
265
+ this.launchedMode = "display";
266
+ }
267
+ else if (xvfbAvailable()) {
268
+ try {
269
+ this.xvfb = await startXvfb({ width: 1280, height: 720 });
270
+ chromeEnv = { ...process.env, DISPLAY: this.xvfb.display };
271
+ chromeHeadless = false;
272
+ this.launchedMode = "xvfb";
273
+ console.error(`[universal-bot] no DISPLAY — spawned Xvfb at ${this.xvfb.display} for headed Chrome`);
274
+ }
275
+ catch (err) {
276
+ console.error(`[universal-bot] Xvfb failed (${err instanceof Error ? err.message : String(err)}) — ` +
277
+ `falling back to true headless; Cloudflare/Stytch-class signups may fail`);
278
+ chromeHeadless = true;
279
+ this.launchedMode = "headless";
280
+ }
281
+ }
282
+ else {
283
+ console.error(`[universal-bot] no DISPLAY and Xvfb not installed — running true headless. ` +
284
+ `For Cloudflare/Stytch-class signups install xvfb: apt-get install -y xvfb`);
285
+ chromeHeadless = true;
286
+ this.launchedMode = "headless";
287
+ }
217
288
  // T3: a PERSISTENT context. The profile dir carries the user's
218
289
  // Google session (established by `mcp login` — see google-login.ts),
219
290
  // so the OAuth-first signup path reuses it instead of starting
220
291
  // logged-out. launchPersistentContext takes launch + context
221
292
  // options in one call.
222
293
  const context = await getChromium().launchPersistentContext(this.profileDir, {
223
- headless: process.env.UNIVERSAL_BOT_HEADLESS !== "false",
294
+ headless: chromeHeadless,
295
+ ...(chromeEnv !== undefined ? { env: chromeEnv } : {}),
224
296
  // `channel:` selects a real installed browser over the bundled
225
297
  // binary; omitted entirely when null.
226
298
  ...(channel !== null ? { channel } : {}),
@@ -241,9 +313,20 @@ export class BrowserController {
241
313
  // timezone + geolocation track the real egress (T3.1); a fixed
242
314
  // default when the probe failed.
243
315
  timezoneId: geo?.timezoneId ?? "America/New_York",
244
- ...(geo?.geolocation !== undefined
245
- ? { geolocation: geo.geolocation, permissions: ["geolocation"] }
246
- : {}),
316
+ // F10: `clipboard-read` is what makes `navigator.clipboard.readText()`
317
+ // return the user's just-clicked Copy-button value, which is how
318
+ // every modern API-key modal (OpenRouter, Anthropic, OpenAI,
319
+ // Stripe) reveals the full secret — the visible display is
320
+ // masked / truncated and only the clipboard has the whole key.
321
+ // `clipboard-write` is a freebie; some Copy buttons no-op without
322
+ // it. Granting both at context-creation time so we don't have to
323
+ // re-grant on every nav.
324
+ permissions: [
325
+ ...(geo?.geolocation !== undefined ? ["geolocation"] : []),
326
+ "clipboard-read",
327
+ "clipboard-write",
328
+ ],
329
+ ...(geo?.geolocation !== undefined ? { geolocation: geo.geolocation } : {}),
247
330
  });
248
331
  this.context = context;
249
332
  // Patch the navigator.webdriver flag — most anti-bot heuristics look here.
@@ -578,25 +661,149 @@ export class BrowserController {
578
661
  await this.page.check(selector, { force: true });
579
662
  }
580
663
  }
581
- // Pick a valid option for a <select> (F3 T6). The bot must not call
582
- // type() on a <select> (Sentry: "Element is not an <input>").
583
- // Signup <select>s are country / region / role pickers — any
584
- // non-placeholder option satisfies the form. Throws (caught by the
585
- // executor) when the select has no selectable option.
586
- async selectOption(selector) {
664
+ // Pick a valid option for either a native <select> OR a custom
665
+ // ARIA combobox (Radix, Headless UI, React Aria, cmdk F11). The
666
+ // bot must not call type() on a select-shaped element (Sentry,
667
+ // legacy form path: "Element is not an <input>"); modern dashboards
668
+ // increasingly render permission / role / region pickers as
669
+ // <button role="combobox"> that open a <ul role="listbox"> with
670
+ // <li role="option"> children, so Playwright's selectOption fails
671
+ // with "no selectable option" on them.
672
+ //
673
+ // Dispatch: read the element's tag. <select> → native path
674
+ // (existing behavior, picks the first non-placeholder option).
675
+ // Anything else → combobox path (click to open, find role=option,
676
+ // click the chosen one).
677
+ //
678
+ // `optionMatcher` is the planner-supplied text of the option to
679
+ // pick (e.g. "Project: Read"). Case-insensitive substring match
680
+ // against the option's visible text. When undefined, picks the
681
+ // first option — preserves the existing behavior for native
682
+ // selects whose contents are interchangeable (country pickers).
683
+ async selectOption(selector, optionMatcher) {
587
684
  if (!this.page)
588
685
  throw new Error("Browser not started");
589
686
  await this.page.waitForSelector(selector, { state: "attached", timeout: 10000 });
590
- const values = await this.page
591
- .locator(`${selector} option`)
592
- .evaluateAll((opts) => opts
593
- .map((o) => (o instanceof HTMLOptionElement ? o.value : ""))
594
- .filter((v) => v.length > 0));
595
- const first = values[0];
596
- if (first === undefined) {
597
- throw new Error(`<select> ${selector} has no selectable option`);
598
- }
599
- await this.page.selectOption(selector, first);
687
+ const tagName = await this.page
688
+ .locator(selector)
689
+ .first()
690
+ .evaluate((node) => node.tagName.toLowerCase());
691
+ if (tagName === "select") {
692
+ // Native path — unchanged.
693
+ const values = await this.page
694
+ .locator(`${selector} option`)
695
+ .evaluateAll((opts) => opts
696
+ .map((o) => (o instanceof HTMLOptionElement ? o.value : ""))
697
+ .filter((v) => v.length > 0));
698
+ const first = values[0];
699
+ if (first === undefined) {
700
+ throw new Error(`<select> ${selector} has no selectable option`);
701
+ }
702
+ // When the planner specified an option, prefer the one whose
703
+ // visible text matches it; fall back to first.
704
+ let chosenValue = first;
705
+ if (optionMatcher !== undefined) {
706
+ const matcherLower = optionMatcher.toLowerCase();
707
+ const matched = await this.page
708
+ .locator(`${selector} option`)
709
+ .evaluateAll((opts, needle) => opts
710
+ .filter((o) => o instanceof HTMLOptionElement)
711
+ .find((o) => o.textContent?.toLowerCase().includes(needle))
712
+ ?.value ?? null, matcherLower);
713
+ if (typeof matched === "string" && matched.length > 0) {
714
+ chosenValue = matched;
715
+ }
716
+ }
717
+ await this.page.selectOption(selector, chosenValue);
718
+ return;
719
+ }
720
+ // Custom combobox path. Sentry, Radix, Headless UI, React Aria
721
+ // — every modern React picker emits role=option on its items.
722
+ await this.selectFromCombobox(selector, optionMatcher);
723
+ }
724
+ // F11 (+rc.7 hardening): click a combobox trigger, wait for the
725
+ // listbox to open, click an option.
726
+ //
727
+ // Tries option-selector patterns in priority order — each tier
728
+ // targets one combobox-library convention. The text-based final
729
+ // tier catches libraries that ship NO ARIA roles at all (Sentry's
730
+ // permissions picker is the canonical case: it uses `<div>`s with
731
+ // plain text for options and pure JS click handlers).
732
+ //
733
+ // 1. [role=option] — Radix, Headless UI, React Aria, cmdk
734
+ // 2. [role=menuitem] — ARIA menu pattern (libs that model
735
+ // a dropdown as a menu)
736
+ // 3. [role=listbox] li — listbox container without role
737
+ // attribute on its children
738
+ // 4. text-based (matcher only) — after the trigger click, any newly-
739
+ // visible element whose text matches
740
+ // the planner-supplied label is
741
+ // almost certainly the option. Only
742
+ // enabled when a matcher exists,
743
+ // since "first text on the page"
744
+ // with no matcher would catch
745
+ // unrelated UI text.
746
+ async selectFromCombobox(triggerSelector, optionMatcher) {
747
+ if (!this.page)
748
+ throw new Error("Browser not started");
749
+ await this.humanClick(triggerSelector);
750
+ const patternSelectors = [
751
+ '[role="option"]:visible',
752
+ '[role="menuitem"]:visible',
753
+ '[role="listbox"]:visible li:visible',
754
+ ];
755
+ const triedDescriptors = [];
756
+ for (const sel of patternSelectors) {
757
+ triedDescriptors.push(sel);
758
+ const locator = this.page.locator(sel);
759
+ try {
760
+ await locator.first().waitFor({ state: "visible", timeout: 1500 });
761
+ }
762
+ catch {
763
+ continue;
764
+ }
765
+ const count = await locator.count();
766
+ if (count === 0)
767
+ continue;
768
+ await this.pickComboboxOption(locator, optionMatcher);
769
+ return;
770
+ }
771
+ // ARIA tiers all empty. Text-based fallback, only if the planner
772
+ // told us WHICH option to pick — without a matcher, "first text
773
+ // on the page" would click unrelated UI.
774
+ if (optionMatcher !== undefined) {
775
+ const byText = this.page.getByText(optionMatcher, { exact: false }).first();
776
+ triedDescriptors.push(`text="${optionMatcher}"`);
777
+ try {
778
+ await byText.waitFor({ state: "visible", timeout: 2000 });
779
+ await this.humanClickLocator(byText);
780
+ await this.wait(0.5);
781
+ return;
782
+ }
783
+ catch {
784
+ // not found — fall through to error
785
+ }
786
+ }
787
+ throw new Error(`combobox ${triggerSelector}: no options found after click. ` +
788
+ `Tried: ${triedDescriptors.join(", ")}. ` +
789
+ `The trigger may not have opened a popover, or the popover uses ` +
790
+ `an option pattern this executor doesn't recognize.`);
791
+ }
792
+ // F11: pick an option from a Playwright Locator already-narrowed to
793
+ // candidates. Matcher → filter by hasText (case-insensitive by
794
+ // default in Playwright). No matcher → first.
795
+ async pickComboboxOption(options, matcher) {
796
+ if (matcher !== undefined) {
797
+ const filtered = options.filter({ hasText: matcher });
798
+ const filteredCount = await filtered.count();
799
+ if (filteredCount > 0) {
800
+ await this.humanClickLocator(filtered.first());
801
+ await this.wait(0.5);
802
+ return;
803
+ }
804
+ }
805
+ await this.humanClickLocator(options.first());
806
+ await this.wait(0.5);
600
807
  }
601
808
  // ───────────── humanization internals ─────────────
602
809
  // Click that mimics a real user: locate element, bezier-path the
@@ -874,6 +1081,23 @@ export class BrowserController {
874
1081
  return buffer.toString("base64");
875
1082
  }
876
1083
  async getState() {
1084
+ if (!this.page)
1085
+ throw new Error("Browser not started");
1086
+ // page.content() / page.title() / screenshot() all throw
1087
+ // "Execution context was destroyed" when the page is mid-
1088
+ // navigation — common after an OAuth-button click that kicks off
1089
+ // a 3-5 hop redirect chain (sentry.io → accounts.google.com →
1090
+ // consent → callback → onboarding). Retry once after a short
1091
+ // settle: most navigations finish in <500ms even on slow links.
1092
+ try {
1093
+ return await this.snapshotState();
1094
+ }
1095
+ catch {
1096
+ await this.wait(0.8);
1097
+ return await this.snapshotState();
1098
+ }
1099
+ }
1100
+ async snapshotState() {
877
1101
  if (!this.page)
878
1102
  throw new Error("Browser not started");
879
1103
  return {
@@ -902,6 +1126,46 @@ export class BrowserController {
902
1126
  // immediate children, excluding descendants. A key in a
903
1127
  // <code>/<span>/<div> yields its clean value here even when a
904
1128
  // sibling button shares the same parent.
1129
+ // F10: read the clipboard contents (typically populated by the
1130
+ // user-modal's Copy button — every modern API-key reveal modal puts
1131
+ // the full secret here while displaying a masked stub). Requires
1132
+ // `clipboard-read` permission, granted at context creation. Returns
1133
+ // an empty string if the clipboard is empty; throws on permission
1134
+ // failure (caller catches and falls through to other paths).
1135
+ async readClipboard() {
1136
+ if (!this.page)
1137
+ throw new Error("Browser not started");
1138
+ return await this.page.evaluate(async () => {
1139
+ try {
1140
+ return await navigator.clipboard.readText();
1141
+ }
1142
+ catch {
1143
+ return "";
1144
+ }
1145
+ });
1146
+ }
1147
+ // F10 fallback: ALL <input> / <textarea> values, ignoring
1148
+ // visibility and type filters. extractCredentialCandidates
1149
+ // deliberately skips `type=hidden` / `type=password` / invisible
1150
+ // elements (correct for general candidate scanning), but some
1151
+ // API-key modals stash the full key in a hidden input the masked
1152
+ // display reads from — and that needs to be reachable when the
1153
+ // visible extraction comes back truncated.
1154
+ async extractAllInputValues() {
1155
+ if (!this.page)
1156
+ throw new Error("Browser not started");
1157
+ return await this.page.evaluate(() => {
1158
+ const out = [];
1159
+ document.querySelectorAll("input, textarea").forEach((el) => {
1160
+ if (!(el instanceof HTMLInputElement) && !(el instanceof HTMLTextAreaElement))
1161
+ return;
1162
+ const value = el.value;
1163
+ if (value.trim().length > 0)
1164
+ out.push(value.trim());
1165
+ });
1166
+ return out;
1167
+ });
1168
+ }
905
1169
  async extractCredentialCandidates() {
906
1170
  if (!this.page)
907
1171
  throw new Error("Browser not started");
@@ -956,6 +1220,14 @@ export class BrowserController {
956
1220
  // networkidle never settles on pages with analytics sockets or
957
1221
  // long-poll — not fatal, fall through to the element wait.
958
1222
  }
1223
+ // F13 follow-up — if we landed on a full-page anti-bot interstitial
1224
+ // (Cloudflare "Just a moment..." / Turnstile pre-clear / similar),
1225
+ // wait for it to clear and the real page to render. networkidle
1226
+ // sometimes fires DURING the interstitial because Cloudflare keeps
1227
+ // the connection quiet between the verify-handshake and the
1228
+ // redirect to the real page. Without this, the bot snapshots a
1229
+ // 2-element interstitial inventory and bails.
1230
+ await this.waitForAntiBotInterstitialToClear(timeoutMs);
959
1231
  try {
960
1232
  await this.page.waitForSelector("input, button", {
961
1233
  state: "visible",
@@ -967,6 +1239,72 @@ export class BrowserController {
967
1239
  // anyway; it fails cleanly rather than hanging.
968
1240
  }
969
1241
  }
1242
+ // Cloudflare and similar gateways serve a full-page interstitial
1243
+ // ("Just a moment..." / Turnstile pre-clear) before the real page.
1244
+ // The challenge usually clears within ~5-10s — the bot just needs
1245
+ // to wait. Detected from page text patterns rather than URL: the
1246
+ // URL stays the same; the body replaces.
1247
+ //
1248
+ // Returns when the interstitial is gone, or after `timeoutMs` if it
1249
+ // never cleared. Best-effort: any unexpected error returns early
1250
+ // rather than failing the whole signup.
1251
+ async waitForAntiBotInterstitialToClear(timeoutMs) {
1252
+ if (!this.page)
1253
+ return;
1254
+ let detected = await this.pollUntilInterstitialClears(timeoutMs);
1255
+ if (!detected) {
1256
+ // We either never saw an interstitial, or we saw one and it
1257
+ // cleared on its own. Nothing more to do.
1258
+ return;
1259
+ }
1260
+ // The interstitial outlived the wait. Cloudflare frequently shows
1261
+ // "Verification successful. Wait" but then never fires the JS
1262
+ // redirect — the challenge passed, but the redirect script got
1263
+ // stuck or the cookie set is racing the navigation. A single
1264
+ // reload, now that the cf_clearance cookie is set, often lets the
1265
+ // real page render. (If the issue is a server-side risk-score
1266
+ // block — fingerprint/IP — reload won't help, but the caller's
1267
+ // inventory diagnostic will still surface the block.)
1268
+ try {
1269
+ await this.page.reload({ waitUntil: "networkidle", timeout: 10_000 });
1270
+ }
1271
+ catch {
1272
+ // reload failed — proceed with what's there
1273
+ }
1274
+ await this.pollUntilInterstitialClears(Math.max(5000, timeoutMs / 2));
1275
+ }
1276
+ // One poll loop. Returns true if an interstitial was ever observed
1277
+ // (cleared or still there at timeout), false if never seen.
1278
+ async pollUntilInterstitialClears(timeoutMs) {
1279
+ if (!this.page)
1280
+ return false;
1281
+ const deadline = Date.now() + timeoutMs;
1282
+ let detected = false;
1283
+ while (Date.now() < deadline) {
1284
+ let title = "";
1285
+ let bodyText = "";
1286
+ try {
1287
+ title = await this.page.title();
1288
+ bodyText = await this.page.evaluate(() => (document.body?.innerText ?? "").slice(0, 500));
1289
+ }
1290
+ catch {
1291
+ await new Promise((r) => setTimeout(r, 500));
1292
+ continue;
1293
+ }
1294
+ const onInterstitial = /just a moment|performing security verification|verifying you are human|checking your browser|attention required/i.test(title + " " + bodyText);
1295
+ if (!onInterstitial) {
1296
+ if (detected) {
1297
+ // Give the freshly-revealed page a tick to hydrate before
1298
+ // the inventory scan.
1299
+ await new Promise((r) => setTimeout(r, 800));
1300
+ }
1301
+ return detected;
1302
+ }
1303
+ detected = true;
1304
+ await new Promise((r) => setTimeout(r, 1000));
1305
+ }
1306
+ return detected;
1307
+ }
970
1308
  // Walk the live DOM (piercing open shadow roots) and return every
971
1309
  // visible interactive element with a bot-computed selector (F3 T1).
972
1310
  // The planner picks from this inventory instead of inventing
@@ -1271,6 +1609,13 @@ export class BrowserController {
1271
1609
  // Closing the persistent context shuts the browser down too.
1272
1610
  if (this.context)
1273
1611
  await this.context.close();
1612
+ // F13 — release the on-demand Xvfb if we spawned one. Order
1613
+ // matters: kill Chrome (context.close) first so it has its
1614
+ // display until it exits, THEN kill Xvfb.
1615
+ if (this.xvfb !== null) {
1616
+ this.xvfb.stop();
1617
+ this.xvfb = null;
1618
+ }
1274
1619
  }
1275
1620
  }
1276
1621
  // Random integer in [min, max]. We use Math.random() (not crypto)