@solarisdk/mcp 0.3.0 → 0.3.2

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/README.md CHANGED
@@ -20,7 +20,8 @@ registry**: `*_create` tools return a `sessionId`, and the rest take it.
20
20
  Point any MCP client at the hosted Streamable HTTP endpoint with your API key:
21
21
 
22
22
  - **Prod:** `https://mcp.getsolari.com/mcp`
23
- - **Staging:** `https://mcp-sta.solaribrowser.com/mcp`
23
+ - **Staging:** currently NOT deployed — the us-east-1 ALB it rode was
24
+ removed in the us-west-1 migration and its DNS record has been deleted.
24
25
 
25
26
  Claude Desktop / claude.ai: **Settings → Connectors → Add custom connector**,
26
27
  URL as above, and send `Authorization: Bearer slr_live_…`. Or in
@@ -68,8 +69,9 @@ lifecycles — re-attach with `solari_connect`).
68
69
  "env": {
69
70
  "SOLARI_API_KEY": "slr_live_…"
70
71
  // optional overrides (defaults hit prod https://api.getsolari.com):
71
- // "SOLARI_BASE_URL": "https://desktops-sta.solaribrowser.com", // sandbox/desktop gateway
72
- // "SOLARI_BROWSER_URL": "https://api-sta.solaribrowser.com" // browser gateway
72
+ // optional staging override — ONE host now serves both gateways:
73
+ // "SOLARI_BASE_URL": "https://api-sta.getsolari.com",
74
+ // "SOLARI_BROWSER_URL": "https://api-sta.getsolari.com"
73
75
  }
74
76
  }
75
77
  }
@@ -85,7 +87,7 @@ Chat, Code and Cowork alike.
85
87
  |---|---|---|---|
86
88
  | `SOLARI_API_KEY` | ✅ | — | unified `slr_live_…` key (works on both gateways) |
87
89
  | `SOLARI_BASE_URL` | | `https://api.getsolari.com` | sandbox/desktop gateway |
88
- | `SOLARI_BROWSER_URL` | | `SOLARI_BASE_URL` → prod | cloud-browser gateway (differs from `SOLARI_BASE_URL` on staging) |
90
+ | `SOLARI_BROWSER_URL` | | `SOLARI_BASE_URL` → prod | cloud-browser gateway. Both prod (`api.getsolari.com`) and staging (`api-sta.getsolari.com`) now path-route browser AND desktop on one host, so this rarely needs setting |
89
91
  | `SOLARI_BROWSER_API_KEY` | | `SOLARI_API_KEY` | separate key for the browser gateway, for environments where the browser and desktop key stores are split |
90
92
 
91
93
  ## Tools
@@ -94,15 +96,82 @@ Chat, Code and Cowork alike.
94
96
 
95
97
  | Tool | What it does |
96
98
  |---|---|
97
- | `solari_browser_create` | Start a browser session (`stealth`, `proxy`, `captcha`, `recording`, `profileId`) → `sessionId` |
99
+ | `solari_browser_create` | Start a browser session (`mode`, `proxy`, `captcha`, `recording`, `profileId`) → `{sessionId, mode, captcha}` |
98
100
  | `solari_browser_navigate` | Go to a URL → `{url, title, status}` |
99
101
  | `solari_browser_read_page` | Page text / links / HTML (30k-char cap) |
100
102
  | `solari_browser_screenshot` | JPEG screenshot (image content) |
101
103
  | `solari_browser_click` / `solari_browser_type` / `solari_browser_key` | Interact (selector or x/y) |
102
104
  | `solari_browser_evaluate` | Run a JS expression on the page |
103
105
  | `solari_browser_replay_url` | rrweb replay URL (needs `recording: true`) |
106
+ | `solari_browser_login` | Ask a HUMAN to sign in — returns a short-lived URL to show them |
107
+ | `solari_browser_await_login` | Wait for them to finish, then re-attach the session |
108
+ | `solari_browser_save_profile` | Persist the signed-in state to a profile (explicit — see below) |
104
109
  | `solari_browser_close` | Release the session |
105
110
 
111
+ ### Browser mode — stealth by default
112
+
113
+ `solari_browser_create` defaults to **`mode: "stealth"`** (the anti-bot hardened pool). A bare
114
+ call with no arguments gets it; the model does not have to ask.
115
+
116
+ This used to be an optional `stealth: true` boolean, and in practice models did not opt in — so
117
+ sessions quietly landed on the fast pool and got blocked by bot detection. The failure looked
118
+ like "the site is broken" rather than "we chose the wrong pool", which is the worst way for a
119
+ default to be wrong.
120
+
121
+ `mode: "fast"` is the explicit opt-out: lower latency, for sites you already know do not
122
+ fingerprint or block automation (internal tools, localhost, your own app, plain docs). If a page
123
+ returns a block/captcha/challenge in fast mode, close the session and retry on stealth.
124
+
125
+ `proxy` and `captcha` are stealth-only upstream and are now **rejected** in fast mode rather than
126
+ silently dropped — a quietly unproxied request leaks the origin IP, which is the one thing the
127
+ caller was trying to avoid. The response echoes the resolved `mode`, so a later block is
128
+ actionable.
129
+
130
+ The deprecated `stealth` boolean still works in both directions for existing callers
131
+ (`stealth: false` still means fast); an explicit `mode` wins when both are supplied.
132
+
133
+ **Captcha auto-solving is on by default too**, for the same reason: an option a model has to opt
134
+ into does not get used, and the resulting failure ("the page is a challenge screen") reads as a
135
+ broken site rather than a missing flag. Pass **`captcha: false`** to opt out — solving costs money
136
+ and adds latency, so turn it off for sites you know never challenge.
137
+
138
+ The default *follows the pool* rather than being a flat `true`: captcha is stealth-only upstream,
139
+ so a flat default would make a plain `mode: "fast"` call throw on an option the caller never
140
+ asked for. Stealth ⇒ on, fast ⇒ off, explicit value always wins. Asking for `captcha: true`
141
+ *together with* `mode: "fast"` is a real contradiction and is refused. The response echoes the
142
+ resolved `captcha` alongside `mode`.
143
+
144
+ ### Login handoff — the agent never touches a password
145
+
146
+ When a session hits a login form or 2FA prompt, the agent calls
147
+ `solari_browser_login` and shows the returned URL to the user. They open it, get
148
+ a **live view of the exact page the agent is on**, and type their credentials
149
+ directly into it. The agent then calls `solari_browser_await_login` and carries
150
+ on where it left off — same page, now signed in.
151
+
152
+ **The agent is locked out for the duration.** The gateway refuses its CDP
153
+ upgrades *and* severs the socket it already holds, so it cannot watch the typing
154
+ even though it is mid-session. That is enforced at the proxy, not here: a check
155
+ in this layer would be advisory, because the agent speaks raw CDP.
156
+
157
+ Two consequences worth knowing:
158
+
159
+ - Other browser tools on that session fail while a handoff is open. That is the
160
+ freeze working, not a bug.
161
+ - `await_login` **reconnects** on success. The sever kills the stored puppeteer
162
+ handle, so resuming needs a fresh dial; without it the agent would "resume"
163
+ into a Target-closed error.
164
+
165
+ The link lives 5 minutes and is single-use. If it lapses, call
166
+ `solari_browser_login` again — the session is untouched.
167
+
168
+ **Saving the login for next time is explicit.** `solari_browser_save_profile`
169
+ captures the session's cookies + localStorage into a profile, and future runs
170
+ skip the human entirely via `create({profileId})`. It is deliberately *not*
171
+ automatic: those are live session cookies, which bypass 2FA, so the profile is a
172
+ credential store and should exist because the user chose it. The tool reports the
173
+ cookie count and origins it stored, so they can see what was persisted.
174
+
106
175
  ### Sandboxes + desktops
