@solarisdk/mcp 0.4.3 → 0.4.4

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.
package/dist/browser.js CHANGED
@@ -4,82 +4,21 @@
4
4
  // page-level actions here are driven client-side over the session's raw CDP
5
5
  // endpoint via puppeteer-core. We keep the connected Browser handle for the
6
6
  // session's lifetime — the signed ws URL cannot be re-dialed after 90 minutes.
7
+ //
8
+ // The toolset itself is assembled from the per-capability modules under
9
+ // browser-tools/ (session lifecycle, auth/profiles, navigation, capture,
10
+ // interaction) — this file owns the shared closure state (session registry
11
+ // lookups, active-page resolution, text truncation) and threads it into each
12
+ // module via a BrowserToolCtx, plus the small bit of top-level plumbing
13
+ // (config/registry/deps types, session release) that isn't a "tool" itself.
7
14
  import puppeteer from "puppeteer-core";
8
- import { z } from "zod";
9
- const text = (o) => ({
10
- content: [{ type: "text", text: typeof o === "string" ? o : JSON.stringify(o, null, 2) }],
11
- });
12
- const MAX_PAGE_TEXT = 30_000;
13
- const MAX_LINKS = 200;
14
- const CDP_DIAL_ATTEMPTS = 3;
15
- /** How often await_login re-checks handoff status. */
16
- const HANDOFF_POLL_MS = 2_000;
17
- /** Signed ws/wss capability URLs must never reach the model or a log. */
18
- const redact = (s) => s.replace(/wss?:\/\/[^\s"']+/gi, "[redacted-ws-url]");
19
- async function api(cfg, method, path, body) {
20
- // Session create can block up to 60s gateway-side waiting for a slot.
21
- const res = await fetch(`${cfg.baseUrl}${path}`, {
22
- method,
23
- headers: {
24
- authorization: `Bearer ${cfg.apiKey}`,
25
- ...(body ? { "content-type": "application/json" } : {}),
26
- },
27
- ...(body ? { body: JSON.stringify(body) } : {}),
28
- signal: AbortSignal.timeout(90_000),
29
- });
30
- return res;
31
- }
32
- /**
33
- * Turn a gateway error into a model-safe message: only the documented
34
- * {code,message} fields, never the raw body (which on a 2xx-shaped response
35
- * carries the session's bearer-capability ws URLs).
36
- */
37
- async function apiError(res, what) {
38
- let detail = "";
39
- try {
40
- const raw = await res.text();
41
- try {
42
- const j = JSON.parse(raw);
43
- detail = [j.code, j.error ?? j.message].filter(Boolean).join(": ");
44
- }
45
- catch {
46
- detail = redact(raw).slice(0, 200);
47
- }
48
- }
49
- catch {
50
- /* body unavailable */
51
- }
52
- throw new Error(`${what} failed: HTTP ${res.status}${detail ? ` ${detail}` : ""}`);
53
- }
54
- /** Release a browser session gateway-side. Safe to call for unknown ids. */
55
- export async function releaseBrowserSession(cfg, id, fetchApi = api) {
56
- const api_ = fetchApi;
57
- // Gateway bearer verification can transiently 401 (control-plane verify blip
58
- // / auth-cache churn) and a failed release leaks the pool slot until TTL, so
59
- // retry transient statuses before giving up.
60
- let res = await api_(cfg, "DELETE", `/sessions/${encodeURIComponent(id)}`);
61
- for (let i = 0; i < 2 && (res.status === 401 || res.status === 429 || res.status >= 500); i++) {
62
- await new Promise((r) => setTimeout(r, 1500));
63
- res = await api_(cfg, "DELETE", `/sessions/${encodeURIComponent(id)}`);
64
- }
65
- if (res.status === 404) {
66
- // Bare 404 = already gone (fine). 404 + InvalidSessionId = the gateway
67
- // refused the id and released NOTHING.
68
- let code;
69
- try {
70
- code = (await res.json()).code;
71
- }
72
- catch {
73
- /* no body */
74
- }
75
- if (code === "InvalidSessionId") {
76
- throw new Error(`release failed: gateway rejected sessionId (session may still be live)`);
77
- }
78
- return;
79
- }
80
- if (res.status !== 204 && !res.ok)
81
- await apiError(res, "session release");
82
- }
15
+ import { makeAuthTools } from "./browser-tools/auth.js";
16
+ import { makeCaptureTools } from "./browser-tools/capture.js";
17
+ import { api, MAX_PAGE_TEXT, releaseBrowserSession, } from "./browser-tools/context.js";
18
+ import { makeInteractionTools } from "./browser-tools/interaction.js";
19
+ import { makeNavigationTools } from "./browser-tools/navigation.js";
20
+ import { makeSessionTools } from "./browser-tools/session.js";
21
+ export { releaseBrowserSession } from "./browser-tools/context.js";
83
22
  const defaultDeps = {
84
23
  connect: (cdpEndpoint) => puppeteer.connect({
85
24
  browserWSEndpoint: cdpEndpoint,
@@ -91,7 +30,7 @@ const defaultDeps = {
91
30
  fetchApi: api,
92
31
  };
93
32
  export function makeBrowserToolset(cfg, reg, deps = defaultDeps) {
94
- const api = deps.fetchApi;
33
+ const apiFn = deps.fetchApi;
95
34
  const need = (id) => {
96
35
  const e = reg.sessions.get(id);
97
36
  if (!e)
@@ -130,740 +69,13 @@ export function makeBrowserToolset(cfg, reg, deps = defaultDeps) {
130
69
  const capText = (s, what) => s.length > MAX_PAGE_TEXT
131
70
  ? `${s.slice(0, MAX_PAGE_TEXT)}\n…[truncated ${s.length - MAX_PAGE_TEXT} of ${s.length} ${what}]`
132
71
  : s;
72
+ const ctx = { cfg, reg, deps, api: apiFn, need, activePage, capText };
133
73
  return {
134
- solari_browser_create: {
135
- description: "Start a Solari cloud browser session. Use this whenever you need to browse the web. " +
136
- "Returns a sessionId for the other solari_browser_* tools.\n" +
137
- "mode defaults to 'stealth' — the anti-bot hardened pool. Just call this with no " +
138
- "arguments and you get it; you do NOT need to ask for stealth explicitly. Stealth is " +
139
- "the right default for the open web: ordinary sites work fine on it, and it is what " +
140
- "keeps bot-detection from blocking the session.\n" +
141
- "Pass mode:'fast' ONLY for a site you already know does not fingerprint or block " +
142
- "automation (internal tools, localhost, your own app, plain docs/API pages) and you " +
143
- "want the lower-latency pool. If a page in fast mode returns a block/captcha/challenge, " +
144
- "close the session and retry with mode:'stealth'.\n" +
145
- "captcha auto-solving is ALSO on by default (it follows the pool: on for stealth, off " +
146
- "for fast). Pass captcha:false to turn it off — solving a challenge costs money and " +
147
- "adds latency, so switch it off for sites you know never challenge.\n" +
148
- "proxy ('smart' | country code like 'us') REQUIRES stealth, as does captcha; both are " +
149
- "rejected if you ask for them explicitly in fast mode. recording gives an rrweb replay.",
150
- inputSchema: {
151
- mode: z.enum(["stealth", "fast"]).optional(),
152
- /** @deprecated use mode. Kept so existing callers keep working. */
153
- stealth: z.boolean().optional(),
154
- proxy: z.string().optional(),
155
- /** Defaults to ON (following the pool). Pass false to opt out. */
156
- captcha: z.boolean().optional(),
157
- recording: z.boolean().optional(),
158
- profileId: z.string().optional(),
159
- /** Defaults to ON. Pass false to never auto-sign-in on this session. */
160
- autoLogin: z.boolean().optional(),
161
- },
162
- handler: async (a) => {
163
- // STEALTH IS THE DEFAULT. Models consistently declined to opt in when it
164
- // was an optional boolean, so sessions silently landed on the fast pool
165
- // and got blocked by bot detection — the failure looked like "the site
166
- // is broken" rather than "we picked the wrong pool". Defaulting on, with
167
- // an explicit escape hatch, removes that whole class of confusion.
168
- //
169
- // Precedence: explicit mode wins; then the deprecated stealth boolean
170
- // (so `stealth:false` still means fast for old callers); then stealth.
171
- const stealth = a.mode !== undefined ? a.mode === "stealth" : (a.stealth ?? true);
172
- // CAPTCHA IS ALSO ON BY DEFAULT — same reasoning as stealth: a model
173
- // that has to opt in does not, and the resulting failure ("the page is
174
- // a challenge screen") reads as a broken site rather than a missing
175
- // option.
176
- //
177
- // The default FOLLOWS the pool rather than being a flat `true`, because
178
- // captcha is stealth-only upstream. A flat true would make plain
179
- // mode:'fast' throw on an option the caller never asked for, turning the
180
- // escape hatch into a dead end. So: stealth ⇒ on, fast ⇒ off, and an
181
- // explicit value always wins (captcha:false is the opt-out).
182
- const captcha = a.captcha ?? stealth;
183
- // proxy/captcha are stealth-only upstream. Previously a fast-pool
184
- // session with proxy came back with the proxy silently dropped and only
185
- // a note in the response; fail loudly instead — a silently unproxied
186
- // request can leak the origin IP, which is the one thing the caller was
187
- // trying to avoid. Only an EXPLICIT ask conflicts; the captcha default
188
- // simply turns itself off in fast mode.
189
- if (!stealth) {
190
- const needsStealth = [a.proxy ? "proxy" : "", a.captcha === true ? "captcha" : ""].filter(Boolean);
191
- if (needsStealth.length) {
192
- throw new Error(`${needsStealth.join(" and ")} ${needsStealth.length > 1 ? "require" : "requires"} ` +
193
- `stealth, but mode is 'fast'. ` +
194
- `Drop ${needsStealth.length > 1 ? "them" : "it"} or use mode:'stealth'.`);
195
- }
196
- }
197
- const body = {};
198
- if (stealth)
199
- body.stealth = true;
200
- if (captcha)
201
- body.captcha = true;
202
- if (a.recording)
203
- body.recording = true;
204
- if (a.proxy)
205
- body.proxy = a.proxy;
206
- if (a.profileId)
207
- body.profileId = a.profileId;
208
- // Auto-login is ON by default. If the account has connected a password
209
- // manager and configured this site, the gateway signs the session in on
210
- // its own — the agent never sees or handles the credential. Costs
211
- // nothing when nothing is configured: the gateway finds no connection
212
- // and the flow is exactly as before.
213
- if (a.autoLogin !== false)
214
- body.autoLogin = true;
215
- const res = await api(cfg, "POST", "/sessions", body);
216
- if (res.status !== 201)
217
- await apiError(res, "session create");
218
- const s = (await res.json());
219
- // cdpEndpoint is optional on the wire; derive it from wsEndpoint the
220
- // way the official SDK does.
221
- const cdp = s.cdpEndpoint ?? s.wsEndpoint?.replace("/ws/", "/cdp/");
222
- if (!cdp)
223
- throw new Error("gateway returned no cdpEndpoint or wsEndpoint");
224
- // From here on the session is BILLABLE. Any failure before it is
225
- // registered must release it, or it leaks until TTL with an id the
226
- // model never saw.
227
- try {
228
- let browser;
229
- let lastErr;
230
- for (let i = 0; i < CDP_DIAL_ATTEMPTS; i++) {
231
- try {
232
- browser = await deps.connect(cdp);
233
- break;
234
- }
235
- catch (err) {
236
- // A freshly-started session has a transient upstream window.
237
- lastErr = err;
238
- await new Promise((r) => setTimeout(r, 500 * (i + 1)));
239
- }
240
- }
241
- if (!browser)
242
- throw lastErr ?? new Error("could not connect to the browser");
243
- const pages = await browser.pages();
244
- const page = pages.find((p) => !p.isClosed()) ?? (await browser.newPage());
245
- reg.sessions.set(s.sessionId, {
246
- sessionId: s.sessionId,
247
- browser,
248
- page,
249
- expiresAt: s.expiresAt,
250
- recording: Boolean(a.recording),
251
- cdpEndpoint: cdp,
252
- });
253
- }
254
- catch (err) {
255
- await releaseBrowserSession(cfg, s.sessionId, api).catch(() => { });
256
- throw new Error(`browser session started but could not be attached (released it): ${redact(err instanceof Error ? err.message : String(err))}`);
257
- }
258
- // Proxy silently degrades rather than erroring — surface what we got.
259
- // Echo the resolved mode back: the caller did not necessarily pick it
260
- // (stealth is the default), and knowing which pool it landed on is what
261
- // makes a later block actionable — "retry on stealth" vs "this site
262
- // blocks us even hardened".
263
- return text({
264
- sessionId: s.sessionId,
265
- mode: stealth ? "stealth" : "fast",
266
- captcha,
267
- expiresAt: s.expiresAt,
268
- proxy: s.proxy ?? (a.proxy ? "NOT APPLIED (check plan)" : undefined),
269
- });
270
- },
271
- },
272
- solari_browser_profiles: {
273
- description: "List saved browser profiles for this account. A profile is a stored signed-in state " +
274
- "(cookies + localStorage) that solari_browser_create({profileId}) replays, so the " +
275
- "session starts already logged in.\n" +
276
- "Check here FIRST when a task will need a login: if a profile for that site already " +
277
- "exists, use it and no human is involved at all. `version` is 1 and `populated` is " +
278
- "false for a profile nobody has signed into yet — that one still needs " +
279
- "solari_browser_login({profileName}).",
280
- inputSchema: {},
281
- handler: async () => {
282
- const res = await api(cfg, "GET", "/profiles");
283
- if (res.status !== 200)
284
- await apiError(res, "list profiles");
285
- const rows = (await res.json());
286
- return text({
287
- profiles: rows.map((p) => ({
288
- profileId: p.id,
289
- name: p.name,
290
- version: p.version,
291
- populated: Boolean(p.storageStateS3Key),
292
- lastUsedAt: p.lastUsedAt ?? null,
293
- })),
294
- });
295
- },
296
- },
297
- solari_browser_autologin_status: {
298
- description: "Check whether this account can sign in to websites automatically, and get a direct " +
299
- "setup link if it cannot.\n" +
300
- "Call this when a task will clearly need a login, BEFORE you start — if nothing is set " +
301
- "up you can tell the user once, up front, with a link, instead of interrupting them " +
302
- "mid-run. Also worth calling if a login keeps needing a human.\n" +
303
- "Returns the connected password managers, the sites already configured, and `setupUrl`. " +
304
- "If `configured` is false, SHOW setupUrl TO THE USER: it opens the page where they " +
305
- "connect a password manager. You cannot do that step for them — it needs a credential " +
306
- "that must never pass through you — but the link removes all the guesswork.",
307
- inputSchema: {},
308
- handler: async () => {
309
- const res = await api(cfg, "GET", "/vault/status");
310
- if (res.status === 501) {
311
- return text({
312
- configured: false,
313
- available: false,
314
- note: "Automatic sign-in is not available on this deployment.",
315
- });
316
- }
317
- if (res.status !== 200)
318
- await apiError(res, "auto-login status");
319
- const d = (await res.json());
320
- const managers = d.managers ?? [];
321
- const sites = d.sites ?? [];
322
- return text({
323
- configured: managers.length > 0,
324
- available: true,
325
- managers: managers.map((m) => ({ name: m.name, provider: m.provider, vaults: m.vaults })),
326
- sites,
327
- setupUrl: d.setupUrl ?? null,
328
- next: managers.length === 0
329
- ? "No password manager is connected. Show setupUrl to the user — it takes them " +
330
- "straight to the page where they connect one, after which logins happen with no " +
331
- "human involved."
332
- : sites.length === 0
333
- ? "A password manager is connected but no sites use it yet. Add one with " +
334
- "solari_browser_autologin_site, or the user can do it at setupUrl."
335
- : "Automatic sign-in is set up. Sessions will log in to these sites on their own.",
336
- });
337
- },
338
- },
339
- solari_browser_autologin_site: {
340
- description: "Tell Solari to sign in to a site automatically using a connected password manager.\n" +
341
- "`domain` is the site, e.g. 'github.com'. `manager` is the NAME of a connected password " +
342
- "manager (see solari_browser_autologin_status) — omit it to mean 'remember the session " +
343
- "but ask a human for a fresh login'. `item` is an optional 'Vault/Item' path for when " +
344
- "one site has several logins; omit it to match by site.\n" +
345
- "This only says WHICH credential to use — it never handles the credential itself.",
346
- inputSchema: {
347
- domain: z.string(),
348
- manager: z.string().optional(),
349
- item: z.string().optional(),
350
- },
351
- handler: async (a) => {
352
- const res = await api(cfg, "POST", "/vault/sites", {
353
- domain: a.domain,
354
- ...(typeof a.manager === "string" ? { manager: a.manager } : {}),
355
- ...(typeof a.item === "string" ? { item: a.item } : {}),
356
- });
357
- if (res.status !== 200)
358
- await apiError(res, "configure auto-login site");
359
- const d = (await res.json());
360
- return text({
361
- ...d,
362
- next: "Set. The next session that hits a login wall on this site signs in automatically " +
363
- "if the credential is found; otherwise a human is asked, as before.",
364
- });
365
- },
366
- },
367
- solari_browser_login: {
368
- description: "Get this session signed in. Call it the moment you hit a login form, a 2FA prompt, or "
369
- + "anything asking for a password — you must never handle credentials yourself.\n"
370
- + "It tries the account's connected password manager FIRST: if one is set up for this "
371
- + "site, you are signed in automatically and NO human is involved — the reply says "
372
- + 'signedIn:true and you simply carry on. Otherwise it falls back to asking a human.\n'
373
- + "Two ways to call it:\n" +
374
- "HOT — pass `sessionId` when you are ALREADY on a login wall mid-task. The user gets a " +
375
- "live view of the exact page you are on and types into it; you resume on that same page.\n" +
376
- "COLD — pass `profileName` when you know a task will need a login and no session is open " +
377
- "yet. The user signs in once in a profile editor, and every later " +
378
- "solari_browser_create({profileId}) starts already authenticated. Prefer this when you " +
379
- "can: nobody has to be watching mid-run. The profile is created if it does not exist.\n" +
380
- "Pass exactly one of the two. Either way, SHOW THE RETURNED URL TO THE USER, then call " +
381
- "solari_browser_await_login.\n" +
382
- "In the HOT case your access to that session is REVOKED while the handoff is open — " +
383
- "deliberately, so you cannot observe what they type — and the link expires in 5 minutes. " +
384
- "Cold links last longer and revoke nothing, because there is no session yet.\n" +
385
- "`reason` is required and is shown to the user — say plainly which site is asking and " +
386
- "what for, because they are being asked to type a password on your say-so.",
387
- inputSchema: {
388
- sessionId: z.string().optional(),
389
- profileName: z.string().optional(),
390
- reason: z.string(),
391
- },
392
- handler: async (a) => {
393
- const sessionId = typeof a.sessionId === "string" ? a.sessionId : "";
394
- const profileName = typeof a.profileName === "string" ? a.profileName.trim() : "";
395
- if (Boolean(sessionId) === Boolean(profileName)) {
396
- throw new Error("Pass exactly one of sessionId (rescue a live session) or profileName (seed a " +
397
- "profile before you start).");
398
- }
399
- // ── COLD: seed a profile, no session involved ────────────────────
400
- if (profileName) {
401
- const listRes = await api(cfg, "GET", "/profiles");
402
- if (listRes.status !== 200)
403
- await apiError(listRes, "list profiles");
404
- const rows = (await listRes.json());
405
- const match = rows.find((p) => (p.name ?? "").toLowerCase() === profileName.toLowerCase());
406
- let profileId = match?.id ?? "";
407
- if (!profileId) {
408
- const mk = await api(cfg, "POST", "/profiles", { name: profileName });
409
- if (mk.status !== 200 && mk.status !== 201)
410
- await apiError(mk, "create profile");
411
- profileId = (await mk.json()).id ?? "";
412
- if (!profileId)
413
- throw new Error("profile created but no id returned");
414
- }
415
- const hRes = await api(cfg, "POST", `/profiles/${encodeURIComponent(profileId)}/login-handoff`, {
416
- reason: a.reason,
417
- });
418
- if (hRes.status !== 200)
419
- await apiError(hRes, "cold login request");
420
- const h = (await hRes.json());
421
- return text({
422
- mode: "cold",
423
- profileId,
424
- profileName,
425
- handoffId: h.handoffId,
426
- url: h.url,
427
- expiresAt: h.expiresAt,
428
- // Echoed so await_login can tell a fresh save from the state it
429
- // started in — the version bump IS the completion signal.
430
- sinceVersion: h.version,
431
- next: `Show the url to the user, then call solari_browser_await_login({ profileId: ` +
432
- `"${profileId}", sinceVersion: ${h.version} }).`,
433
- });
434
- }
435
- // ── HOT: rescue the live session ─────────────────────────────────
436
- const e = need(sessionId);
437
- // Try the account's password manager FIRST. If a vault is connected and
438
- // this site is configured, the gateway signs in on its own and no human
439
- // is involved at all — we never see the credential either way. Only
440
- // escalate to a human when that cannot work. Best-effort: any failure
441
- // here just falls through to the handoff below, which is the behaviour
442
- // that existed before.
443
- let setupUrl = null;
444
- try {
445
- const auto = await api(cfg, "POST", `/sessions/${encodeURIComponent(sessionId)}/autologin`, {});
446
- if (auto.status === 200) {
447
- const r = (await auto.json());
448
- if (r.outcome === "already_authenticated" || r.outcome === "vault_login") {
449
- return text({
450
- mode: "auto",
451
- signedIn: true,
452
- via: r.outcome === "vault_login" ? "password manager" : "saved session",
453
- next: "You are signed in — carry on. No human was needed.",
454
- });
455
- }
456
- // The chain could not sign in and ALREADY OPENED a handoff for us.
457
- // Falling through here would POST /handoff for a session that now
458
- // has one open, which the gateway correctly refuses with 409
459
- // HandoffAlreadyOpen -- and the url we were just handed would be
460
- // thrown away, leaving an open handoff that nobody can reach and a
461
- // session frozen until it expires. Use what we were given.
462
- if (r.outcome === "human_required" && r.handoffUrl) {
463
- try {
464
- await e.browser.close();
465
- }
466
- catch {
467
- /* the gateway is severing this socket anyway */
468
- }
469
- return text({
470
- mode: "hot",
471
- handoffId: r.handoffId,
472
- url: r.handoffUrl,
473
- // Why the password manager could not do this unaided. Surfaced
474
- // because otherwise a mis-configured site is indistinguishable
475
- // from an unsupported one.
476
- ...(r.reason ? { autoLoginFailed: r.reason } : {}),
477
- ...(r.setupRequired && r.setupUrl ? { setupUrl: r.setupUrl } : {}),
478
- next: "Show the url to the user, then call solari_browser_await_login. " +
479
- "Your access to this session is revoked until they finish.",
480
- });
481
- }
482
- // Nothing is configured for this site. Remember the setup link so
483
- // it can be offered alongside the handoff below — the user is
484
- // already being interrupted, so this is the cheapest possible
485
- // moment to tell them how to stop being interrupted next time.
486
- if (r.setupRequired && r.setupUrl)
487
- setupUrl = r.setupUrl;
488
- }
489
- }
490
- catch {
491
- /* fall through to the human handoff */
492
- }
493
- const res = await api(cfg, "POST", `/sessions/${encodeURIComponent(sessionId)}/handoff`, { reason: a.reason });
494
- if (res.status !== 200)
495
- await apiError(res, "handoff request");
496
- const h = (await res.json());
497
- // The gateway is severing our socket right now. Drop the local handle
498
- // rather than leave a half-dead Browser object that throws confusing
499
- // Target-closed errors on every subsequent tool call.
500
- try {
501
- await e.browser.close();
502
- }
503
- catch {
504
- /* already gone — that is the point */
505
- }
506
- // Prefer the short link when the gateway offers one — it is far easier
507
- // to relay to someone on a phone. Fall back to the long URL for older
508
- // gateways that do not return shortUrl yet.
509
- const showUrl = h.shortUrl ?? h.url;
510
- return text({
511
- mode: "hot",
512
- handoffId: h.handoffId,
513
- url: showUrl,
514
- fullUrl: h.url,
515
- expiresAt: h.expiresAt,
516
- // Offered only when the account has NOTHING configured for this site.
517
- // The user is already being interrupted, so this is the cheapest
518
- // moment to show them how to stop being interrupted next time.
519
- ...(setupUrl
520
- ? {
521
- setupUrl,
522
- tip: "This account has no password manager connected for this site, so a human is " +
523
- "needed every time. Connecting one at the setupUrl lets future logins happen " +
524
- "automatically. MENTION THIS TO THE USER along with the sign-in link.",
525
- }
526
- : {}),
527
- next: "Show the url to the user, then call solari_browser_await_login.",
528
- });
529
- },
530
- },
531
- solari_browser_save_profile: {
532
- description: "Save this session's signed-in state (cookies + localStorage) into an existing profile, " +
533
- "so future sessions start already logged in and no human is needed again. Pass the " +
534
- "profileId to overwrite.\n" +
535
- "ONLY do this when the user has asked you to remember the login. It is deliberately not " +
536
- "automatic: a saved profile holds live session cookies, which bypass 2FA — it is a " +
537
- "credential store, and it should exist because someone chose it. If they have not said " +
538
- "so, ask first.\n" +
539
- "Returns what was captured (cookie count and origins) so the user can see what they " +
540
- "just persisted.",
541
- inputSchema: {
542
- sessionId: z.string(),
543
- profileId: z.string(),
544
- },
545
- handler: async (a) => {
546
- need(a.sessionId);
547
- const res = await api(cfg, "POST", `/sessions/${encodeURIComponent(a.sessionId)}/save-profile`, { profileId: a.profileId });
548
- if (res.status !== 200)
549
- await apiError(res, "save profile");
550
- const body = (await res.json());
551
- return text({
552
- ...body,
553
- note: "Future sessions can use this with solari_browser_create({profileId}).",
554
- });
555
- },
556
- },
557
- solari_browser_await_login: {
558
- description: "Wait for the human to finish the sign-in you requested with solari_browser_login. " +
559
- "Blocks until they are done, the link expires, or timeoutMs elapses. Returns only a " +
560
- "status — never anything the user typed. On 'completed' the session is yours again, " +
561
- "on the same page, still signed in, and you can carry on where you left off. On " +
562
- "'expired' or 'timeout' the user did not finish: ask if they still want to, and call " +
563
- "solari_browser_login again for a fresh link rather than retrying this.",
564
- inputSchema: {
565
- sessionId: z.string().optional(),
566
- profileId: z.string().optional(),
567
- sinceVersion: z.number().optional(),
568
- handoffId: z.string().optional(),
569
- timeoutMs: z.number().optional(),
570
- },
571
- handler: async (a) => {
572
- const sessionId = typeof a.sessionId === "string" ? a.sessionId : "";
573
- const profileId = typeof a.profileId === "string" ? a.profileId : "";
574
- if (Boolean(sessionId) === Boolean(profileId)) {
575
- throw new Error("Pass exactly one of sessionId (hot handoff) or profileId (cold handoff) — the " +
576
- "same one solari_browser_login returned.");
577
- }
578
- // Clamp: never spin forever, never poll so briefly the human has no
579
- // chance. Defaults to the handoff's own 5-minute life.
580
- const requested = typeof a.timeoutMs === "number" ? a.timeoutMs : 300_000;
581
- const deadline = Date.now() + Math.min(Math.max(requested, 5_000), 600_000);
582
- let status = "pending";
583
- // ── COLD: watch the profile's version, which the editor's save bumps.
584
- // Deliberately NOT "did a storageState appear": re-signing into a
585
- // profile that already had one must also count as completed.
586
- if (profileId) {
587
- const since = typeof a.sinceVersion === "number" ? a.sinceVersion : 0;
588
- let version = since;
589
- while (Date.now() < deadline) {
590
- const res = await api(cfg, "GET", "/profiles");
591
- if (res.status !== 200)
592
- await apiError(res, "profile status");
593
- const rows = (await res.json());
594
- const row = rows.find((p) => p.id === profileId);
595
- if (!row)
596
- throw new Error(`profile ${profileId} no longer exists`);
597
- version = typeof row.version === "number" ? row.version : since;
598
- if (version > since) {
599
- status = "completed";
600
- break;
601
- }
602
- await new Promise((r) => setTimeout(r, HANDOFF_POLL_MS));
603
- }
604
- if (status !== "completed")
605
- status = "timeout";
606
- return text({
607
- mode: "cold",
608
- status,
609
- profileId,
610
- version,
611
- next: status === "completed"
612
- ? `Signed in and saved. Use solari_browser_create({ profileId: "${profileId}" }) ` +
613
- "and you will start already authenticated."
614
- : "The user did not finish. Ask if they still want to, then call " +
615
- "solari_browser_login again for a fresh link.",
616
- });
617
- }
618
- const e = need(sessionId);
619
- while (Date.now() < deadline) {
620
- const q = typeof a.handoffId === "string" && a.handoffId
621
- ? `?handoffId=${encodeURIComponent(a.handoffId)}`
622
- : "";
623
- const res = await api(cfg, "GET", `/sessions/${encodeURIComponent(sessionId)}/handoff${q}`);
624
- if (res.status !== 200)
625
- await apiError(res, "handoff status");
626
- const body = (await res.json());
627
- status = body.status ?? "none";
628
- // "none" means no open handoff and no id to look up — treat as done
629
- // rather than spinning; the caller can re-mint if that was wrong.
630
- if (status !== "pending")
631
- break;
632
- await new Promise((r) => setTimeout(r, HANDOFF_POLL_MS));
633
- }
634
- if (status === "pending")
635
- status = "timeout";
636
- // Reconnect: the handoff severed our old socket on purpose, so the
637
- // stored handle is dead even on success. Re-dial and re-adopt the page
638
- // the human left us on, which is the whole point of a HOT handoff —
639
- // the agent resumes mid-flow instead of starting over.
640
- let resumed = false;
641
- if (status === "completed" || status === "none") {
642
- try {
643
- const browser = await deps.connect(e.cdpEndpoint);
644
- const pages = await browser.pages();
645
- const page = pages.find((p) => !p.isClosed()) ?? (await browser.newPage());
646
- e.browser = browser;
647
- e.page = page;
648
- resumed = true;
649
- }
650
- catch (err) {
651
- return text({
652
- status,
653
- resumed: false,
654
- note: "the sign-in finished but the session could not be re-attached: " +
655
- redact(err instanceof Error ? err.message : String(err)),
656
- });
657
- }
658
- }
659
- return text({
660
- status,
661
- resumed,
662
- ...(resumed ? { url: e.page.url() } : {}),
663
- });
664
- },
665
- },
666
- solari_browser_navigate: {
667
- description: "Navigate the browser session to a URL. Returns final url, title and HTTP status.",
668
- inputSchema: { sessionId: z.string(), url: z.string() },
669
- handler: async (a) => {
670
- const page = await activePage(need(a.sessionId));
671
- let url = a.url.trim();
672
- if (!/^[a-z][a-z0-9+.-]*:/i.test(url))
673
- url = `https://${url}`;
674
- const scheme = url.slice(0, url.indexOf(":")).toLowerCase();
675
- if (scheme !== "http" && scheme !== "https") {
676
- throw new Error(`refusing to navigate to a non-http(s) URL (${scheme}:)`);
677
- }
678
- // Block link-local / cloud metadata so page content can't steer the
679
- // agent into reading the browser pool's instance credentials.
680
- const host = new URL(url).hostname;
681
- if (/^(169\.254\.|127\.|\[?::1\]?$|localhost$|metadata\.google)/i.test(host)) {
682
- throw new Error(`refusing to navigate to internal host ${host}`);
683
- }
684
- const resp = await page.goto(url, { waitUntil: "domcontentloaded", timeout: 60_000 });
685
- return text({ url: page.url(), title: await page.title(), status: resp?.status() ?? null });
686
- },
687
- },
688
- solari_browser_read_page: {
689
- description: "Read the current page. format 'text' (default) returns visible text; 'links' returns " +
690
- "clickable links {text, href}; 'html' returns HTML with scripts/styles stripped. " +
691
- "Large output is truncated with an explicit marker.",
692
- inputSchema: {
693
- sessionId: z.string(),
694
- format: z.enum(["text", "links", "html"]).optional(),
695
- },
696
- handler: async (a) => {
697
- const page = await activePage(need(a.sessionId));
698
- const fmt = a.format ?? "text";
699
- if (fmt === "links") {
700
- const all = await page.$$eval("a[href]", (as) => as
701
- .map((el) => ({
702
- text: (el.textContent ?? "").trim().slice(0, 120),
703
- href: el.href,
704
- }))
705
- .filter((l) => l.text));
706
- // Truncate whole elements — slicing the serialized JSON would hand
707
- // the model unparseable output.
708
- const shown = all.slice(0, MAX_LINKS);
709
- return text({ url: page.url(), shown: shown.length, total: all.length, links: shown });
710
- }
711
- let out;
712
- if (fmt === "html") {
713
- out = await page.evaluate(() => {
714
- const d = document.cloneNode(true);
715
- d.querySelectorAll("script,style,noscript,svg").forEach((n) => n.remove());
716
- return d.documentElement?.outerHTML ?? "";
717
- });
718
- }
719
- else {
720
- out = await page.evaluate(() => document.body?.innerText ?? "");
721
- }
722
- return text(`${page.url()}\n\n${capText(out, "chars")}`);
723
- },
724
- },
725
- solari_browser_screenshot: {
726
- description: "Screenshot the browser session's current page (JPEG). fullPage captures the whole " +
727
- "scrollable page (may be downscaled by the client if very tall).",
728
- inputSchema: {
729
- sessionId: z.string(),
730
- fullPage: z.boolean().optional(),
731
- quality: z.number().min(1).max(100).optional(),
732
- },
733
- handler: async (a) => {
734
- const page = await activePage(need(a.sessionId));
735
- const buf = await page.screenshot({
736
- type: "jpeg",
737
- quality: a.quality ?? 75,
738
- fullPage: Boolean(a.fullPage),
739
- });
740
- return {
741
- content: [
742
- { type: "image", data: Buffer.from(buf).toString("base64"), mimeType: "image/jpeg" },
743
- ],
744
- };
745
- },
746
- },
747
- solari_browser_click: {
748
- description: "Click on the page: give a CSS selector, or x/y viewport coordinates (e.g. from a screenshot).",
749
- inputSchema: {
750
- sessionId: z.string(),
751
- selector: z.string().optional(),
752
- x: z.number().optional(),
753
- y: z.number().optional(),
754
- },
755
- handler: async (a) => {
756
- const page = await activePage(need(a.sessionId));
757
- if (a.selector) {
758
- await page.click(a.selector);
759
- }
760
- else if (typeof a.x === "number" && typeof a.y === "number") {
761
- await page.mouse.click(a.x, a.y);
762
- }
763
- else {
764
- throw new Error("provide selector or x+y");
765
- }
766
- return text({ ok: true, url: page.url() });
767
- },
768
- },
769
- solari_browser_type: {
770
- description: "Type text into the page. Optionally focus a CSS selector first. clear:true replaces the " +
771
- "field's existing value (otherwise text is appended at the caret). pressEnter submits.",
772
- inputSchema: {
773
- sessionId: z.string(),
774
- text: z.string(),
775
- selector: z.string().optional(),
776
- clear: z.boolean().optional(),
777
- pressEnter: z.boolean().optional(),
778
- },
779
- handler: async (a) => {
780
- const page = await activePage(need(a.sessionId));
781
- if (a.selector) {
782
- const sel = a.selector;
783
- await page.focus(sel);
784
- if (a.clear) {
785
- await page.$eval(sel, (el) => {
786
- const f = el;
787
- f.value = "";
788
- f.dispatchEvent(new Event("input", { bubbles: true }));
789
- });
790
- }
791
- }
792
- await page.keyboard.type(a.text, { delay: 20 });
793
- if (a.pressEnter)
794
- await page.keyboard.press("Enter");
795
- return text({ ok: true });
796
- },
797
- },
798
- solari_browser_key: {
799
- description: "Press a key in the browser session (e.g. 'Enter', 'Escape', 'ArrowDown', 'PageDown'). " +
800
- "Chords use '+' (e.g. 'Control+a').",
801
- inputSchema: { sessionId: z.string(), key: z.string() },
802
- handler: async (a) => {
803
- const page = await activePage(need(a.sessionId));
804
- const parts = a.key.split("+").filter(Boolean);
805
- const key = parts.pop();
806
- for (const m of parts)
807
- await page.keyboard.down(m);
808
- try {
809
- await page.keyboard.press(key);
810
- }
811
- finally {
812
- for (const m of parts.reverse())
813
- await page.keyboard.up(m);
814
- }
815
- return text({ ok: true });
816
- },
817
- },
818
- solari_browser_evaluate: {
819
- description: "Evaluate a JavaScript expression on the page and return its JSON-serialized result.",
820
- inputSchema: { sessionId: z.string(), expression: z.string() },
821
- handler: async (a) => {
822
- const page = await activePage(need(a.sessionId));
823
- // Pass the expression as a string: puppeteer sends it as a
824
- // debugger-originated Runtime.evaluate, which is exempt from the
825
- // page's CSP. Wrapping it in eval() inside page context is not.
826
- const result = await page.evaluate(a.expression);
827
- return text(result === undefined ? "undefined" : result);
828
- },
829
- },
830
- solari_browser_replay_url: {
831
- description: "Get the session-replay URL for a browser session created with recording: true. " +
832
- "May 404 for a few seconds right after close — retry.",
833
- inputSchema: { sessionId: z.string() },
834
- handler: async (a) => {
835
- const id = a.sessionId;
836
- const known = reg.sessions.get(id);
837
- if (known && !known.recording) {
838
- throw new Error(`session ${id} was not created with recording: true, so it has no replay`);
839
- }
840
- const res = await api(cfg, "GET", `/sessions/${encodeURIComponent(id)}/replay-url`);
841
- if (!res.ok)
842
- await apiError(res, "replay-url");
843
- return text(await res.json());
844
- },
845
- },
846
- solari_browser_close: {
847
- description: "Close a browser session and release it.",
848
- inputSchema: { sessionId: z.string() },
849
- handler: async (a) => {
850
- const id = a.sessionId;
851
- // Release FIRST: if it fails, keep the registry entry so the model can
852
- // retry rather than losing the handle to a still-billing session.
853
- await releaseBrowserSession(cfg, id, api);
854
- const e = reg.sessions.get(id);
855
- if (e) {
856
- try {
857
- await e.browser.disconnect();
858
- }
859
- catch {
860
- /* already gone */
861
- }
862
- reg.sessions.delete(id);
863
- }
864
- return text({ ok: true });
865
- },
866
- },
74
+ ...makeSessionTools(ctx),
75
+ ...makeAuthTools(ctx),
76
+ ...makeNavigationTools(ctx),
77
+ ...makeCaptureTools(ctx),
78
+ ...makeInteractionTools(ctx),
867
79
  };
868
80
  }
869
81
  /** Release every browser session in a registry (used on eviction/shutdown). */