@oya-ai/browser 1.0.110 → 1.0.113

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
@@ -166,6 +166,21 @@ await browser.ask('Attach my resume to the application and submit it', {
166
166
 
167
167
  A string argument is a path on disk (Node only); a `Blob`, a `File`, or a `Uint8Array` works anywhere. `name` sets the filename the site sees and `type` overrides the MIME guessed from the extension. The ceiling is 10MB per file, and the bytes travel inline with the run. Nothing is stored server-side after it ends.
168
168
 
169
+ ### Data instead of a sentence
170
+
171
+ `extract()` runs the same agent and answers in the shape you ask for, so you do not
172
+ parse prose:
173
+
174
+ ```ts
175
+ const { title, price } = await browser.extract('What is the product on this page?', {
176
+ type: 'object',
177
+ properties: { title: { type: 'string' }, price: { type: 'string' } },
178
+ required: ['title', 'price'],
179
+ });
180
+ ```
181
+
182
+ It throws when the agent reports it could not do the task.
183
+
169
184
  Files work in `data` for `ask()`, `submit()`, and `play()`; `secrets` rejects them, because a file is never typed through a placeholder. A run recorded with `toPlaybook()` keeps the upload as a variable, so the replay takes a different file:
170
185
 
171
186
  ```js
@@ -310,7 +325,7 @@ await browser.goto('https://github.com/trending');
310
325
 
311
326
  // Analyze page: returns markdown and visible numbered elements
312
327
  const { markdown, elements } = await browser.analyze();
313
- console.log(markdown.slice(0, 300));
328
+ console.log((markdown ?? '').slice(0, 300));
314
329
 
315
330
  // Interact using numbered element IDs:
316
331
  const firstRepo = elements.find((el) => el.tag === 'a' && el.href?.includes('/stargazers'));
@@ -422,34 +437,35 @@ const oya = new Oya({
422
437
 
423
438
  ### Browser Instance Methods (`browser.*`)
424
439
 
425
- | Method | Returns | Description |
426
- | :---------------------------------- | :------------------------------------------- | :------------------------------------------------------- |
427
- | `goto(url)` | `Promise<void>` | Navigate to URL (with optional auto-CAPTCHA) |
428
- | `ask(prompt, { data?, secrets? }?)` | `Promise<string>` | Natural-language AI driving using key's configured model |
429
- | `analyze()` | `Promise<Analysis>` | Returns markdown representation and numbered elements |
430
- | `elements()` | `Promise<Element[]>` | Returns only visible interactable elements |
431
- | `click(elementId)` | `Promise<void>` | Click element by numeric ID from `analyze()` |
432
- | `type(elementId, text)` | `Promise<{ suggestions_visible?: boolean }>` | Type text into specified element |
433
- | `pressKey(key)` | `Promise<void>` | Dispatch keyboard key event (e.g. `'Enter'`) |
434
- | `scroll(dir, amount?, at?)` | `Promise<void>` | Scroll `'up' \| 'down' \| 'top' \| 'bottom'` |
435
- | `waitFor(selector, timeout?)` | `Promise<void>` | Wait for DOM selector |
436
- | `screenshot()` | `Promise<string>` | Capture page as base64 image data URL |
437
- | `url()` | `Promise<string>` | Current active tab URL |
438
- | `tabs()` | `Promise<Tab[]>` | List open tabs |
439
- | `openTab(url?)` | `Promise<string>` | Open a new tab |
440
- | `switchTab(tabId)` | `Promise<void>` | Switch active tab |
441
- | `closeTab(tabId)` | `Promise<void>` | Close target tab |
442
- | `solveCaptcha()` | `Promise<CaptchaResult>` | Detect and solve on-screen CAPTCHA |
443
- | `completeMfa()` | `Promise<MfaResult>` | Resolve TOTP/SMS MFA or return `liveViewUrl` |
444
- | `liveViewUrl()` | `string` | Dashboard link for this browser |
445
- | `liveStreamUrl()` | `Promise<string>` | SSE frame stream URL with a single-use ticket |
446
- | `shareUrl(options?)` | `Promise<{ url, id, expiresAt }>` | Expiring browser share link; optional control access |
447
- | `revokeShare(id)` | `Promise<void>` | Revoke a share link |
448
- | `submit(task, options?)` | `Promise<Run>` | Background prompt or playbook with callbacks |
449
- | `toPlaybook(name)` | `Promise<Playbook>` | Save the latest agent flow and export Playwright code |
450
- | `play(name, data?, { autoHeal? }?)` | `Promise<PlayResult>` | Replay a saved flow |
451
- | `status()` | `Promise<BrowserDetail>` | Instance metrics, health, and recent activity log |
452
- | `stop()` | `Promise<StopResult>` | Tear down sandbox and release CDP session |
440
+ | Method | Returns | Description |
441
+ | :---------------------------------------------- | :------------------------------------------- | :------------------------------------------------------- |
442
+ | `goto(url)` | `Promise<void>` | Navigate to URL (with optional auto-CAPTCHA) |
443
+ | `ask(prompt, { data?, secrets? }?)` | `Promise<string>` | Natural-language AI driving using key's configured model |
444
+ | `extract(prompt, schema, { data?, secrets? }?)` | `Promise<T>` | The same, answered as data in the shape of a JSON schema |
445
+ | `analyze()` | `Promise<Analysis>` | Returns markdown representation and numbered elements |
446
+ | `elements()` | `Promise<Element[]>` | Returns only visible interactable elements |
447
+ | `click(elementId)` | `Promise<void>` | Click element by numeric ID from `analyze()` |
448
+ | `type(elementId, text)` | `Promise<{ suggestions_visible?: boolean }>` | Type text into specified element |
449
+ | `pressKey(key)` | `Promise<void>` | Dispatch keyboard key event (e.g. `'Enter'`) |
450
+ | `scroll(dir, amount?, at?)` | `Promise<void>` | Scroll `'up' \| 'down' \| 'top' \| 'bottom'` |
451
+ | `waitFor(selector, timeout?)` | `Promise<void>` | Wait for DOM selector |
452
+ | `screenshot()` | `Promise<string>` | Capture page as base64 image data URL |
453
+ | `url()` | `Promise<string>` | Current active tab URL |
454
+ | `tabs()` | `Promise<Tab[]>` | List open tabs |
455
+ | `openTab(url?)` | `Promise<string>` | Open a new tab |
456
+ | `switchTab(tabId)` | `Promise<void>` | Switch active tab |
457
+ | `closeTab(tabId)` | `Promise<void>` | Close target tab |
458
+ | `solveCaptcha()` | `Promise<CaptchaResult>` | Detect and solve on-screen CAPTCHA |
459
+ | `completeMfa()` | `Promise<MfaResult>` | Resolve TOTP/SMS MFA or return `liveViewUrl` |
460
+ | `liveViewUrl()` | `string` | Dashboard link for this browser |
461
+ | `liveStreamUrl()` | `Promise<string>` | SSE frame stream URL with a single-use ticket |
462
+ | `shareUrl(options?)` | `Promise<{ url, id, expiresAt }>` | Expiring browser share link; optional control access |
463
+ | `revokeShare(id)` | `Promise<void>` | Revoke a share link |
464
+ | `submit(task, options?)` | `Promise<Run>` | Background prompt or playbook with callbacks |
465
+ | `toPlaybook(name)` | `Promise<Playbook>` | Save the latest agent flow and export Playwright code |
466
+ | `play(name, data?, { autoHeal? }?)` | `Promise<PlayResult>` | Replay a saved flow |
467
+ | `status()` | `Promise<BrowserDetail>` | Instance metrics, health, and recent activity log |
468
+ | `stop()` | `Promise<StopResult>` | Tear down sandbox and release CDP session |
453
469
 
454
470
  ### Profile and persona management (`oya.profiles`, `oya.personas`)
455
471
 
@@ -468,10 +484,10 @@ const oya = new Oya({
468
484
  | `remove(id)` | Delete persona and associated cookie jar |
469
485
  | `setMfa(id, config)` | Store TOTP secret (sealed at rest with AES-256-GCM) |
470
486
  | `clearMfa(id)` | Remove MFA secret from persona |
471
- | `cookies(id, format?)` | Export the persona's logins; `'playwright'` fits `addCookies()` |
472
- | `importCookies(id, cookies)` | Merge cookies into the jar (from a file, a script, anywhere) |
473
- | `copyCookies(from, to)` | Copy one persona's logins into another |
474
- | `clearCookies(id)` | Forget every cookie: signs the persona out everywhere |
487
+ | `cookies(id, format?)` | Export the persona's logins; `'playwright'` fits `addCookies()` |
488
+ | `importCookies(id, cookies)` | Merge cookies into the jar (from a file, a script, anywhere) |
489
+ | `copyCookies(from, to)` | Copy one persona's logins into another |
490
+ | `clearCookies(id)` | Forget every cookie: signs the persona out everywhere |
475
491
 
476
492
  ### Proxies (`oya.proxies`)
477
493
 
package/dist/index.cjs CHANGED
@@ -30,6 +30,47 @@ __export(index_exports, {
30
30
  });
31
31
  module.exports = __toCommonJS(index_exports);
32
32
 
33
+ // src/constants.ts
34
+ var DEFAULT_BASE_URL = "https://oyabrowser.com";
35
+ var DEFAULT_TIMEOUT_MS = 6e4;
36
+ var START_TIMEOUT_MS = 12e4;
37
+ var READY_TIMEOUT_MS = 12e4;
38
+ var READY_POLL_MS = 2e3;
39
+ var NAVIGATE_TIMEOUT_MS = 12e4;
40
+ var CHALLENGE_TIMEOUT_MS = 18e4;
41
+ var AGENT_TIMEOUT_MS = 6e5;
42
+ var PLAYBOOK_TIMEOUT_MS = 12e4;
43
+ var STOP_TIMEOUT_MS = 6e4;
44
+ var WAIT_FOR_DEFAULT_MS = 3e4;
45
+ var WAIT_FOR_GRACE_MS = 5e3;
46
+ var AIMED_SCROLL_AMOUNT = 500;
47
+ var RUN_POLL_MS = 2e3;
48
+ var MAX_RUN_POLL_ERRORS = 5;
49
+ var BYTES_PER_MB = 1048576;
50
+ var MAX_FILE_MB = 10;
51
+ var BASE64_CHUNK_BYTES = 8192;
52
+ var MS_PER_SECOND = 1e3;
53
+ var LATIN1_MAX = 255;
54
+ var SPACE = 32;
55
+ var TAB = 9;
56
+ var DEL = 127;
57
+ var HEX = 16;
58
+ var CODE_POINT_DIGITS = 4;
59
+ var Status = {
60
+ /** The caller passed something unusable, such as a non-numeric element id. */
61
+ BAD_REQUEST: 400,
62
+ /** The server does not know that session. */
63
+ NOT_FOUND: 404,
64
+ /** The browser is in a state that needs attention first. */
65
+ CONFLICT: 409,
66
+ /** A browser command ran and failed. */
67
+ UNPROCESSABLE: 422,
68
+ /** A run failed without saying why. */
69
+ SERVER_ERROR: 500,
70
+ /** A browser did not come up in time. */
71
+ GATEWAY_TIMEOUT: 504
72
+ };
73
+
33
74
  // src/errors.ts
34
75
  var OyaError = class extends Error {
35
76
  /** The HTTP status, or the SDK's own status for errors it raises itself. */
@@ -44,6 +85,10 @@ var OyaError = class extends Error {
44
85
  this.body = body;
45
86
  }
46
87
  };
88
+ function refusal(message, field, suggestion) {
89
+ const body = { error: message, code: "invalid_request", field, ...suggestion ? { suggestion } : {} };
90
+ return new OyaError(message, Status.BAD_REQUEST, body);
91
+ }
47
92
 
48
93
  // src/cli-config.ts
49
94
  var node = () => globalThis.process;
@@ -67,58 +112,26 @@ function savedConfig() {
67
112
  }
68
113
  }
69
114
 
70
- // src/constants.ts
71
- var DEFAULT_BASE_URL = "https://oyabrowser.com";
72
- var DEFAULT_TIMEOUT_MS = 6e4;
73
- var START_TIMEOUT_MS = 12e4;
74
- var READY_TIMEOUT_MS = 12e4;
75
- var READY_POLL_MS = 2e3;
76
- var NAVIGATE_TIMEOUT_MS = 12e4;
77
- var CHALLENGE_TIMEOUT_MS = 18e4;
78
- var AGENT_TIMEOUT_MS = 6e5;
79
- var PLAYBOOK_TIMEOUT_MS = 12e4;
80
- var STOP_TIMEOUT_MS = 6e4;
81
- var WAIT_FOR_DEFAULT_MS = 3e4;
82
- var WAIT_FOR_GRACE_MS = 5e3;
83
- var AIMED_SCROLL_AMOUNT = 500;
84
- var RUN_POLL_MS = 2e3;
85
- var MAX_RUN_POLL_ERRORS = 5;
86
- var BYTES_PER_MB = 1048576;
87
- var MAX_FILE_MB = 10;
88
- var BASE64_CHUNK_BYTES = 8192;
89
- var MS_PER_SECOND = 1e3;
90
- var Status = {
91
- /** The caller passed something unusable, such as a non-numeric element id. */
92
- BAD_REQUEST: 400,
93
- /** The server does not know that session. */
94
- NOT_FOUND: 404,
95
- /** The browser is in a state that needs attention first. */
96
- CONFLICT: 409,
97
- /** A browser command ran and failed. */
98
- UNPROCESSABLE: 422,
99
- /** A run failed without saying why. */
100
- SERVER_ERROR: 500,
101
- /** A browser did not come up in time. */
102
- GATEWAY_TIMEOUT: 504
103
- };
104
-
105
115
  // src/client.ts
116
+ var OPTIONS_ORIGIN = { apiKeyFrom: "the apiKey option", baseUrlFrom: "the baseUrl option", savedKey: false };
106
117
  var Http = class {
107
- /** Stores where to call, with which key, how long to wait and which fetch to use. */
108
- constructor(baseUrl, apiKey, timeoutMs, fetchImpl) {
118
+ /** Stores where to call, with which key, how long to wait, which fetch to use, and where the key and address came from. */
119
+ constructor(baseUrl, apiKey, timeoutMs, fetchImpl, origin = OPTIONS_ORIGIN) {
109
120
  this.baseUrl = baseUrl;
110
121
  this.apiKey = apiKey;
111
122
  this.timeoutMs = timeoutMs;
112
123
  this.fetchImpl = fetchImpl;
124
+ this.origin = origin;
113
125
  }
114
126
  baseUrl;
115
127
  apiKey;
116
128
  timeoutMs;
117
129
  fetchImpl;
130
+ origin;
118
131
  /** Sends one request and returns the parsed answer, or throws an OyaError when it failed. */
119
132
  async request(method, path, body, timeoutMs = this.timeoutMs, headers = {}) {
120
133
  const res = await this.send(`${this.baseUrl}${path}`, this.init(method, body, timeoutMs, headers), timeoutMs);
121
- return readAnswer(res, `${method} ${path}`, this.baseUrl);
134
+ return readAnswer(res, `${method} ${path}`, this);
122
135
  }
123
136
  /** The fetch options for one call, with this client's key on them. */
124
137
  init(method, body, timeoutMs, headers) {
@@ -133,23 +146,34 @@ var Http = class {
133
146
  try {
134
147
  return await this.fetchImpl(url, init);
135
148
  } catch (err) {
136
- throw new OyaError(unreachable(this.baseUrl, timeoutMs, err), 0, { error: String(err?.message) });
149
+ throw noAnswer(this, timeoutMs, err);
137
150
  }
138
151
  }
139
152
  };
140
- function unreachable(baseUrl, timeoutMs, err) {
141
- const cause = err?.name === "TimeoutError" || /abort/i.test(String(err?.message));
142
- if (cause) return `No answer from ${baseUrl} within ${timeoutMs}ms. Is it reachable, and is the call this slow?`;
143
- return `Could not reach ${baseUrl}. Is the server running, and is OYA_BASE_URL right?`;
153
+ var timedOut = (err) => err?.name === "TimeoutError" || /abort/i.test(String(err?.message));
154
+ function causeOf(err) {
155
+ const cause = err?.cause;
156
+ return cause?.code ?? cause?.errors?.[0]?.code;
157
+ }
158
+ function noAnswer(http, timeoutMs, err) {
159
+ const code = timedOut(err) ? "timeout" : "unreachable";
160
+ const body = { error: String(err?.message), code, ...causeOf(err) ? { cause: causeOf(err) } : {} };
161
+ return new OyaError(unreachable(http, timeoutMs, code), 0, body);
162
+ }
163
+ function unreachable({ baseUrl, origin }, timeoutMs, code) {
164
+ if (code === "timeout")
165
+ return `No answer from ${baseUrl} within ${timeoutMs}ms. Is it reachable, and is the call this slow?`;
166
+ if (origin.baseUrlFrom === "default") return `Could not reach ${baseUrl}. Check your connection.`;
167
+ return `Could not reach ${baseUrl}. Is the server running, and is ${origin.baseUrlFrom} right?`;
144
168
  }
145
169
  function requestInit(method, body, timeoutMs, headers) {
146
170
  const json = body === void 0 ? {} : { "Content-Type": "application/json" };
147
171
  const payload = body === void 0 ? void 0 : JSON.stringify(body);
148
172
  return { method, headers: { ...headers, ...json }, body: payload, signal: AbortSignal.timeout(timeoutMs) };
149
173
  }
150
- async function readAnswer(res, call2, baseUrl) {
174
+ async function readAnswer(res, call2, http) {
151
175
  const payload = parseBody(await res.text());
152
- if (!res.ok) throw failure(call2, res.status, payload, baseUrl);
176
+ if (!res.ok) throw failure(call2, res.status, payload, http);
153
177
  return payload;
154
178
  }
155
179
  function parseBody(text) {
@@ -159,22 +183,74 @@ function parseBody(text) {
159
183
  return text;
160
184
  }
161
185
  }
162
- function failure(call2, status, payload, baseUrl) {
163
- let message = payload?.error || `${call2} failed (${status})`;
164
- if (message === "Invalid API key") {
165
- message += ` for ${baseUrl}. Check OYA_API_KEY: a value exported in your shell beats .env.`;
166
- }
167
- return new OyaError(message, status, payload);
186
+ function failure(call2, status, payload, http) {
187
+ const message = payload?.error || `${call2} failed (${status})`;
188
+ return new OyaError(message === "Invalid API key" ? invalidKey(http) : message, status, payload);
189
+ }
190
+ function invalidKey({ baseUrl, origin }) {
191
+ const overrides = origin.apiKeyFrom === "OYA_API_KEY" && origin.savedKey;
192
+ const rest = overrides ? ", which overrides the one saved in ~/.oya/config.json" : "";
193
+ return `Invalid API key for ${baseUrl}. The key came from ${origin.apiKeyFrom}${rest}.`;
168
194
  }
169
195
  var env = (name) => globalThis.process?.env?.[name];
196
+ function firstOf(option, name, saved, optionName) {
197
+ if (option) return { value: option, from: `the ${optionName} option` };
198
+ if (env(name)) return { value: env(name), from: name };
199
+ return { value: saved, from: "~/.oya/config.json" };
200
+ }
201
+ function addressOf(options, saved) {
202
+ const found = firstOf(options.baseUrl, "OYA_BASE_URL", saved, "baseUrl");
203
+ return found.value ? found : { value: DEFAULT_BASE_URL, from: "default" };
204
+ }
205
+ function keyOf(options, saved) {
206
+ const found = firstOf(options.apiKey, "OYA_API_KEY", saved, "apiKey");
207
+ if (!found.value) throw new Error("No API key. Pass { apiKey }, set OYA_API_KEY, or run `npx @oya-ai/cli login`.");
208
+ return { ...found, value: headerSafe(found.value, "apiKey", "Copy the key again.") };
209
+ }
170
210
  function createHttp(options) {
171
211
  const saved = savedConfig();
172
- const apiKey = options.apiKey || env("OYA_API_KEY") || saved.apiKey;
173
- if (!apiKey) throw new Error("No API key. Pass { apiKey }, set OYA_API_KEY, or run `npx @oya-ai/cli login`.");
174
- const baseUrl = (options.baseUrl || env("OYA_BASE_URL") || saved.baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, "");
212
+ const key = keyOf(options, saved.apiKey);
213
+ const address = addressOf(options, saved.baseUrl);
175
214
  const fetchImpl = options.fetch || globalThis.fetch;
176
215
  if (!fetchImpl) throw new Error("No fetch available, pass { fetch } or use Node 18+.");
177
- return new Http(baseUrl, apiKey, options.timeoutMs ?? DEFAULT_TIMEOUT_MS, fetchImpl.bind(globalThis));
216
+ const origin = { apiKeyFrom: key.from, baseUrlFrom: address.from, savedKey: !!saved.apiKey };
217
+ const timeout = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
218
+ return new Http(webAddress(address), key.value, timeout, fetchImpl.bind(globalThis), origin);
219
+ }
220
+ var LOCAL_HOST = /^(localhost|127\.)/i;
221
+ function webAddress({ value, from }) {
222
+ const url = value.trim().replace(/\/+$/, "");
223
+ if (/^https?:\/\//i.test(url)) return url;
224
+ const name = from.startsWith("the ") ? "baseUrl" : from === "OYA_BASE_URL" ? from : `baseUrl in ${from}`;
225
+ const suggestion = suggestionFor(url);
226
+ const tryIt = suggestion ? ` Try ${suggestion}.` : "";
227
+ throw refusal(`${name} must start with http:// or https://, not "${url}".${tryIt}`, "baseUrl", suggestion);
228
+ }
229
+ function suggestionFor(url) {
230
+ if (/^[a-z][a-z0-9+.-]*:/i.test(url) && !/^[^:/]+:\d+(\/|$)/.test(url)) return void 0;
231
+ return `${LOCAL_HOST.test(url) ? "http" : "https"}://${url}`;
232
+ }
233
+ function notInAHeader(ch) {
234
+ const code = ch.codePointAt(0);
235
+ return code < SPACE && code !== TAB || code === DEL || code > LATIN1_MAX;
236
+ }
237
+ function headerSafe(value, field, ending) {
238
+ const trimmed = value.trim();
239
+ const chars = Array.from(trimmed);
240
+ const at = chars.findIndex(notInAHeader);
241
+ if (at < 0) return trimmed;
242
+ const leading = Array.from(value).length - Array.from(value.trimStart()).length;
243
+ const where = `position ${leading + at + 1} (${codePoint(chars[at])})`;
244
+ throw refusal(`${field} has a character HTTP headers cannot carry at ${where}. ${ending}`, field);
245
+ }
246
+ var codePoint = (ch) => `U+${ch.codePointAt(0).toString(HEX).toUpperCase().padStart(CODE_POINT_DIGITS, "0")}`;
247
+ var shown = (value) => typeof value === "string" ? JSON.stringify(value) : String(value);
248
+ function segment(value, field = "id") {
249
+ if (typeof value !== "string" || !value)
250
+ throw refusal(`${field} must be a non-empty string, not ${shown(value)}`, field);
251
+ if (value.includes("/") || value === "." || value === "..")
252
+ throw refusal(`${field} must be a single path segment (no "/" and not "." or ".."), not ${shown(value)}`, field);
253
+ return encodeURIComponent(value);
178
254
  }
179
255
 
180
256
  // src/run-watch.ts
@@ -396,6 +472,17 @@ var Browser = class {
396
472
  const path = `/api/browsers/${this.id}/chat`;
397
473
  return agentAnswer(await this.http.request("POST", path, body, AGENT_TIMEOUT_MS)).text;
398
474
  }
475
+ /**
476
+ * `ask()` for data: the agent does the task and answers in the shape of `schema`
477
+ * (a JSON schema), instead of in words. Throws when the agent reports it could not.
478
+ */
479
+ async extract(prompt, schema, values = {}) {
480
+ const body = { messages: [{ role: "user", content: prompt }], ...values, schema };
481
+ const path = `/api/browsers/${this.id}/chat`;
482
+ const res = agentAnswer(await this.http.request("POST", path, body, AGENT_TIMEOUT_MS));
483
+ if (res.failed || res.data === void 0) throw new OyaError(res.text, Status.UNPROCESSABLE, res);
484
+ return res.data;
485
+ }
399
486
  /**
400
487
  * Save the last `ask()` on this browser as a named playbook. Every value that was
401
488
  * typed, picked or clicked becomes a variable, with what the run used kept in
@@ -413,7 +500,7 @@ var Browser = class {
413
500
  * Play `'<name>:draft'` to try a draft before promoting it.
414
501
  */
415
502
  async play(name, data = {}, { autoHeal = true } = {}) {
416
- const path = `/api/browsers/${this.id}/playbooks/${encodeURIComponent(name)}/play`;
503
+ const path = `/api/browsers/${this.id}/playbooks/${segment(name, "name")}/play`;
417
504
  return agentAnswer(
418
505
  await this.http.request("POST", path, { variables: data, autoHeal }, AGENT_TIMEOUT_MS)
419
506
  );
@@ -464,7 +551,7 @@ var Browser = class {
464
551
  }
465
552
  /** Revoke a link from `shareUrl()` before it expires, by the id it returned. */
466
553
  async revokeShare(id) {
467
- await this.http.request("DELETE", `/api/control/credentials/${encodeURIComponent(id)}`);
554
+ await this.http.request("DELETE", `/api/control/credentials/${segment(id)}`);
468
555
  }
469
556
  /** Counters, health and the last 50 things this browser did. */
470
557
  status() {
@@ -520,8 +607,9 @@ function startBody(options) {
520
607
  const { profile, persona, provider, wsUrl, name, queueMs, priority, budgetUsd, governed, policy } = options;
521
608
  return { profile: profile || persona, provider, wsUrl, name, queueMs, priority, budgetUsd, governed, policy };
522
609
  }
610
+ var idempotencyKeyOf = ({ idempotencyKey }) => idempotencyKey ? headerSafe(idempotencyKey, "idempotencyKey", 'Use letters, digits, "-" and "_".') : globalThis.crypto.randomUUID();
523
611
  async function start(http, wait, options) {
524
- const headers = { "Idempotency-Key": options.idempotencyKey || globalThis.crypto.randomUUID() };
612
+ const headers = { "Idempotency-Key": idempotencyKeyOf(options) };
525
613
  const path = "/api/browsers/start";
526
614
  const started = await http().request("POST", path, startBody(options), START_TIMEOUT_MS, headers);
527
615
  if (started.status === "starting") {
@@ -530,7 +618,7 @@ async function start(http, wait, options) {
530
618
  }
531
619
  return new Browser(http(), started, options.captcha === "auto");
532
620
  }
533
- var fetchBrowser = (http, id) => http().request("GET", `/api/browsers/${encodeURIComponent(id)}`);
621
+ var fetchBrowser = (http, id) => http().request("GET", `/api/browsers/${segment(id)}`);
534
622
  async function reattach(http, id) {
535
623
  const found = await fetchBrowser(http, id);
536
624
  return new Browser(http(), asStarted(found), false);
@@ -542,69 +630,79 @@ function asStarted(found) {
542
630
  var stopBrowsers = (http, ids) => http().request("POST", "/api/browsers/stop", ids === "all" ? { all: true } : { ids }, START_TIMEOUT_MS);
543
631
  var browserApi = (http, wait) => ({
544
632
  /** Start a browser and wait until it can take commands. */
545
- start: (options = {}) => start(http, wait, options),
633
+ start: async (options = {}) => start(http, wait, options),
546
634
  /** Reattach to a browser that is already running. */
547
- get: (id) => reattach(http, id),
635
+ get: async (id) => reattach(http, id),
548
636
  /** Every browser on this key. */
549
- list: () => http().request("GET", "/api/browsers"),
637
+ list: async () => http().request("GET", "/api/browsers"),
550
638
  /** Stop some (`ids`) or every browser on this key. Each reports separately. */
551
- stop: (ids) => stopBrowsers(http, ids),
639
+ stop: async (ids) => stopBrowsers(http, ids),
552
640
  /** Stop every browser on this key; returns how many stopped. */
553
641
  stopAll: async () => (await stopBrowsers(http, "all")).stopped
554
642
  });
555
643
 
556
644
  // src/api/control.ts
557
- var session = (id, action = "") => `/api/control/sessions/${encodeURIComponent(id)}${action}`;
558
- var item = (kind, id) => `/api/control/${kind}/${encodeURIComponent(id)}`;
645
+ var session = (id, action = "") => `/api/control/sessions/${segment(id)}${action}`;
646
+ var item = (kind, id) => `/api/control/${kind}/${segment(id)}`;
559
647
  var sessionCalls = (http) => ({
560
648
  /** Settings, sessions and recent events at a glance. */
561
- overview: () => http().request("GET", "/api/control"),
649
+ overview: async () => http().request("GET", "/api/control"),
562
650
  /** Every session, including disconnected and cleanup-pending ones. */
563
- sessions: () => http().request("GET", "/api/control/sessions"),
651
+ sessions: async () => http().request("GET", "/api/control/sessions"),
564
652
  /** One session. */
565
- session: (id) => http().request("GET", session(id)),
653
+ session: async (id) => http().request("GET", session(id)),
566
654
  /** Update limits, rate cards and retention. */
567
- settings: (changes) => http().request("PATCH", "/api/control/project", changes)
655
+ settings: async (changes) => http().request("PATCH", "/api/control/project", changes)
568
656
  });
569
657
  var lifecycleCalls = (http) => ({
570
- /** Cancel queued or provisioning work. */
571
- cancel: (id) => http().request("POST", session(id, "/cancel"), {}),
658
+ /** Stop a session in any state; answers its final state. */
659
+ cancel: async (id) => http().request("POST", session(id, "/cancel"), {}),
572
660
  /** Stop a session; `force` stops despite a profile-save error, or reconciles. */
573
- stop: (id, force = false) => http().request("POST", session(id, "/stop"), { force }),
661
+ stop: async (id, force = false) => http().request("POST", session(id, "/stop"), { force }),
574
662
  /** Acquire or release human control, or acknowledge the agent's resume. */
575
- takeover: (id, action) => http().request("POST", session(id, "/control"), { action }),
663
+ takeover: async (id, action) => http().request("POST", session(id, "/control"), { action }),
576
664
  /** Send one input as the human holding the control lease. */
577
- input: (id, action, params) => http().request("POST", session(id, "/input"), { action, params })
665
+ input: async (id, action, params) => http().request("POST", session(id, "/input"), { action, params })
578
666
  });
667
+ function recoverBody(options) {
668
+ const { replace = false, wsUrl } = typeof options === "boolean" ? { replace: options } : options ?? {};
669
+ if (wsUrl && !replace)
670
+ throw refusal("wsUrl is only used with replace: true; recovering in place keeps the endpoint it had.", "wsUrl");
671
+ return wsUrl ? { replace, wsUrl } : { replace };
672
+ }
579
673
  var recoveryCalls = (http) => ({
580
- /** Explicitly recover a session, or replace it with a fresh one. */
581
- recover: (id, replace = false) => http().request("POST", session(id, "/recover"), { replace }),
674
+ /**
675
+ * Explicitly recover a session, or replace it with a fresh one. A cdp
676
+ * session's replacement needs the Chrome it runs on: `{ replace: true, wsUrl }`.
677
+ * `recover(id, true)` still means replace.
678
+ */
679
+ recover: async (id, options = {}) => http().request("POST", session(id, "/recover"), recoverBody(options)),
582
680
  /** A single-use ticket for the live stream. */
583
- ticket: (id) => http().request("POST", session(id, "/ticket"), {}),
681
+ ticket: async (id) => http().request("POST", session(id, "/ticket"), {}),
584
682
  /** Durable lifecycle events after a cursor. */
585
- events: (after = 0) => http().request("GET", `/api/control/events?after=${after}`)
683
+ events: async (after = 0) => http().request("GET", `/api/control/events?after=${after}`)
586
684
  });
587
685
  var credentialCalls = (http) => ({
588
686
  /** Mint a service credential. Its token is returned this once. */
589
- createCredential: (options) => http().request("POST", "/api/control/credentials", options),
687
+ createCredential: async (options) => http().request("POST", "/api/control/credentials", options),
590
688
  /** Revoke a service credential. */
591
- revokeCredential: (id) => http().request("DELETE", item("credentials", id))
689
+ revokeCredential: async (id) => http().request("DELETE", item("credentials", id))
592
690
  });
593
691
  var memberCalls = (http) => ({
594
692
  /** The owner and every member. */
595
- members: () => http().request("GET", "/api/control/members"),
693
+ members: async () => http().request("GET", "/api/control/members"),
596
694
  /** An invitation code for a new member. */
597
- inviteMember: (role = "operator") => http().request("POST", "/api/control/members/invite", { role }),
695
+ inviteMember: async (role = "operator") => http().request("POST", "/api/control/members/invite", { role }),
598
696
  /** Remove a member. */
599
- removeMember: (userId) => http().request("DELETE", item("members", userId))
697
+ removeMember: async (userId) => http().request("DELETE", item("members", userId))
600
698
  });
601
699
  var webhookCalls = (http) => ({
602
700
  /** Register a webhook; `types` limits which events it receives. */
603
- createWebhook: (url, types = []) => http().request("POST", "/api/control/webhooks", { url, types }),
701
+ createWebhook: async (url, types = []) => http().request("POST", "/api/control/webhooks", { url, types }),
604
702
  /** Disable a webhook. */
605
- removeWebhook: (id) => http().request("DELETE", item("webhooks", id)),
703
+ removeWebhook: async (id) => http().request("DELETE", item("webhooks", id)),
606
704
  /** Send a delivery again. */
607
- replayDelivery: (id) => http().request("POST", `${item("deliveries", id)}/replay`, {})
705
+ replayDelivery: async (id) => http().request("POST", `${item("deliveries", id)}/replay`, {})
608
706
  });
609
707
  var controlApi = (http) => ({
610
708
  ...sessionCalls(http),
@@ -616,7 +714,7 @@ var controlApi = (http) => ({
616
714
  });
617
715
 
618
716
  // src/api/playbooks.ts
619
- var playbook = (name) => `/api/playbooks/${encodeURIComponent(name)}`;
717
+ var playbook = (name) => `/api/playbooks/${segment(name, "name")}`;
620
718
  var playbookApi = (http) => ({
621
719
  /** Every saved playbook, with any draft waiting on it. */
622
720
  list: async () => (await http().request("GET", "/api/playbooks")).playbooks,
@@ -625,7 +723,7 @@ var playbookApi = (http) => ({
625
723
  await http().request("DELETE", playbook(name));
626
724
  },
627
725
  /** Replace a playbook with the draft a healed replay saved. Try it first with `browser.play('<name>:draft')`. */
628
- promote: (name) => http().request("POST", `${playbook(name)}/promote`, {})
726
+ promote: async (name) => http().request("POST", `${playbook(name)}/promote`, {})
629
727
  });
630
728
 
631
729
  // src/api/proxies.ts
@@ -633,10 +731,10 @@ var proxyApi = (http) => ({
633
731
  /** Every proxy this key can use, shared ones included. */
634
732
  list: async () => (await http().request("GET", "/api/proxies")).proxies,
635
733
  /** Add a proxy. Its credentials are never read back. */
636
- create: (proxy) => http().request("POST", "/api/proxies", proxy),
734
+ create: async (proxy) => http().request("POST", "/api/proxies", proxy),
637
735
  /** Remove one of this key's proxies. */
638
736
  remove: async (id) => {
639
- await http().request("DELETE", `/api/proxies/${encodeURIComponent(id)}`);
737
+ await http().request("DELETE", `/api/proxies/${segment(id)}`);
640
738
  },
641
739
  /** Dial each proxy and learn its real exit IP. Failing ones cool down and are skipped. */
642
740
  check: async () => (await http().request("POST", "/api/proxies/check", {})).results
@@ -647,35 +745,38 @@ var identityCalls = (http) => ({
647
745
  /** Every persona on this key. */
648
746
  list: async () => (await http().request("GET", "/api/personas")).personas,
649
747
  /** One persona. */
650
- get: (id) => http().request("GET", `/api/personas/${id}`),
748
+ get: async (id) => http().request("GET", `/api/personas/${segment(id)}`),
651
749
  /**
652
750
  * Create an identity. The device, platform, timezone, locale, is chosen
653
751
  * here and fixed for its life; `preview()` shows what a choice produces.
654
752
  */
655
- create: (options = {}) => http().request("POST", "/api/personas", options),
753
+ create: async (options = {}) => http().request("POST", "/api/personas", options),
656
754
  /** Name, concurrency cap and proxy hint. Never the device, clone for that. */
657
- update: (id, changes) => http().request("PUT", `/api/personas/${id}`, changes),
755
+ update: async (id, changes) => http().request("PUT", `/api/personas/${segment(id)}`, changes),
658
756
  /** A new persona of the same kind of device: same choices, fresh identity, empty jar. */
659
- clone: (id, options = {}) => http().request("POST", `/api/personas/${id}/clone`, options)
757
+ clone: async (id, options = {}) => http().request("POST", `/api/personas/${segment(id)}/clone`, options)
660
758
  });
661
759
  var deviceCalls = (http) => ({
662
760
  /** The fingerprint these choices would produce. Persists nothing. */
663
761
  preview: async (prefs = {}) => (await http().request("POST", "/api/personas/preview", { prefs })).fingerprint,
664
762
  /** Platforms, and the timezones and locales each may coherently claim. */
665
- options: () => http().request("GET", "/api/personas/options"),
763
+ options: async () => http().request("GET", "/api/personas/options"),
666
764
  /** Pin the persona to one of your proxies, or `null` to let assignment happen at connect. */
667
- pinProxy: (id, proxyId) => http().request("PUT", `/api/personas/${id}/proxy`, { proxyId }),
765
+ pinProxy: async (id, proxyId) => http().request("PUT", `/api/personas/${segment(id)}/proxy`, { proxyId }),
668
766
  /** Delete a persona. */
669
767
  remove: async (id) => {
670
- await http().request("DELETE", `/api/personas/${id}`);
768
+ await http().request("DELETE", `/api/personas/${segment(id)}`);
671
769
  }
672
770
  });
673
771
  var mfaCalls = (http) => ({
674
772
  /** Store the second factor for this identity. Sealed at rest, never read back. */
675
- setMfa: (id, config) => http().request("PUT", `/api/personas/${id}/mfa`, config),
773
+ setMfa: async (id, config) => http().request("PUT", `/api/personas/${segment(id)}/mfa`, config),
676
774
  /** Remove the persona-wide factor, or the one filed against `domain`. */
677
775
  clearMfa: async (id, domain) => {
678
- await http().request("DELETE", `/api/personas/${id}/mfa${domain ? `?domain=${encodeURIComponent(domain)}` : ""}`);
776
+ await http().request(
777
+ "DELETE",
778
+ `/api/personas/${segment(id)}/mfa${domain ? `?domain=${encodeURIComponent(domain)}` : ""}`
779
+ );
679
780
  }
680
781
  });
681
782
  var loginCalls = (http) => ({
@@ -686,20 +787,20 @@ var loginCalls = (http) => ({
686
787
  * better path. This is for portals that expire a session server-side
687
788
  * between runs, where an unattended run has nothing else to recover with.
688
789
  */
689
- setCredentials: (id, config) => http().request("PUT", `/api/personas/${id}/credentials`, config),
790
+ setCredentials: async (id, config) => http().request("PUT", `/api/personas/${segment(id)}/credentials`, config),
690
791
  /** Which sites this identity can sign in to. Usernames only. */
691
- credentials: (id) => http().request("GET", `/api/personas/${id}/credentials`),
792
+ credentials: async (id) => http().request("GET", `/api/personas/${segment(id)}/credentials`),
692
793
  /** Remove the login stored for one site. */
693
794
  clearCredentials: async (id, domain) => {
694
- await http().request("DELETE", `/api/personas/${id}/credentials?domain=${encodeURIComponent(domain)}`);
795
+ await http().request("DELETE", `/api/personas/${segment(id)}/credentials?domain=${encodeURIComponent(domain)}`);
695
796
  }
696
797
  });
697
- var jarPath = (id) => `/api/pool/cookies?persona=${encodeURIComponent(id)}`;
798
+ var jarPath = (id) => `/api/pool/cookies?persona=${segment(id)}`;
698
799
  var jarCalls = (http) => ({
699
800
  /** Every cookie in the jar; `format: 'playwright'` is ready for `context.addCookies()`. */
700
- cookies: async (id, format = "json") => (await http().request("GET", `${jarPath(id)}&format=${format}`)).cookies,
801
+ cookies: async (id, format = "json") => (await http().request("GET", `${jarPath(id)}&format=${encodeURIComponent(format)}`)).cookies,
701
802
  /** Merge cookies into the jar. The persona's browsers pick them up on their next visit to each site. */
702
- importCookies: (id, list) => http().request("PUT", jarPath(id), { cookies: list })
803
+ importCookies: async (id, list) => http().request("PUT", jarPath(id), { cookies: list })
703
804
  });
704
805
  var cookieCalls = (http) => ({
705
806
  ...jarCalls(http),