107
176
 
108
177
  | Tool | What it does |
@@ -137,11 +206,46 @@ npm run build:bundle # esbuild → dist/solari-mcp.bundle.cjs (single file, no n
137
206
 
138
207
  ```bash
139
208
  SOLARI_API_KEY=slr_live_… \
140
- SOLARI_BASE_URL=https://desktops-sta.solaribrowser.com \
141
- SOLARI_BROWSER_URL=https://api-sta.solaribrowser.com \
209
+ SOLARI_BASE_URL=https://api-sta.getsolari.com \
210
+ SOLARI_BROWSER_URL=https://api-sta.getsolari.com \
142
211
  node test/live-smoke.mjs # add SMOKE_SERVER=dist/solari-mcp.bundle.cjs to test the bundle
143
212
  ```
144
213
 
145
214
  Round-trips browser create→navigate→read→screenshot→search→close, sandbox
146
215
  create→exec→run_code→kill, and desktop create→screenshot→kill as a real MCP
147
216
  client over stdio.
217
+
218
+ ## Hosted connector — where it runs (ops)
219
+
220
+ All of this is **manual**, not terraform.
221
+
222
+ | | Prod | Staging |
223
+ |---|---|---|
224
+ | URL | `https://mcp.getsolari.com/mcp` | *(not deployed — see above)* |
225
+ | Region | **us-west-1** | — |
226
+ | Cluster | `solari-desktops-prod-usw1-gw` | — |
227
+ | Service / taskdef | `solari-mcp-prod-usw1` | — |
228
+ | Image | ECR `solari-mcp:prod` (us-west-1) | — |
229
+ | Ingress | listener rule **prio 20** on the gateway ALB, host-header match | — |
230
+
231
+ It rides the desktop gateway's ALB, so it is **coupled to that ALB's lifetime**.
232
+
233
+ ### ⚠️ `/health` returning 200 does NOT mean the connector is up
234
+
235
+ The desktop gateway answers `GET /health` with `{"ok":true}` too. If DNS points at the
236
+ gateway instead of the connector — which is exactly what happened on 2026-07-28, when the
237
+ us-west-1 migration deleted the old us-east-1 gateway ALB and re-aliased
238
+ `mcp.getsolari.com` to the new one — `/health` still returns 200 while **every MCP call
239
+ 404s**. The connector was down and the health check was green.
240
+
241
+ Two checks that actually discriminate:
242
+
243
+ ```sh
244
+ curl -s https://mcp.getsolari.com/health # connector's body has a "sessions" field; the gateway's does not
245
+ curl -s -o /dev/null -w '%{http_code}\n' -XPOST https://mcp.getsolari.com/mcp \
246
+ -H 'content-type: application/json' -d '{}'
247
+ # 401 = the connector (auth required) 404 = the gateway is answering — connector NOT reachable
248
+ ```
249
+
250
+ **If you move or rebuild the desktop gateway ALB, you must re-point this service too** —
251
+ target group, listener rule, and the Route53 alias all live on that ALB.
package/dist/browser.d.ts CHANGED
@@ -26,6 +26,15 @@ interface BrowserEntry {
26
26
  page: Page;
27
27
  expiresAt: string;
28
28
  recording: boolean;
29
+ /**
30
+ * The session's raw CDP endpoint, kept so the handoff flow can RECONNECT.
31
+ *
32
+ * A login handoff severs the agent's socket gateway-side — deliberately, so
33
+ * the agent cannot watch the human type. That kills this entry's puppeteer
34
+ * handle, so resuming afterwards needs a fresh connect rather than the dead
35
+ * one. Without this the agent "resumes" into a Target-closed error.
36
+ */
37
+ cdpEndpoint: string;
29
38
  }
30
39
  export interface BrowserRegistry {
31
40
  sessions: Map<string, BrowserEntry>;
package/dist/browser.js CHANGED
@@ -12,6 +12,8 @@ const text = (o) => ({
12
12
  const MAX_PAGE_TEXT = 30_000;
13
13
  const MAX_LINKS = 200;
14
14
  const CDP_DIAL_ATTEMPTS = 3;
15
+ /** How often await_login re-checks handoff status. */
16
+ const HANDOFF_POLL_MS = 2_000;
15
17
  /** Signed ws/wss capability URLs must never reach the model or a log. */
16
18
  const redact = (s) => s.replace(/wss?:\/\/[^\s"']+/gi, "[redacted-ws-url]");
17
19
  async function api(cfg, method, path, body) {
@@ -131,21 +133,69 @@ export function makeBrowserToolset(cfg, reg, deps = defaultDeps) {
131
133
  return {
132
134
  solari_browser_create: {
133
135
  description: "Start a Solari cloud browser session. Use this whenever you need to browse the web. " +
134
- "Returns a sessionId for the other solari_browser_* tools. Options: stealth (anti-bot " +
135
- "hardened pool), proxy ('smart' | country code like 'us' requires stealth), captcha " +
136
- "auto-solving (requires stealth), recording (rrweb replay).",
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.",
137
150
  inputSchema: {
151
+ mode: z.enum(["stealth", "fast"]).optional(),
152
+ /** @deprecated use mode. Kept so existing callers keep working. */
138
153
  stealth: z.boolean().optional(),
139
154
  proxy: z.string().optional(),
155
+ /** Defaults to ON (following the pool). Pass false to opt out. */
140
156
  captcha: z.boolean().optional(),
141
157
  recording: z.boolean().optional(),
142
158
  profileId: z.string().optional(),
143
159
  },
144
160
  handler: async (a) => {
161
+ // STEALTH IS THE DEFAULT. Models consistently declined to opt in when it
162
+ // was an optional boolean, so sessions silently landed on the fast pool
163
+ // and got blocked by bot detection — the failure looked like "the site
164
+ // is broken" rather than "we picked the wrong pool". Defaulting on, with
165
+ // an explicit escape hatch, removes that whole class of confusion.
166
+ //
167
+ // Precedence: explicit mode wins; then the deprecated stealth boolean
168
+ // (so `stealth:false` still means fast for old callers); then stealth.
169
+ const stealth = a.mode !== undefined ? a.mode === "stealth" : (a.stealth ?? true);
170
+ // CAPTCHA IS ALSO ON BY DEFAULT — same reasoning as stealth: a model
171
+ // that has to opt in does not, and the resulting failure ("the page is
172
+ // a challenge screen") reads as a broken site rather than a missing
173
+ // option.
174
+ //
175
+ // The default FOLLOWS the pool rather than being a flat `true`, because
176
+ // captcha is stealth-only upstream. A flat true would make plain
177
+ // mode:'fast' throw on an option the caller never asked for, turning the
178
+ // escape hatch into a dead end. So: stealth ⇒ on, fast ⇒ off, and an
179
+ // explicit value always wins (captcha:false is the opt-out).
180
+ const captcha = a.captcha ?? stealth;
181
+ // proxy/captcha are stealth-only upstream. Previously a fast-pool
182
+ // session with proxy came back with the proxy silently dropped and only
183
+ // a note in the response; fail loudly instead — a silently unproxied
184
+ // request can leak the origin IP, which is the one thing the caller was
185
+ // trying to avoid. Only an EXPLICIT ask conflicts; the captcha default
186
+ // simply turns itself off in fast mode.
187
+ if (!stealth) {
188
+ const needsStealth = [a.proxy ? "proxy" : "", a.captcha === true ? "captcha" : ""].filter(Boolean);
189
+ if (needsStealth.length) {
190
+ throw new Error(`${needsStealth.join(" and ")} ${needsStealth.length > 1 ? "require" : "requires"} ` +
191
+ `stealth, but mode is 'fast'. ` +
192
+ `Drop ${needsStealth.length > 1 ? "them" : "it"} or use mode:'stealth'.`);
193
+ }
194
+ }
145
195
  const body = {};
146
- if (a.stealth)
196
+ if (stealth)
147
197
  body.stealth = true;
148
- if (a.captcha)
198
+ if (captcha)
149
199
  body.captcha = true;
150
200
  if (a.recording)
151
201
  body.recording = true;
@@ -189,6 +239,7 @@ export function makeBrowserToolset(cfg, reg, deps = defaultDeps) {
189
239
  page,
190
240
  expiresAt: s.expiresAt,
191
241
  recording: Boolean(a.recording),
242
+ cdpEndpoint: cdp,
192
243
  });
193
244
  }
194
245
  catch (err) {
@@ -196,10 +247,263 @@ export function makeBrowserToolset(cfg, reg, deps = defaultDeps) {
196
247
  throw new Error(`browser session started but could not be attached (released it): ${redact(err instanceof Error ? err.message : String(err))}`);
197
248
  }
198
249
  // Proxy silently degrades rather than erroring — surface what we got.
250
+ // Echo the resolved mode back: the caller did not necessarily pick it
251
+ // (stealth is the default), and knowing which pool it landed on is what
252
+ // makes a later block actionable — "retry on stealth" vs "this site
253
+ // blocks us even hardened".
199
254
  return text({
200
255
  sessionId: s.sessionId,
256
+ mode: stealth ? "stealth" : "fast",
257
+ captcha,
201
258
  expiresAt: s.expiresAt,
202
- proxy: s.proxy ?? (a.proxy ? "NOT APPLIED (check plan/stealth)" : undefined),
259
+ proxy: s.proxy ?? (a.proxy ? "NOT APPLIED (check plan)" : undefined),
260
+ });
261
+ },
262
+ },
263
+ solari_browser_profiles: {
264
+ description: "List saved browser profiles for this account. A profile is a stored signed-in state " +
265
+ "(cookies + localStorage) that solari_browser_create({profileId}) replays, so the " +
266
+ "session starts already logged in.\n" +
267
+ "Check here FIRST when a task will need a login: if a profile for that site already " +
268
+ "exists, use it and no human is involved at all. `version` is 1 and `populated` is " +
269
+ "false for a profile nobody has signed into yet — that one still needs " +
270
+ "solari_browser_login({profileName}).",
271
+ inputSchema: {},
272
+ handler: async () => {
273
+ const res = await api(cfg, "GET", "/profiles");
274
+ if (res.status !== 200)
275
+ await apiError(res, "list profiles");
276
+ const rows = (await res.json());
277
+ return text({
278
+ profiles: rows.map((p) => ({
279
+ profileId: p.id,
280
+ name: p.name,
281
+ version: p.version,
282
+ populated: Boolean(p.storageStateS3Key),
283
+ lastUsedAt: p.lastUsedAt ?? null,
284
+ })),
285
+ });
286
+ },
287
+ },
288
+ solari_browser_login: {
289
+ description: "Ask a HUMAN to sign in, so you never handle credentials yourself. Two ways to call it:\n" +
290
+ "HOT — pass `sessionId` when you are ALREADY on a login wall mid-task. The user gets a " +
291
+ "live view of the exact page you are on and types into it; you resume on that same page.\n" +
292
+ "COLD — pass `profileName` when you know a task will need a login and no session is open " +
293
+ "yet. The user signs in once in a profile editor, and every later " +
294
+ "solari_browser_create({profileId}) starts already authenticated. Prefer this when you " +
295
+ "can: nobody has to be watching mid-run. The profile is created if it does not exist.\n" +
296
+ "Pass exactly one of the two. Either way, SHOW THE RETURNED URL TO THE USER, then call " +
297
+ "solari_browser_await_login.\n" +
298
+ "In the HOT case your access to that session is REVOKED while the handoff is open — " +
299
+ "deliberately, so you cannot observe what they type — and the link expires in 5 minutes. " +
300
+ "Cold links last longer and revoke nothing, because there is no session yet.\n" +
301
+ "`reason` is required and is shown to the user — say plainly which site is asking and " +
302
+ "what for, because they are being asked to type a password on your say-so.",
303
+ inputSchema: {
304
+ sessionId: z.string().optional(),
305
+ profileName: z.string().optional(),
306
+ reason: z.string(),
307
+ },
308
+ handler: async (a) => {
309
+ const sessionId = typeof a.sessionId === "string" ? a.sessionId : "";
310
+ const profileName = typeof a.profileName === "string" ? a.profileName.trim() : "";
311
+ if (Boolean(sessionId) === Boolean(profileName)) {
312
+ throw new Error("Pass exactly one of sessionId (rescue a live session) or profileName (seed a " +
313
+ "profile before you start).");
314
+ }
315
+ // ── COLD: seed a profile, no session involved ────────────────────
316
+ if (profileName) {
317
+ const listRes = await api(cfg, "GET", "/profiles");
318
+ if (listRes.status !== 200)
319
+ await apiError(listRes, "list profiles");
320
+ const rows = (await listRes.json());
321
+ const match = rows.find((p) => (p.name ?? "").toLowerCase() === profileName.toLowerCase());
322
+ let profileId = match?.id ?? "";
323
+ if (!profileId) {
324
+ const mk = await api(cfg, "POST", "/profiles", { name: profileName });
325
+ if (mk.status !== 200 && mk.status !== 201)
326
+ await apiError(mk, "create profile");
327
+ profileId = (await mk.json()).id ?? "";
328
+ if (!profileId)
329
+ throw new Error("profile created but no id returned");
330
+ }
331
+ const hRes = await api(cfg, "POST", `/profiles/${encodeURIComponent(profileId)}/login-handoff`, {
332
+ reason: a.reason,
333
+ });
334
+ if (hRes.status !== 200)
335
+ await apiError(hRes, "cold login request");
336
+ const h = (await hRes.json());
337
+ return text({
338
+ mode: "cold",
339
+ profileId,
340
+ profileName,
341
+ handoffId: h.handoffId,
342
+ url: h.url,
343
+ expiresAt: h.expiresAt,
344
+ // Echoed so await_login can tell a fresh save from the state it
345
+ // started in — the version bump IS the completion signal.
346
+ sinceVersion: h.version,
347
+ next: `Show the url to the user, then call solari_browser_await_login({ profileId: ` +
348
+ `"${profileId}", sinceVersion: ${h.version} }).`,
349
+ });
350
+ }
351
+ // ── HOT: rescue the live session ─────────────────────────────────
352
+ const e = need(sessionId);
353
+ const res = await api(cfg, "POST", `/sessions/${encodeURIComponent(sessionId)}/handoff`, { reason: a.reason });
354
+ if (res.status !== 200)
355
+ await apiError(res, "handoff request");
356
+ const h = (await res.json());
357
+ // The gateway is severing our socket right now. Drop the local handle
358
+ // rather than leave a half-dead Browser object that throws confusing
359
+ // Target-closed errors on every subsequent tool call.
360
+ try {
361
+ await e.browser.close();
362
+ }
363
+ catch {
364
+ /* already gone — that is the point */
365
+ }
366
+ return text({
367
+ mode: "hot",
368
+ handoffId: h.handoffId,
369
+ url: h.url,
370
+ expiresAt: h.expiresAt,
371
+ next: "Show the url to the user, then call solari_browser_await_login.",
372
+ });
373
+ },
374
+ },
375
+ solari_browser_save_profile: {
376
+ description: "Save this session's signed-in state (cookies + localStorage) into an existing profile, " +
377
+ "so future sessions start already logged in and no human is needed again. Pass the " +
378
+ "profileId to overwrite.\n" +
379
+ "ONLY do this when the user has asked you to remember the login. It is deliberately not " +
380
+ "automatic: a saved profile holds live session cookies, which bypass 2FA — it is a " +
381
+ "credential store, and it should exist because someone chose it. If they have not said " +
382
+ "so, ask first.\n" +
383
+ "Returns what was captured (cookie count and origins) so the user can see what they " +
384
+ "just persisted.",
385
+ inputSchema: {
386
+ sessionId: z.string(),
387
+ profileId: z.string(),
388
+ },
389
+ handler: async (a) => {
390
+ need(a.sessionId);
391
+ const res = await api(cfg, "POST", `/sessions/${encodeURIComponent(a.sessionId)}/save-profile`, { profileId: a.profileId });
392
+ if (res.status !== 200)
393
+ await apiError(res, "save profile");
394
+ const body = (await res.json());
395
+ return text({
396
+ ...body,
397
+ note: "Future sessions can use this with solari_browser_create({profileId}).",
398
+ });
399
+ },
400
+ },
401
+ solari_browser_await_login: {
402
+ description: "Wait for the human to finish the sign-in you requested with solari_browser_login. " +
403
+ "Blocks until they are done, the link expires, or timeoutMs elapses. Returns only a " +
404
+ "status — never anything the user typed. On 'completed' the session is yours again, " +
405
+ "on the same page, still signed in, and you can carry on where you left off. On " +
406
+ "'expired' or 'timeout' the user did not finish: ask if they still want to, and call " +
407
+ "solari_browser_login again for a fresh link rather than retrying this.",
408
+ inputSchema: {
409
+ sessionId: z.string().optional(),
410
+ profileId: z.string().optional(),
411
+ sinceVersion: z.number().optional(),
412
+ handoffId: z.string().optional(),
413
+ timeoutMs: z.number().optional(),
414
+ },
415
+ handler: async (a) => {
416
+ const sessionId = typeof a.sessionId === "string" ? a.sessionId : "";
417
+ const profileId = typeof a.profileId === "string" ? a.profileId : "";
418
+ if (Boolean(sessionId) === Boolean(profileId)) {
419
+ throw new Error("Pass exactly one of sessionId (hot handoff) or profileId (cold handoff) — the " +
420
+ "same one solari_browser_login returned.");
421
+ }
422
+ // Clamp: never spin forever, never poll so briefly the human has no
423
+ // chance. Defaults to the handoff's own 5-minute life.
424
+ const requested = typeof a.timeoutMs === "number" ? a.timeoutMs : 300_000;
425
+ const deadline = Date.now() + Math.min(Math.max(requested, 5_000), 600_000);
426
+ let status = "pending";
427
+ // ── COLD: watch the profile's version, which the editor's save bumps.
428
+ // Deliberately NOT "did a storageState appear": re-signing into a
429
+ // profile that already had one must also count as completed.
430
+ if (profileId) {
431
+ const since = typeof a.sinceVersion === "number" ? a.sinceVersion : 0;
432
+ let version = since;
433
+ while (Date.now() < deadline) {
434
+ const res = await api(cfg, "GET", "/profiles");
435
+ if (res.status !== 200)
436
+ await apiError(res, "profile status");
437
+ const rows = (await res.json());
438
+ const row = rows.find((p) => p.id === profileId);
439
+ if (!row)
440
+ throw new Error(`profile ${profileId} no longer exists`);
441
+ version = typeof row.version === "number" ? row.version : since;
442
+ if (version > since) {
443
+ status = "completed";
444
+ break;
445
+ }
446
+ await new Promise((r) => setTimeout(r, HANDOFF_POLL_MS));
447
+ }
448
+ if (status !== "completed")
449
+ status = "timeout";
450
+ return text({
451
+ mode: "cold",
452
+ status,
453
+ profileId,
454
+ version,
455
+ next: status === "completed"
456
+ ? `Signed in and saved. Use solari_browser_create({ profileId: "${profileId}" }) ` +
457
+ "and you will start already authenticated."
458
+ : "The user did not finish. Ask if they still want to, then call " +
459
+ "solari_browser_login again for a fresh link.",
460
+ });
461
+ }
462
+ const e = need(sessionId);
463
+ while (Date.now() < deadline) {
464
+ const q = typeof a.handoffId === "string" && a.handoffId
465
+ ? `?handoffId=${encodeURIComponent(a.handoffId)}`
466
+ : "";
467
+ const res = await api(cfg, "GET", `/sessions/${encodeURIComponent(sessionId)}/handoff${q}`);
468
+ if (res.status !== 200)
469
+ await apiError(res, "handoff status");
470
+ const body = (await res.json());
471
+ status = body.status ?? "none";
472
+ // "none" means no open handoff and no id to look up — treat as done
473
+ // rather than spinning; the caller can re-mint if that was wrong.
474
+ if (status !== "pending")
475
+ break;
476
+ await new Promise((r) => setTimeout(r, HANDOFF_POLL_MS));
477
+ }
478
+ if (status === "pending")
479
+ status = "timeout";
480
+ // Reconnect: the handoff severed our old socket on purpose, so the
481
+ // stored handle is dead even on success. Re-dial and re-adopt the page
482
+ // the human left us on, which is the whole point of a HOT handoff —
483
+ // the agent resumes mid-flow instead of starting over.
484
+ let resumed = false;
485
+ if (status === "completed" || status === "none") {
486
+ try {
487
+ const browser = await deps.connect(e.cdpEndpoint);
488
+ const pages = await browser.pages();
489
+ const page = pages.find((p) => !p.isClosed()) ?? (await browser.newPage());
490
+ e.browser = browser;
491
+ e.page = page;
492
+ resumed = true;
493
+ }
494
+ catch (err) {
495
+ return text({
496
+ status,
497
+ resumed: false,
498
+ note: "the sign-in finished but the session could not be re-attached: " +
499
+ redact(err instanceof Error ? err.message : String(err)),
500
+ });
501
+ }
502
+ }
503
+ return text({
504
+ status,
505
+ resumed,
506
+ ...(resumed ? { url: e.page.url() } : {}),
203
507
  });
204
508
  },
205
509
  },
@@ -113676,6 +113676,7 @@ var text = (o) => ({
113676
113676
  var MAX_PAGE_TEXT = 3e4;
113677
113677
  var MAX_LINKS = 200;
113678
113678
  var CDP_DIAL_ATTEMPTS = 3;
113679
+ var HANDOFF_POLL_MS = 2e3;
113679
113680
  var redact = (s) => s.replace(/wss?:\/\/[^\s"']+/gi, "[redacted-ws-url]");
113680
113681
  async function api(cfg, method, path12, body) {
113681
113682
  const res = await fetch(`${cfg.baseUrl}${path12}`, {
@@ -113768,18 +113769,31 @@ function makeBrowserToolset(cfg, reg, deps = defaultDeps) {
113768
113769
  \u2026[truncated ${s.length - MAX_PAGE_TEXT} of ${s.length} ${what}]` : s;
113769
113770
  return {
113770
113771
  solari_browser_create: {
113771
- description: "Start a Solari cloud browser session. Use this whenever you need to browse the web. Returns a sessionId for the other solari_browser_* tools. Options: stealth (anti-bot hardened pool), proxy ('smart' | country code like 'us' \u2014 requires stealth), captcha auto-solving (requires stealth), recording (rrweb replay).",
113772
+ description: "Start a Solari cloud browser session. Use this whenever you need to browse the web. Returns a sessionId for the other solari_browser_* tools.\nmode defaults to 'stealth' \u2014 the anti-bot hardened pool. Just call this with no arguments and you get it; you do NOT need to ask for stealth explicitly. Stealth is the right default for the open web: ordinary sites work fine on it, and it is what keeps bot-detection from blocking the session.\nPass mode:'fast' ONLY for a site you already know does not fingerprint or block automation (internal tools, localhost, your own app, plain docs/API pages) and you want the lower-latency pool. If a page in fast mode returns a block/captcha/challenge, close the session and retry with mode:'stealth'.\ncaptcha auto-solving is ALSO on by default (it follows the pool: on for stealth, off for fast). Pass captcha:false to turn it off \u2014 solving a challenge costs money and adds latency, so switch it off for sites you know never challenge.\nproxy ('smart' | country code like 'us') REQUIRES stealth, as does captcha; both are rejected if you ask for them explicitly in fast mode. recording gives an rrweb replay.",
113772
113773
  inputSchema: {
113774
+ mode: external_exports.enum(["stealth", "fast"]).optional(),
113775
+ /** @deprecated use mode. Kept so existing callers keep working. */
113773
113776
  stealth: external_exports.boolean().optional(),
113774
113777
  proxy: external_exports.string().optional(),
113778
+ /** Defaults to ON (following the pool). Pass false to opt out. */
113775
113779
  captcha: external_exports.boolean().optional(),
113776
113780
  recording: external_exports.boolean().optional(),
113777
113781
  profileId: external_exports.string().optional()
113778
113782
  },
113779
113783
  handler: async (a2) => {
113784
+ const stealth = a2.mode !== void 0 ? a2.mode === "stealth" : a2.stealth ?? true;
113785
+ const captcha = a2.captcha ?? stealth;
113786
+ if (!stealth) {
113787
+ const needsStealth = [a2.proxy ? "proxy" : "", a2.captcha === true ? "captcha" : ""].filter(Boolean);
113788
+ if (needsStealth.length) {
113789
+ throw new Error(
113790
+ `${needsStealth.join(" and ")} ${needsStealth.length > 1 ? "require" : "requires"} stealth, but mode is 'fast'. Drop ${needsStealth.length > 1 ? "them" : "it"} or use mode:'stealth'.`
113791
+ );
113792
+ }
113793
+ }
113780
113794
  const body = {};
113781
- if (a2.stealth) body.stealth = true;
113782
- if (a2.captcha) body.captcha = true;
113795
+ if (stealth) body.stealth = true;
113796
+ if (captcha) body.captcha = true;
113783
113797
  if (a2.recording) body.recording = true;
113784
113798
  if (a2.proxy) body.proxy = a2.proxy;
113785
113799
  if (a2.profileId) body.profileId = a2.profileId;
@@ -113808,7 +113822,8 @@ function makeBrowserToolset(cfg, reg, deps = defaultDeps) {
113808
113822
  browser,
113809
113823
  page,
113810
113824
  expiresAt: s.expiresAt,
113811
- recording: Boolean(a2.recording)
113825
+ recording: Boolean(a2.recording),
113826
+ cdpEndpoint: cdp
113812
113827
  });
113813
113828
  } catch (err) {
113814
113829
  await releaseBrowserSession(cfg, s.sessionId, api2).catch(() => {
@@ -113819,8 +113834,203 @@ function makeBrowserToolset(cfg, reg, deps = defaultDeps) {
113819
113834
  }
113820
113835
  return text({
113821
113836
  sessionId: s.sessionId,
113837
+ mode: stealth ? "stealth" : "fast",
113838
+ captcha,
113822
113839
  expiresAt: s.expiresAt,
113823
- proxy: s.proxy ?? (a2.proxy ? "NOT APPLIED (check plan/stealth)" : void 0)
113840
+ proxy: s.proxy ?? (a2.proxy ? "NOT APPLIED (check plan)" : void 0)
113841
+ });
113842
+ }
113843
+ },
113844
+ solari_browser_profiles: {
113845
+ description: "List saved browser profiles for this account. A profile is a stored signed-in state (cookies + localStorage) that solari_browser_create({profileId}) replays, so the session starts already logged in.\nCheck here FIRST when a task will need a login: if a profile for that site already exists, use it and no human is involved at all. `version` is 1 and `populated` is false for a profile nobody has signed into yet \u2014 that one still needs solari_browser_login({profileName}).",
113846
+ inputSchema: {},
113847
+ handler: async () => {
113848
+ const res = await api2(cfg, "GET", "/profiles");
113849
+ if (res.status !== 200) await apiError(res, "list profiles");
113850
+ const rows = await res.json();
113851
+ return text({
113852
+ profiles: rows.map((p) => ({
113853
+ profileId: p.id,
113854
+ name: p.name,
113855
+ version: p.version,
113856
+ populated: Boolean(p.storageStateS3Key),
113857
+ lastUsedAt: p.lastUsedAt ?? null
113858
+ }))
113859
+ });
113860
+ }
113861
+ },
113862
+ solari_browser_login: {
113863
+ description: "Ask a HUMAN to sign in, so you never handle credentials yourself. Two ways to call it:\nHOT \u2014 pass `sessionId` when you are ALREADY on a login wall mid-task. The user gets a live view of the exact page you are on and types into it; you resume on that same page.\nCOLD \u2014 pass `profileName` when you know a task will need a login and no session is open yet. The user signs in once in a profile editor, and every later solari_browser_create({profileId}) starts already authenticated. Prefer this when you can: nobody has to be watching mid-run. The profile is created if it does not exist.\nPass exactly one of the two. Either way, SHOW THE RETURNED URL TO THE USER, then call solari_browser_await_login.\nIn the HOT case your access to that session is REVOKED while the handoff is open \u2014 deliberately, so you cannot observe what they type \u2014 and the link expires in 5 minutes. Cold links last longer and revoke nothing, because there is no session yet.\n`reason` is required and is shown to the user \u2014 say plainly which site is asking and what for, because they are being asked to type a password on your say-so.",
113864
+ inputSchema: {
113865
+ sessionId: external_exports.string().optional(),
113866
+ profileName: external_exports.string().optional(),
113867
+ reason: external_exports.string()
113868
+ },
113869
+ handler: async (a2) => {
113870
+ const sessionId = typeof a2.sessionId === "string" ? a2.sessionId : "";
113871
+ const profileName = typeof a2.profileName === "string" ? a2.profileName.trim() : "";
113872
+ if (Boolean(sessionId) === Boolean(profileName)) {
113873
+ throw new Error(
113874
+ "Pass exactly one of sessionId (rescue a live session) or profileName (seed a profile before you start)."
113875
+ );
113876
+ }
113877
+ if (profileName) {
113878
+ const listRes = await api2(cfg, "GET", "/profiles");
113879
+ if (listRes.status !== 200) await apiError(listRes, "list profiles");
113880
+ const rows = await listRes.json();
113881
+ const match = rows.find(
113882
+ (p) => (p.name ?? "").toLowerCase() === profileName.toLowerCase()
113883
+ );
113884
+ let profileId = match?.id ?? "";
113885
+ if (!profileId) {
113886
+ const mk = await api2(cfg, "POST", "/profiles", { name: profileName });
113887
+ if (mk.status !== 200 && mk.status !== 201) await apiError(mk, "create profile");
113888
+ profileId = (await mk.json()).id ?? "";
113889
+ if (!profileId) throw new Error("profile created but no id returned");
113890
+ }
113891
+ const hRes = await api2(cfg, "POST", `/profiles/${encodeURIComponent(profileId)}/login-handoff`, {
113892
+ reason: a2.reason
113893
+ });
113894
+ if (hRes.status !== 200) await apiError(hRes, "cold login request");
113895
+ const h2 = await hRes.json();
113896
+ return text({
113897
+ mode: "cold",
113898
+ profileId,
113899
+ profileName,
113900
+ handoffId: h2.handoffId,
113901
+ url: h2.url,
113902
+ expiresAt: h2.expiresAt,
113903
+ // Echoed so await_login can tell a fresh save from the state it
113904
+ // started in — the version bump IS the completion signal.
113905
+ sinceVersion: h2.version,
113906
+ next: `Show the url to the user, then call solari_browser_await_login({ profileId: "${profileId}", sinceVersion: ${h2.version} }).`
113907
+ });
113908
+ }
113909
+ const e = need(sessionId);
113910
+ const res = await api2(
113911
+ cfg,
113912
+ "POST",
113913
+ `/sessions/${encodeURIComponent(sessionId)}/handoff`,
113914
+ { reason: a2.reason }
113915
+ );
113916
+ if (res.status !== 200) await apiError(res, "handoff request");
113917
+ const h = await res.json();
113918
+ try {
113919
+ await e.browser.close();
113920
+ } catch {
113921
+ }
113922
+ return text({
113923
+ mode: "hot",
113924
+ handoffId: h.handoffId,
113925
+ url: h.url,
113926
+ expiresAt: h.expiresAt,
113927
+ next: "Show the url to the user, then call solari_browser_await_login."
113928
+ });
113929
+ }
113930
+ },
113931
+ solari_browser_save_profile: {
113932
+ description: "Save this session's signed-in state (cookies + localStorage) into an existing profile, so future sessions start already logged in and no human is needed again. Pass the profileId to overwrite.\nONLY do this when the user has asked you to remember the login. It is deliberately not automatic: a saved profile holds live session cookies, which bypass 2FA \u2014 it is a credential store, and it should exist because someone chose it. If they have not said so, ask first.\nReturns what was captured (cookie count and origins) so the user can see what they just persisted.",
113933
+ inputSchema: {
113934
+ sessionId: external_exports.string(),
113935
+ profileId: external_exports.string()
113936
+ },
113937
+ handler: async (a2) => {
113938
+ need(a2.sessionId);
113939
+ const res = await api2(
113940
+ cfg,
113941
+ "POST",
113942
+ `/sessions/${encodeURIComponent(a2.sessionId)}/save-profile`,
113943
+ { profileId: a2.profileId }
113944
+ );
113945
+ if (res.status !== 200) await apiError(res, "save profile");
113946
+ const body = await res.json();
113947
+ return text({
113948
+ ...body,
113949
+ note: "Future sessions can use this with solari_browser_create({profileId})."
113950
+ });
113951
+ }
113952
+ },
113953
+ solari_browser_await_login: {
113954
+ description: "Wait for the human to finish the sign-in you requested with solari_browser_login. Blocks until they are done, the link expires, or timeoutMs elapses. Returns only a status \u2014 never anything the user typed. On 'completed' the session is yours again, on the same page, still signed in, and you can carry on where you left off. On 'expired' or 'timeout' the user did not finish: ask if they still want to, and call solari_browser_login again for a fresh link rather than retrying this.",
113955
+ inputSchema: {
113956
+ sessionId: external_exports.string().optional(),
113957
+ profileId: external_exports.string().optional(),
113958
+ sinceVersion: external_exports.number().optional(),
113959
+ handoffId: external_exports.string().optional(),
113960
+ timeoutMs: external_exports.number().optional()
113961
+ },
113962
+ handler: async (a2) => {
113963
+ const sessionId = typeof a2.sessionId === "string" ? a2.sessionId : "";
113964
+ const profileId = typeof a2.profileId === "string" ? a2.profileId : "";
113965
+ if (Boolean(sessionId) === Boolean(profileId)) {
113966
+ throw new Error(
113967
+ "Pass exactly one of sessionId (hot handoff) or profileId (cold handoff) \u2014 the same one solari_browser_login returned."
113968
+ );
113969
+ }
113970
+ const requested = typeof a2.timeoutMs === "number" ? a2.timeoutMs : 3e5;
113971
+ const deadline = Date.now() + Math.min(Math.max(requested, 5e3), 6e5);
113972
+ let status = "pending";
113973
+ if (profileId) {
113974
+ const since = typeof a2.sinceVersion === "number" ? a2.sinceVersion : 0;
113975
+ let version2 = since;
113976
+ while (Date.now() < deadline) {
113977
+ const res = await api2(cfg, "GET", "/profiles");
113978
+ if (res.status !== 200) await apiError(res, "profile status");
113979
+ const rows = await res.json();
113980
+ const row = rows.find((p) => p.id === profileId);
113981
+ if (!row) throw new Error(`profile ${profileId} no longer exists`);
113982
+ version2 = typeof row.version === "number" ? row.version : since;
113983
+ if (version2 > since) {
113984
+ status = "completed";
113985
+ break;
113986
+ }
113987
+ await new Promise((r) => setTimeout(r, HANDOFF_POLL_MS));
113988
+ }
113989
+ if (status !== "completed") status = "timeout";
113990
+ return text({
113991
+ mode: "cold",
113992
+ status,
113993
+ profileId,
113994
+ version: version2,
113995
+ next: status === "completed" ? `Signed in and saved. Use solari_browser_create({ profileId: "${profileId}" }) and you will start already authenticated.` : "The user did not finish. Ask if they still want to, then call solari_browser_login again for a fresh link."
113996
+ });
113997
+ }
113998
+ const e = need(sessionId);
113999
+ while (Date.now() < deadline) {
114000
+ const q2 = typeof a2.handoffId === "string" && a2.handoffId ? `?handoffId=${encodeURIComponent(a2.handoffId)}` : "";
114001
+ const res = await api2(
114002
+ cfg,
114003
+ "GET",
114004
+ `/sessions/${encodeURIComponent(sessionId)}/handoff${q2}`
114005
+ );
114006
+ if (res.status !== 200) await apiError(res, "handoff status");
114007
+ const body = await res.json();
114008
+ status = body.status ?? "none";
114009
+ if (status !== "pending") break;
114010
+ await new Promise((r) => setTimeout(r, HANDOFF_POLL_MS));
114011
+ }
114012
+ if (status === "pending") status = "timeout";
114013
+ let resumed = false;
114014
+ if (status === "completed" || status === "none") {
114015
+ try {
114016
+ const browser = await deps.connect(e.cdpEndpoint);
114017
+ const pages = await browser.pages();
114018
+ const page = pages.find((p) => !p.isClosed()) ?? await browser.newPage();
114019
+ e.browser = browser;
114020
+ e.page = page;
114021
+ resumed = true;
114022
+ } catch (err) {
114023
+ return text({
114024
+ status,
114025
+ resumed: false,
114026
+ note: "the sign-in finished but the session could not be re-attached: " + redact(err instanceof Error ? err.message : String(err))
114027
+ });
114028
+ }
114029
+ }
114030
+ return text({
114031
+ status,
114032
+ resumed,
114033
+ ...resumed ? { url: e.page.url() } : {}
113824
114034
  });
113825
114035
  }
113826
114036
  },
@@ -112345,6 +112345,7 @@ var text = (o) => ({
112345
112345
  var MAX_PAGE_TEXT = 3e4;
112346
112346
  var MAX_LINKS = 200;
112347
112347
  var CDP_DIAL_ATTEMPTS = 3;
112348
+ var HANDOFF_POLL_MS = 2e3;
112348
112349
  var redact = (s) => s.replace(/wss?:\/\/[^\s"']+/gi, "[redacted-ws-url]");
112349
112350
  async function api(cfg, method, path12, body) {
112350
112351
  const res = await fetch(`${cfg.baseUrl}${path12}`, {
@@ -112437,18 +112438,31 @@ function makeBrowserToolset(cfg, reg, deps = defaultDeps) {
112437
112438
  \u2026[truncated ${s.length - MAX_PAGE_TEXT} of ${s.length} ${what}]` : s;
112438
112439
  return {
112439
112440
  solari_browser_create: {
112440
- description: "Start a Solari cloud browser session. Use this whenever you need to browse the web. Returns a sessionId for the other solari_browser_* tools. Options: stealth (anti-bot hardened pool), proxy ('smart' | country code like 'us' \u2014 requires stealth), captcha auto-solving (requires stealth), recording (rrweb replay).",
112441
+ description: "Start a Solari cloud browser session. Use this whenever you need to browse the web. Returns a sessionId for the other solari_browser_* tools.\nmode defaults to 'stealth' \u2014 the anti-bot hardened pool. Just call this with no arguments and you get it; you do NOT need to ask for stealth explicitly. Stealth is the right default for the open web: ordinary sites work fine on it, and it is what keeps bot-detection from blocking the session.\nPass mode:'fast' ONLY for a site you already know does not fingerprint or block automation (internal tools, localhost, your own app, plain docs/API pages) and you want the lower-latency pool. If a page in fast mode returns a block/captcha/challenge, close the session and retry with mode:'stealth'.\ncaptcha auto-solving is ALSO on by default (it follows the pool: on for stealth, off for fast). Pass captcha:false to turn it off \u2014 solving a challenge costs money and adds latency, so switch it off for sites you know never challenge.\nproxy ('smart' | country code like 'us') REQUIRES stealth, as does captcha; both are rejected if you ask for them explicitly in fast mode. recording gives an rrweb replay.",
112441
112442
  inputSchema: {
112443
+ mode: external_exports.enum(["stealth", "fast"]).optional(),
112444
+ /** @deprecated use mode. Kept so existing callers keep working. */
112442
112445
  stealth: external_exports.boolean().optional(),
112443
112446
  proxy: external_exports.string().optional(),
112447
+ /** Defaults to ON (following the pool). Pass false to opt out. */
112444
112448
  captcha: external_exports.boolean().optional(),
112445
112449
  recording: external_exports.boolean().optional(),
112446
112450
  profileId: external_exports.string().optional()
112447
112451
  },
112448
112452
  handler: async (a2) => {
112453
+ const stealth = a2.mode !== void 0 ? a2.mode === "stealth" : a2.stealth ?? true;
112454
+ const captcha = a2.captcha ?? stealth;
112455
+ if (!stealth) {
112456
+ const needsStealth = [a2.proxy ? "proxy" : "", a2.captcha === true ? "captcha" : ""].filter(Boolean);
112457
+ if (needsStealth.length) {
112458
+ throw new Error(
112459
+ `${needsStealth.join(" and ")} ${needsStealth.length > 1 ? "require" : "requires"} stealth, but mode is 'fast'. Drop ${needsStealth.length > 1 ? "them" : "it"} or use mode:'stealth'.`
112460
+ );
112461
+ }
112462
+ }
112449
112463
  const body = {};
112450
- if (a2.stealth) body.stealth = true;
112451
- if (a2.captcha) body.captcha = true;
112464
+ if (stealth) body.stealth = true;
112465
+ if (captcha) body.captcha = true;
112452
112466
  if (a2.recording) body.recording = true;
112453
112467
  if (a2.proxy) body.proxy = a2.proxy;
112454
112468
  if (a2.profileId) body.profileId = a2.profileId;
@@ -112477,7 +112491,8 @@ function makeBrowserToolset(cfg, reg, deps = defaultDeps) {
112477
112491
  browser,
112478
112492
  page,
112479
112493
  expiresAt: s.expiresAt,
112480
- recording: Boolean(a2.recording)
112494
+ recording: Boolean(a2.recording),
112495
+ cdpEndpoint: cdp
112481
112496
  });
112482
112497
  } catch (err) {
112483
112498
  await releaseBrowserSession(cfg, s.sessionId, api2).catch(() => {
@@ -112488,8 +112503,203 @@ function makeBrowserToolset(cfg, reg, deps = defaultDeps) {
112488
112503
  }
112489
112504
  return text({
112490
112505
  sessionId: s.sessionId,
112506
+ mode: stealth ? "stealth" : "fast",
112507
+ captcha,
112491
112508
  expiresAt: s.expiresAt,
112492
- proxy: s.proxy ?? (a2.proxy ? "NOT APPLIED (check plan/stealth)" : void 0)
112509
+ proxy: s.proxy ?? (a2.proxy ? "NOT APPLIED (check plan)" : void 0)
112510
+ });
112511
+ }
112512
+ },
112513
+ solari_browser_profiles: {
112514
+ description: "List saved browser profiles for this account. A profile is a stored signed-in state (cookies + localStorage) that solari_browser_create({profileId}) replays, so the session starts already logged in.\nCheck here FIRST when a task will need a login: if a profile for that site already exists, use it and no human is involved at all. `version` is 1 and `populated` is false for a profile nobody has signed into yet \u2014 that one still needs solari_browser_login({profileName}).",
112515
+ inputSchema: {},
112516
+ handler: async () => {
112517
+ const res = await api2(cfg, "GET", "/profiles");
112518
+ if (res.status !== 200) await apiError(res, "list profiles");
112519
+ const rows = await res.json();
112520
+ return text({
112521
+ profiles: rows.map((p) => ({
112522
+ profileId: p.id,
112523
+ name: p.name,
112524
+ version: p.version,
112525
+ populated: Boolean(p.storageStateS3Key),
112526
+ lastUsedAt: p.lastUsedAt ?? null
112527
+ }))
112528
+ });
112529
+ }
112530
+ },
112531
+ solari_browser_login: {
112532
+ description: "Ask a HUMAN to sign in, so you never handle credentials yourself. Two ways to call it:\nHOT \u2014 pass `sessionId` when you are ALREADY on a login wall mid-task. The user gets a live view of the exact page you are on and types into it; you resume on that same page.\nCOLD \u2014 pass `profileName` when you know a task will need a login and no session is open yet. The user signs in once in a profile editor, and every later solari_browser_create({profileId}) starts already authenticated. Prefer this when you can: nobody has to be watching mid-run. The profile is created if it does not exist.\nPass exactly one of the two. Either way, SHOW THE RETURNED URL TO THE USER, then call solari_browser_await_login.\nIn the HOT case your access to that session is REVOKED while the handoff is open \u2014 deliberately, so you cannot observe what they type \u2014 and the link expires in 5 minutes. Cold links last longer and revoke nothing, because there is no session yet.\n`reason` is required and is shown to the user \u2014 say plainly which site is asking and what for, because they are being asked to type a password on your say-so.",
112533
+ inputSchema: {
112534
+ sessionId: external_exports.string().optional(),
112535
+ profileName: external_exports.string().optional(),
112536
+ reason: external_exports.string()
112537
+ },
112538
+ handler: async (a2) => {
112539
+ const sessionId = typeof a2.sessionId === "string" ? a2.sessionId : "";
112540
+ const profileName = typeof a2.profileName === "string" ? a2.profileName.trim() : "";
112541
+ if (Boolean(sessionId) === Boolean(profileName)) {
112542
+ throw new Error(
112543
+ "Pass exactly one of sessionId (rescue a live session) or profileName (seed a profile before you start)."
112544
+ );
112545
+ }
112546
+ if (profileName) {
112547
+ const listRes = await api2(cfg, "GET", "/profiles");
112548
+ if (listRes.status !== 200) await apiError(listRes, "list profiles");
112549
+ const rows = await listRes.json();
112550
+ const match = rows.find(
112551
+ (p) => (p.name ?? "").toLowerCase() === profileName.toLowerCase()
112552
+ );
112553
+ let profileId = match?.id ?? "";
112554
+ if (!profileId) {
112555
+ const mk = await api2(cfg, "POST", "/profiles", { name: profileName });
112556
+ if (mk.status !== 200 && mk.status !== 201) await apiError(mk, "create profile");
112557
+ profileId = (await mk.json()).id ?? "";
112558
+ if (!profileId) throw new Error("profile created but no id returned");
112559
+ }
112560
+ const hRes = await api2(cfg, "POST", `/profiles/${encodeURIComponent(profileId)}/login-handoff`, {
112561
+ reason: a2.reason
112562
+ });
112563
+ if (hRes.status !== 200) await apiError(hRes, "cold login request");
112564
+ const h2 = await hRes.json();
112565
+ return text({
112566
+ mode: "cold",
112567
+ profileId,
112568
+ profileName,
112569
+ handoffId: h2.handoffId,
112570
+ url: h2.url,
112571
+ expiresAt: h2.expiresAt,
112572
+ // Echoed so await_login can tell a fresh save from the state it
112573
+ // started in — the version bump IS the completion signal.
112574
+ sinceVersion: h2.version,
112575
+ next: `Show the url to the user, then call solari_browser_await_login({ profileId: "${profileId}", sinceVersion: ${h2.version} }).`
112576
+ });
112577
+ }
112578
+ const e = need(sessionId);
112579
+ const res = await api2(
112580
+ cfg,
112581
+ "POST",
112582
+ `/sessions/${encodeURIComponent(sessionId)}/handoff`,
112583
+ { reason: a2.reason }
112584
+ );
112585
+ if (res.status !== 200) await apiError(res, "handoff request");
112586
+ const h = await res.json();
112587
+ try {
112588
+ await e.browser.close();
112589
+ } catch {
112590
+ }
112591
+ return text({
112592
+ mode: "hot",
112593
+ handoffId: h.handoffId,
112594
+ url: h.url,
112595
+ expiresAt: h.expiresAt,
112596
+ next: "Show the url to the user, then call solari_browser_await_login."
112597
+ });
112598
+ }
112599
+ },
112600
+ solari_browser_save_profile: {
112601
+ description: "Save this session's signed-in state (cookies + localStorage) into an existing profile, so future sessions start already logged in and no human is needed again. Pass the profileId to overwrite.\nONLY do this when the user has asked you to remember the login. It is deliberately not automatic: a saved profile holds live session cookies, which bypass 2FA \u2014 it is a credential store, and it should exist because someone chose it. If they have not said so, ask first.\nReturns what was captured (cookie count and origins) so the user can see what they just persisted.",
112602
+ inputSchema: {
112603
+ sessionId: external_exports.string(),
112604
+ profileId: external_exports.string()
112605
+ },
112606
+ handler: async (a2) => {
112607
+ need(a2.sessionId);
112608
+ const res = await api2(
112609
+ cfg,
112610
+ "POST",
112611
+ `/sessions/${encodeURIComponent(a2.sessionId)}/save-profile`,
112612
+ { profileId: a2.profileId }
112613
+ );
112614
+ if (res.status !== 200) await apiError(res, "save profile");
112615
+ const body = await res.json();
112616
+ return text({
112617
+ ...body,
112618
+ note: "Future sessions can use this with solari_browser_create({profileId})."
112619
+ });
112620
+ }
112621
+ },
112622
+ solari_browser_await_login: {
112623
+ description: "Wait for the human to finish the sign-in you requested with solari_browser_login. Blocks until they are done, the link expires, or timeoutMs elapses. Returns only a status \u2014 never anything the user typed. On 'completed' the session is yours again, on the same page, still signed in, and you can carry on where you left off. On 'expired' or 'timeout' the user did not finish: ask if they still want to, and call solari_browser_login again for a fresh link rather than retrying this.",
112624
+ inputSchema: {
112625
+ sessionId: external_exports.string().optional(),
112626
+ profileId: external_exports.string().optional(),
112627
+ sinceVersion: external_exports.number().optional(),
112628
+ handoffId: external_exports.string().optional(),
112629
+ timeoutMs: external_exports.number().optional()
112630
+ },
112631
+ handler: async (a2) => {
112632
+ const sessionId = typeof a2.sessionId === "string" ? a2.sessionId : "";
112633
+ const profileId = typeof a2.profileId === "string" ? a2.profileId : "";
112634
+ if (Boolean(sessionId) === Boolean(profileId)) {
112635
+ throw new Error(
112636
+ "Pass exactly one of sessionId (hot handoff) or profileId (cold handoff) \u2014 the same one solari_browser_login returned."
112637
+ );
112638
+ }
112639
+ const requested = typeof a2.timeoutMs === "number" ? a2.timeoutMs : 3e5;
112640
+ const deadline = Date.now() + Math.min(Math.max(requested, 5e3), 6e5);
112641
+ let status = "pending";
112642
+ if (profileId) {
112643
+ const since = typeof a2.sinceVersion === "number" ? a2.sinceVersion : 0;
112644
+ let version2 = since;
112645
+ while (Date.now() < deadline) {
112646
+ const res = await api2(cfg, "GET", "/profiles");
112647
+ if (res.status !== 200) await apiError(res, "profile status");
112648
+ const rows = await res.json();
112649
+ const row = rows.find((p) => p.id === profileId);
112650
+ if (!row) throw new Error(`profile ${profileId} no longer exists`);
112651
+ version2 = typeof row.version === "number" ? row.version : since;
112652
+ if (version2 > since) {
112653
+ status = "completed";
112654
+ break;
112655
+ }
112656
+ await new Promise((r) => setTimeout(r, HANDOFF_POLL_MS));
112657
+ }
112658
+ if (status !== "completed") status = "timeout";
112659
+ return text({
112660
+ mode: "cold",
112661
+ status,
112662
+ profileId,
112663
+ version: version2,
112664
+ next: status === "completed" ? `Signed in and saved. Use solari_browser_create({ profileId: "${profileId}" }) and you will start already authenticated.` : "The user did not finish. Ask if they still want to, then call solari_browser_login again for a fresh link."
112665
+ });
112666
+ }
112667
+ const e = need(sessionId);
112668
+ while (Date.now() < deadline) {
112669
+ const q2 = typeof a2.handoffId === "string" && a2.handoffId ? `?handoffId=${encodeURIComponent(a2.handoffId)}` : "";
112670
+ const res = await api2(
112671
+ cfg,
112672
+ "GET",
112673
+ `/sessions/${encodeURIComponent(sessionId)}/handoff${q2}`
112674
+ );
112675
+ if (res.status !== 200) await apiError(res, "handoff status");
112676
+ const body = await res.json();
112677
+ status = body.status ?? "none";
112678
+ if (status !== "pending") break;
112679
+ await new Promise((r) => setTimeout(r, HANDOFF_POLL_MS));
112680
+ }
112681
+ if (status === "pending") status = "timeout";
112682
+ let resumed = false;
112683
+ if (status === "completed" || status === "none") {
112684
+ try {
112685
+ const browser = await deps.connect(e.cdpEndpoint);
112686
+ const pages = await browser.pages();
112687
+ const page = pages.find((p) => !p.isClosed()) ?? await browser.newPage();
112688
+ e.browser = browser;
112689
+ e.page = page;
112690
+ resumed = true;
112691
+ } catch (err) {
112692
+ return text({
112693
+ status,
112694
+ resumed: false,
112695
+ note: "the sign-in finished but the session could not be re-attached: " + redact(err instanceof Error ? err.message : String(err))
112696
+ });
112697
+ }
112698
+ }
112699
+ return text({
112700
+ status,
112701
+ resumed,
112702
+ ...resumed ? { url: e.page.url() } : {}
112493
112703
  });
112494
112704
  }
112495
112705
  },
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@solarisdk/mcp",
3
- "version": "0.3.0",
4
- "description": "Model Context Protocol server for the Solari cloud browser, sandboxes + desktops drive them from Claude Desktop/Cowork, Claude Code, Cursor, etc.",
3
+ "version": "0.3.2",
4
+ "description": "Model Context Protocol server for the Solari cloud browser, sandboxes + desktops \u2014 drive them from Claude Desktop/Cowork, Claude Code, Cursor, etc.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "solari-mcp": "./dist/cli.js"