@velanir/openclaw-browserbase 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,1173 @@
1
+ // src/api.ts
2
+ import { definePluginEntry as defineOpenClawPluginEntry } from "openclaw/plugin-sdk/plugin-entry";
3
+ function definePluginEntry(entry) {
4
+ return defineOpenClawPluginEntry(entry);
5
+ }
6
+
7
+ // src/broker.ts
8
+ import { createHash, randomUUID, timingSafeEqual } from "crypto";
9
+ import { createServer } from "http";
10
+ import { WebSocket, WebSocketServer } from "ws";
11
+
12
+ // src/browserbase-client.ts
13
+ var BrowserbaseApiError = class extends Error {
14
+ constructor(message, status, retryAfterMs) {
15
+ super(message);
16
+ this.status = status;
17
+ this.retryAfterMs = retryAfterMs;
18
+ }
19
+ get isRateLimit() {
20
+ return this.status === 429;
21
+ }
22
+ };
23
+ function buildSessionPayload(config, mint) {
24
+ return {
25
+ projectId: config.projectId,
26
+ region: config.region,
27
+ timeout: Math.round(config.defaults.timeoutSeconds),
28
+ keepAlive: false,
29
+ // Omit `proxies` entirely when unset so Browserbase applies plain direct
30
+ // egress rather than an empty routing array.
31
+ ...config.proxies.length > 0 ? { proxies: config.proxies } : {},
32
+ browserSettings: {
33
+ // persist defaults to false upstream; durable logins require it explicit.
34
+ context: { id: mint.contextId, persist: config.context.persist },
35
+ viewport: { ...config.defaults.viewport },
36
+ solveCaptchas: config.defaults.solveCaptchas,
37
+ recordSession: config.defaults.recordSession,
38
+ logSession: config.defaults.logSession,
39
+ // Upstream default is true; credentialed SaaS browsing must not
40
+ // silently accept bad TLS.
41
+ ignoreCertificateErrors: config.defaults.ignoreCertificateErrors
42
+ },
43
+ userMetadata: {
44
+ ...config.metadata,
45
+ broker: "velanir-browserbase",
46
+ instance: mint.instanceKey,
47
+ bootId: mint.bootId,
48
+ leaseId: mint.leaseId
49
+ }
50
+ };
51
+ }
52
+ var DEFAULT_REQUEST_TIMEOUT_MS = 1e4;
53
+ var BrowserbaseClient = class {
54
+ constructor(opts) {
55
+ this.opts = opts;
56
+ }
57
+ async request(method, path2, body) {
58
+ const fetchFn = this.opts.fetchFn ?? fetch;
59
+ const timeoutMs = this.opts.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
60
+ let response;
61
+ try {
62
+ response = await fetchFn(`${this.opts.baseUrl}${path2}`, {
63
+ method,
64
+ headers: {
65
+ "X-BB-API-Key": this.opts.apiKey,
66
+ "Content-Type": "application/json"
67
+ },
68
+ signal: AbortSignal.timeout(timeoutMs),
69
+ ...body === void 0 ? {} : { body: JSON.stringify(body) }
70
+ });
71
+ } catch (err) {
72
+ const name = err instanceof Error ? err.name : "";
73
+ if (name === "TimeoutError" || name === "AbortError") {
74
+ throw new BrowserbaseApiError(`Browserbase ${method} ${path2} timed out after ${timeoutMs}ms`, 408);
75
+ }
76
+ throw err;
77
+ }
78
+ if (!response.ok) {
79
+ const retryAfterHeader = response.headers.get("retry-after");
80
+ const retryAfterSeconds = retryAfterHeader ? Number(retryAfterHeader) : Number.NaN;
81
+ const detail = await response.text().catch(() => "");
82
+ throw new BrowserbaseApiError(
83
+ `Browserbase ${method} ${path2} failed: ${response.status} ${detail.slice(0, 300)}`,
84
+ response.status,
85
+ Number.isFinite(retryAfterSeconds) ? retryAfterSeconds * 1e3 : void 0
86
+ );
87
+ }
88
+ if (response.status === 204) {
89
+ return void 0;
90
+ }
91
+ return await response.json();
92
+ }
93
+ createContext() {
94
+ return this.request("POST", "/v1/contexts", { projectId: this.opts.projectId });
95
+ }
96
+ createSession(payload) {
97
+ return this.request("POST", "/v1/sessions", payload);
98
+ }
99
+ /** REQUEST_RELEASE is the only supported mid-session mutation. */
100
+ async releaseSession(sessionId) {
101
+ await this.request("POST", `/v1/sessions/${sessionId}`, {
102
+ projectId: this.opts.projectId,
103
+ status: "REQUEST_RELEASE"
104
+ });
105
+ }
106
+ getSession(sessionId) {
107
+ return this.request("GET", `/v1/sessions/${sessionId}`);
108
+ }
109
+ /**
110
+ * RUNNING sessions for the key's project. Metadata filtering happens
111
+ * client-side so the reaper does not depend on the list endpoint's
112
+ * query-string syntax (verified counts are small per project).
113
+ */
114
+ listRunningSessions() {
115
+ return this.request("GET", "/v1/sessions?status=RUNNING");
116
+ }
117
+ getDebugUrls(sessionId) {
118
+ return this.request("GET", `/v1/sessions/${sessionId}/debug`);
119
+ }
120
+ };
121
+
122
+ // src/cdp-pipe.ts
123
+ var INJECTED_ID_BASE = 19e8;
124
+ var INJECTED_RESPONSE_MAX_BYTES = 4096;
125
+ var DEFAULT_INJECTION_TIMEOUT_MS = 2e3;
126
+ function rawDataByteLength(data) {
127
+ if (Buffer.isBuffer(data)) {
128
+ return data.byteLength;
129
+ }
130
+ if (Array.isArray(data)) {
131
+ return data.reduce((sum, chunk) => sum + chunk.byteLength, 0);
132
+ }
133
+ return data.byteLength;
134
+ }
135
+ function rawDataToString(data) {
136
+ if (Buffer.isBuffer(data)) {
137
+ return data.toString("utf8");
138
+ }
139
+ if (Array.isArray(data)) {
140
+ return Buffer.concat(data).toString("utf8");
141
+ }
142
+ return Buffer.from(data).toString("utf8");
143
+ }
144
+ var CdpPipe = class {
145
+ constructor(downstream, upstream, opts) {
146
+ this.downstream = downstream;
147
+ this.upstream = upstream;
148
+ this.opts = opts;
149
+ downstream.on("message", (data, isBinary) => {
150
+ this.lastActivityAt = Date.now();
151
+ if (upstream.readyState === upstream.OPEN) {
152
+ upstream.send(data, { binary: isBinary });
153
+ }
154
+ });
155
+ upstream.on("message", (data, isBinary) => {
156
+ this.lastActivityAt = Date.now();
157
+ if (!isBinary && this.maybeResolveInjected(data)) {
158
+ return;
159
+ }
160
+ if (downstream.readyState === downstream.OPEN) {
161
+ downstream.send(data, { binary: isBinary });
162
+ }
163
+ });
164
+ downstream.on("close", () => this.close("downstream-closed"));
165
+ downstream.on("error", () => this.close("downstream-error"));
166
+ upstream.on("close", () => this.close("upstream-closed"));
167
+ upstream.on("error", () => this.close("upstream-error"));
168
+ }
169
+ nextInjectedId = INJECTED_ID_BASE;
170
+ pending = /* @__PURE__ */ new Map();
171
+ closeReason = null;
172
+ lastActivityAt = Date.now();
173
+ get closed() {
174
+ return this.closeReason !== null;
175
+ }
176
+ /** Send a broker-owned CDP command on the piped connection and await its reply. */
177
+ inject(method, params) {
178
+ if (this.closeReason !== null || this.upstream.readyState !== this.upstream.OPEN) {
179
+ return Promise.reject(new Error("CDP pipe is not connected"));
180
+ }
181
+ const id = this.nextInjectedId;
182
+ this.nextInjectedId += 1;
183
+ return new Promise((resolve, reject) => {
184
+ const timer = setTimeout(() => {
185
+ this.pending.delete(id);
186
+ reject(new Error(`Injected CDP command timed out: ${method}`));
187
+ }, this.opts.injectionTimeoutMs ?? DEFAULT_INJECTION_TIMEOUT_MS);
188
+ this.pending.set(id, { resolve, reject, timer });
189
+ this.upstream.send(JSON.stringify({ id, method, ...params ? { params } : {} }));
190
+ });
191
+ }
192
+ maybeResolveInjected(data) {
193
+ if (this.pending.size === 0 || rawDataByteLength(data) > INJECTED_RESPONSE_MAX_BYTES) {
194
+ return false;
195
+ }
196
+ let message;
197
+ try {
198
+ message = JSON.parse(rawDataToString(data));
199
+ } catch {
200
+ return false;
201
+ }
202
+ const id = typeof message.id === "number" ? message.id : null;
203
+ if (id === null || id < INJECTED_ID_BASE) {
204
+ return false;
205
+ }
206
+ const entry = this.pending.get(id);
207
+ if (!entry) {
208
+ return true;
209
+ }
210
+ this.pending.delete(id);
211
+ clearTimeout(entry.timer);
212
+ if (message.error) {
213
+ entry.reject(new Error(message.error.message ?? "CDP command failed"));
214
+ } else {
215
+ entry.resolve(
216
+ typeof message.result === "object" && message.result !== null ? message.result : {}
217
+ );
218
+ }
219
+ return true;
220
+ }
221
+ close(reason) {
222
+ if (this.closeReason !== null) {
223
+ return;
224
+ }
225
+ this.closeReason = reason;
226
+ for (const [, entry] of this.pending) {
227
+ clearTimeout(entry.timer);
228
+ entry.reject(new Error(`CDP pipe closed: ${reason}`));
229
+ }
230
+ this.pending.clear();
231
+ for (const socket of [this.downstream, this.upstream]) {
232
+ if (socket.readyState === socket.OPEN || socket.readyState === socket.CLOSING) {
233
+ try {
234
+ socket.close(1e3);
235
+ } catch {
236
+ socket.terminate();
237
+ }
238
+ } else if (socket.readyState === socket.CONNECTING) {
239
+ socket.terminate();
240
+ }
241
+ }
242
+ this.opts.onClose(reason);
243
+ }
244
+ };
245
+
246
+ // src/config.ts
247
+ var BROWSERBASE_REGIONS = ["us-west-2", "us-east-1", "eu-central-1", "ap-southeast-1"];
248
+ var DEFAULT_REGION = "us-west-2";
249
+ var DEFAULT_TIMEOUT_SECONDS = 3600;
250
+ var MIN_TIMEOUT_SECONDS = 60;
251
+ var MAX_TIMEOUT_SECONDS = 21600;
252
+ var DEFAULT_VIEWPORT = { width: 1280, height: 900 };
253
+ var DEFAULT_CONTEXT_COOLDOWN_SECONDS = 10;
254
+ var DEFAULT_CONTEXT_WAIT_MS = 1e4;
255
+ var DEFAULT_IDLE_DISCONNECT_MINUTES = 15;
256
+ var DEFAULT_REAPER_INTERVAL_MINUTES = 5;
257
+ var DEFAULT_API_BASE_URL = "https://api.browserbase.com";
258
+ var DEFAULT_UPSTREAM_CONNECT_TIMEOUT_MS = 15e3;
259
+ function isRecord(value) {
260
+ return typeof value === "object" && value !== null && !Array.isArray(value);
261
+ }
262
+ function readString(value) {
263
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : void 0;
264
+ }
265
+ function readBoolean(value, fallback) {
266
+ return typeof value === "boolean" ? value : fallback;
267
+ }
268
+ function readNumber(value, fallback, min, max) {
269
+ if (typeof value !== "number" || !Number.isFinite(value)) {
270
+ return fallback;
271
+ }
272
+ return Math.min(max, Math.max(min, value));
273
+ }
274
+ function readStringMap(value) {
275
+ if (!isRecord(value)) {
276
+ return {};
277
+ }
278
+ const entries = {};
279
+ for (const [key, raw] of Object.entries(value)) {
280
+ if (typeof raw === "string" && raw.trim().length > 0) {
281
+ entries[key] = raw.trim();
282
+ }
283
+ }
284
+ return entries;
285
+ }
286
+ function readProxies(value) {
287
+ if (!Array.isArray(value)) {
288
+ return [];
289
+ }
290
+ return value.filter(isRecord);
291
+ }
292
+ function readRegion(value) {
293
+ const region = readString(value);
294
+ return region && BROWSERBASE_REGIONS.includes(region) ? region : DEFAULT_REGION;
295
+ }
296
+ var BrowserbaseConfigError = class extends Error {
297
+ };
298
+ var URL_SAFE_TOKEN_PATTERN = /^[A-Za-z0-9._~-]+$/;
299
+ function isUrlSafeBrokerToken(token) {
300
+ return URL_SAFE_TOKEN_PATTERN.test(token);
301
+ }
302
+ var URL_SAFE_TOKEN_ADVICE = "must be URL-safe ([A-Za-z0-9._~-]); characters like + & # = / change meaning in the cdpUrl query string and break broker auth. Generate it with base64url or hex.";
303
+ function normalizeConfig(raw) {
304
+ const root = isRecord(raw) ? raw : {};
305
+ const projectId = readString(root.projectId);
306
+ if (!projectId) {
307
+ throw new BrowserbaseConfigError(
308
+ "velanir-browserbase: config.projectId is required (the Browserbase project to mint sessions in)."
309
+ );
310
+ }
311
+ const apiKey = readString(root.apiKey);
312
+ if (!apiKey) {
313
+ throw new BrowserbaseConfigError(
314
+ 'velanir-browserbase: config.apiKey is required. Use ${ENV} substitution (e.g. "${OCT8_BROWSERBASE_API_KEY}") so the key never lives in openclaw.json.'
315
+ );
316
+ }
317
+ if (apiKey.startsWith("${")) {
318
+ throw new BrowserbaseConfigError(
319
+ `velanir-browserbase: config.apiKey looks like an unsubstituted env reference (${apiKey}). The referenced environment variable is not set in the gateway service environment.`
320
+ );
321
+ }
322
+ const listenPortRaw = root.listenPort;
323
+ const listenPort = typeof listenPortRaw === "number" && Number.isInteger(listenPortRaw) ? listenPortRaw : Number.NaN;
324
+ if (!Number.isInteger(listenPort) || listenPort <= 0 || listenPort > 65535) {
325
+ throw new BrowserbaseConfigError(
326
+ "velanir-browserbase: config.listenPort must be an integer between 1 and 65535."
327
+ );
328
+ }
329
+ const token = readString(root.token);
330
+ if (token?.startsWith("${")) {
331
+ throw new BrowserbaseConfigError(
332
+ `velanir-browserbase: config.token looks like an unsubstituted env reference (${token}). The referenced environment variable is not set in the gateway service environment; accepting it would make the loopback broker's bearer token a predictable public string.`
333
+ );
334
+ }
335
+ if (token && !isUrlSafeBrokerToken(token)) {
336
+ throw new BrowserbaseConfigError(`velanir-browserbase: config.token ${URL_SAFE_TOKEN_ADVICE}`);
337
+ }
338
+ const defaults = isRecord(root.defaults) ? root.defaults : {};
339
+ const viewport = isRecord(defaults.viewport) ? defaults.viewport : {};
340
+ const context = isRecord(root.context) ? root.context : {};
341
+ return {
342
+ projectId,
343
+ apiKey,
344
+ region: readRegion(root.region),
345
+ listenPort,
346
+ ...token ? { token } : {},
347
+ ...readString(root.instanceKey) ? { instanceKey: readString(root.instanceKey) } : {},
348
+ metadata: readStringMap(root.metadata),
349
+ defaults: {
350
+ timeoutSeconds: readNumber(
351
+ defaults.timeoutSeconds,
352
+ DEFAULT_TIMEOUT_SECONDS,
353
+ MIN_TIMEOUT_SECONDS,
354
+ MAX_TIMEOUT_SECONDS
355
+ ),
356
+ viewport: {
357
+ width: readNumber(viewport.width, DEFAULT_VIEWPORT.width, 320, 3840),
358
+ height: readNumber(viewport.height, DEFAULT_VIEWPORT.height, 320, 2160)
359
+ },
360
+ solveCaptchas: readBoolean(defaults.solveCaptchas, true),
361
+ recordSession: readBoolean(defaults.recordSession, true),
362
+ logSession: readBoolean(defaults.logSession, true),
363
+ ignoreCertificateErrors: readBoolean(defaults.ignoreCertificateErrors, false)
364
+ },
365
+ context: {
366
+ persist: readBoolean(context.persist, true),
367
+ cooldownSeconds: readNumber(context.cooldownSeconds, DEFAULT_CONTEXT_COOLDOWN_SECONDS, 0, 120),
368
+ waitMs: readNumber(context.waitMs, DEFAULT_CONTEXT_WAIT_MS, 0, 6e4)
369
+ },
370
+ proxies: readProxies(root.proxies),
371
+ idleDisconnectMinutes: readNumber(root.idleDisconnectMinutes, DEFAULT_IDLE_DISCONNECT_MINUTES, 0, 1440),
372
+ reaperIntervalMinutes: readNumber(root.reaperIntervalMinutes, DEFAULT_REAPER_INTERVAL_MINUTES, 0, 1440),
373
+ apiBaseUrl: readString(root.apiBaseUrl) ?? DEFAULT_API_BASE_URL,
374
+ upstreamConnectTimeoutMs: readNumber(
375
+ root.upstreamConnectTimeoutMs,
376
+ DEFAULT_UPSTREAM_CONNECT_TIMEOUT_MS,
377
+ 1e3,
378
+ 6e4
379
+ )
380
+ };
381
+ }
382
+
383
+ // src/context-store.ts
384
+ import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
385
+ import path from "path";
386
+ var EMPTY_STATE = { contexts: {} };
387
+ var ContextStore = class {
388
+ filePath;
389
+ constructor(stateDir) {
390
+ const dir = path.join(stateDir, "velanir-browserbase");
391
+ mkdirSync(dir, { recursive: true });
392
+ this.filePath = path.join(dir, "state.json");
393
+ }
394
+ read() {
395
+ try {
396
+ const raw = JSON.parse(readFileSync(this.filePath, "utf8"));
397
+ if (typeof raw === "object" && raw !== null && !Array.isArray(raw) && typeof raw.contexts === "object" && raw.contexts !== null) {
398
+ return raw;
399
+ }
400
+ } catch {
401
+ }
402
+ return { ...EMPTY_STATE, contexts: {} };
403
+ }
404
+ getContextId(projectId) {
405
+ return this.read().contexts[projectId]?.contextId;
406
+ }
407
+ setContextId(projectId, contextId) {
408
+ const state = this.read();
409
+ state.contexts[projectId] = { contextId, createdAt: (/* @__PURE__ */ new Date()).toISOString() };
410
+ const tmpPath = `${this.filePath}.tmp`;
411
+ writeFileSync(tmpPath, `${JSON.stringify(state, null, 2)}
412
+ `, { encoding: "utf8", mode: 384 });
413
+ renameSync(tmpPath, this.filePath);
414
+ chmodSync(this.filePath, 384);
415
+ }
416
+ };
417
+
418
+ // src/redact.ts
419
+ var SENSITIVE_QUERY_PARAMS = /* @__PURE__ */ new Set(["apikey", "api_key", "token", "signingkey", "signing_key", "sessionid"]);
420
+ function redactUrlSecrets(text2) {
421
+ return text2.replace(/([?&])([A-Za-z0-9_-]+)=([^&\s"']+)/g, (match, sep, key, _value) => {
422
+ return SENSITIVE_QUERY_PARAMS.has(key.toLowerCase()) ? `${sep}${key}=[redacted]` : match;
423
+ });
424
+ }
425
+ function createRedactor(secrets) {
426
+ const literals = secrets.filter((value) => typeof value === "string" && value.length >= 6);
427
+ return (text2) => {
428
+ let out = redactUrlSecrets(text2);
429
+ for (const literal of literals) {
430
+ out = out.split(literal).join("[redacted]");
431
+ }
432
+ return out;
433
+ };
434
+ }
435
+ function createRedactedLogger(logger, redact) {
436
+ const wrap = (fn) => fn ? (message) => fn(redact(message)) : void 0;
437
+ return {
438
+ debug: wrap(logger.debug),
439
+ info: wrap(logger.info),
440
+ warn: wrap(logger.warn),
441
+ error: wrap(logger.error)
442
+ };
443
+ }
444
+
445
+ // src/broker.ts
446
+ var WS_MAX_PAYLOAD_BYTES = 256 * 1024 * 1024;
447
+ var ADOPTION_WINDOW_MS = 15e3;
448
+ var BUSY_POLL_INTERVAL_MS = 250;
449
+ var SHUTDOWN_RELEASE_BUDGET_MS = 5e3;
450
+ var RATE_LIMIT_RETRY_BUDGET_MS = 8e3;
451
+ var DEFAULT_IDLE_SWEEP_INTERVAL_MS = 3e4;
452
+ var JSON_VERSION_BODY = {
453
+ Browser: "Chrome/138.0.0.0",
454
+ "Protocol-Version": "1.3",
455
+ "User-Agent": "Velanir Browserbase session broker",
456
+ "V8-Version": "13.8",
457
+ "WebKit-Version": "537.36"
458
+ };
459
+ var BrokerStartError = class extends Error {
460
+ };
461
+ function sha256Short(input) {
462
+ return createHash("sha256").update(input).digest("hex").slice(0, 12);
463
+ }
464
+ function sleep(ms) {
465
+ return new Promise((resolve) => setTimeout(resolve, ms));
466
+ }
467
+ function writeJson(res, status, body) {
468
+ const payload = JSON.stringify(body);
469
+ res.writeHead(status, { "content-type": "application/json", "content-length": Buffer.byteLength(payload) });
470
+ res.end(payload);
471
+ }
472
+ function destroyWithHttpError(socket, status, statusText, message) {
473
+ const body = JSON.stringify({ error: message });
474
+ socket.write(
475
+ `HTTP/1.1 ${status} ${statusText}\r
476
+ content-type: application/json\r
477
+ content-length: ${Buffer.byteLength(body)}\r
478
+ connection: close\r
479
+ \r
480
+ ${body}`
481
+ );
482
+ socket.destroy();
483
+ }
484
+ var BrowserbaseBroker = class {
485
+ constructor(config, deps) {
486
+ this.config = config;
487
+ const token = config.token ?? process.env.OCT8_BB_BROKER_TOKEN?.trim();
488
+ if (!token) {
489
+ throw new BrokerStartError(
490
+ 'velanir-browserbase: no broker token. Set plugins.entries.velanir-browserbase.config.token ("${OCT8_BB_BROKER_TOKEN}") or export OCT8_BB_BROKER_TOKEN in the gateway service environment; the browser profile cdpUrl must carry the same value as ?token=.'
491
+ );
492
+ }
493
+ if (token.startsWith("${")) {
494
+ throw new BrokerStartError(
495
+ `velanir-browserbase: broker token looks like an unsubstituted env reference (${token}); refusing to start with a predictable bearer token.`
496
+ );
497
+ }
498
+ if (!isUrlSafeBrokerToken(token)) {
499
+ throw new BrokerStartError(`velanir-browserbase: broker token ${URL_SAFE_TOKEN_ADVICE}`);
500
+ }
501
+ this.token = token;
502
+ this.instanceKey = config.instanceKey ?? sha256Short(deps.stateDir);
503
+ const redact = createRedactor([config.apiKey, token]);
504
+ this.logger = createRedactedLogger(deps.logger, redact);
505
+ this.contextStore = new ContextStore(deps.stateDir);
506
+ this.client = new BrowserbaseClient({
507
+ apiKey: config.apiKey,
508
+ baseUrl: config.apiBaseUrl,
509
+ projectId: config.projectId,
510
+ ...deps.fetchFn ? { fetchFn: deps.fetchFn } : {}
511
+ });
512
+ this.wss = new WebSocketServer({ noServer: true, maxPayload: WS_MAX_PAYLOAD_BYTES, perMessageDeflate: false });
513
+ this.idleSweepIntervalMs = deps.idleSweepIntervalMs ?? DEFAULT_IDLE_SWEEP_INTERVAL_MS;
514
+ }
515
+ client;
516
+ contextStore;
517
+ logger;
518
+ token;
519
+ instanceKey;
520
+ bootId = randomUUID();
521
+ wss;
522
+ idleSweepIntervalMs;
523
+ server = null;
524
+ active = null;
525
+ adoptable = null;
526
+ cooldownUntil = 0;
527
+ timers = [];
528
+ stopping = false;
529
+ // Upgrade flows run strictly one at a time. This is what makes the
530
+ // one-session-per-context invariant airtight: a second connect (OpenClaw's
531
+ // 5s/7s/9s retry ladder, profile races) can never mint concurrently with an
532
+ // in-flight mint/dial — it queues, then adopts the held session or fails
533
+ // fast on the busy check.
534
+ upgradeChain = Promise.resolve();
535
+ async start() {
536
+ const server = createServer((req, res) => {
537
+ void this.handleRequest(req, res);
538
+ });
539
+ server.on("upgrade", (req, socket, head) => {
540
+ this.handleUpgrade(req, socket, head);
541
+ });
542
+ await new Promise((resolve, reject) => {
543
+ server.once("error", (err) => {
544
+ reject(
545
+ new BrokerStartError(
546
+ err.code === "EADDRINUSE" ? `velanir-browserbase: listen port ${this.config.listenPort} is already in use on 127.0.0.1. Free the port or change config.listenPort (and the browser profile cdpUrl) for this gateway.` : `velanir-browserbase: failed to bind 127.0.0.1:${this.config.listenPort}: ${err.message}`
547
+ )
548
+ );
549
+ });
550
+ server.listen(this.config.listenPort, "127.0.0.1", () => {
551
+ server.removeAllListeners("error");
552
+ server.on("error", (err) => this.logger.error?.(`broker server error: ${String(err)}`));
553
+ resolve();
554
+ });
555
+ });
556
+ this.server = server;
557
+ if (this.idleSweepIntervalMs > 0 && this.config.idleDisconnectMinutes > 0) {
558
+ const idleTimer = setInterval(() => this.sweepIdle(), this.idleSweepIntervalMs);
559
+ idleTimer.unref();
560
+ this.timers.push(idleTimer);
561
+ }
562
+ if (this.config.reaperIntervalMinutes > 0) {
563
+ const reaperTimer = setInterval(() => {
564
+ void this.reapStaleSessions("interval");
565
+ }, this.config.reaperIntervalMinutes * 6e4);
566
+ reaperTimer.unref();
567
+ this.timers.push(reaperTimer);
568
+ void this.reapStaleSessions("boot");
569
+ }
570
+ this.logger.info?.(
571
+ `velanir-browserbase broker listening on 127.0.0.1:${this.config.listenPort} (project ${this.config.projectId}, region ${this.config.region}, instance ${this.instanceKey})`
572
+ );
573
+ }
574
+ async stop() {
575
+ this.stopping = true;
576
+ for (const timer of this.timers) {
577
+ clearInterval(timer);
578
+ }
579
+ this.timers = [];
580
+ if (this.adoptable) {
581
+ clearTimeout(this.adoptable.timer);
582
+ const held = this.adoptable;
583
+ this.adoptable = null;
584
+ held.upstream?.terminate();
585
+ await this.releaseQuietly(held.session.sessionId, "shutdown-unattached");
586
+ }
587
+ const active = this.active;
588
+ if (active) {
589
+ const released = new Promise((resolve) => {
590
+ this.onActiveReleased = resolve;
591
+ });
592
+ active.pipe.close("shutdown");
593
+ await Promise.race([released, sleep(SHUTDOWN_RELEASE_BUDGET_MS)]);
594
+ this.onActiveReleased = null;
595
+ }
596
+ await new Promise((resolve) => {
597
+ if (!this.server) {
598
+ resolve();
599
+ return;
600
+ }
601
+ this.server.close(() => resolve());
602
+ this.server.closeAllConnections?.();
603
+ });
604
+ this.server = null;
605
+ }
606
+ onActiveReleased = null;
607
+ // ---------------------------------------------------------------- HTTP --
608
+ isAuthorized(url) {
609
+ const provided = url.searchParams.get("token");
610
+ if (provided === null) {
611
+ return false;
612
+ }
613
+ const a = createHash("sha256").update(provided).digest();
614
+ const b = createHash("sha256").update(this.token).digest();
615
+ return timingSafeEqual(a, b);
616
+ }
617
+ async handleRequest(req, res) {
618
+ const url = new URL(req.url ?? "/", `http://127.0.0.1:${this.config.listenPort}`);
619
+ if (!this.isAuthorized(url)) {
620
+ writeJson(res, 401, { error: "missing or invalid token" });
621
+ return;
622
+ }
623
+ const pathname = url.pathname.replace(/\/$/, "") || "/";
624
+ try {
625
+ if (pathname === "/json/version") {
626
+ const leaseId = randomUUID();
627
+ writeJson(res, 200, {
628
+ ...JSON_VERSION_BODY,
629
+ webSocketDebuggerUrl: `ws://127.0.0.1:${this.config.listenPort}/devtools/broker/${leaseId}?token=${this.token}`
630
+ });
631
+ return;
632
+ }
633
+ if (pathname === "/json" || pathname === "/json/list") {
634
+ writeJson(res, 200, await this.listTargets());
635
+ return;
636
+ }
637
+ if (pathname === "/json/new") {
638
+ await this.handleNewTab(url, res);
639
+ return;
640
+ }
641
+ if (pathname.startsWith("/json/close/")) {
642
+ await this.handleCloseTab(pathname.slice("/json/close/".length), res);
643
+ return;
644
+ }
645
+ writeJson(res, 404, { error: "not found" });
646
+ } catch (err) {
647
+ writeJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
648
+ }
649
+ }
650
+ async listTargets() {
651
+ const active = this.active;
652
+ if (!active || active.pipe.closed) {
653
+ return [];
654
+ }
655
+ const result = await active.pipe.inject("Target.getTargets");
656
+ const infos = Array.isArray(result.targetInfos) ? result.targetInfos : [];
657
+ return infos.filter((info) => typeof info === "object" && info !== null).filter((info) => info.type === "page").map((info) => ({
658
+ id: String(info.targetId ?? ""),
659
+ type: "page",
660
+ title: String(info.title ?? ""),
661
+ url: String(info.url ?? "")
662
+ }));
663
+ }
664
+ async handleNewTab(url, res) {
665
+ const active = this.active;
666
+ if (!active || active.pipe.closed) {
667
+ writeJson(res, 503, { error: "no active Browserbase session; run a browser action to connect first" });
668
+ return;
669
+ }
670
+ let target = url.searchParams.get("url");
671
+ if (target === null) {
672
+ const bare = url.search.replace(/^\?/, "").split("&").filter((part) => !part.startsWith("token=")).join("&");
673
+ target = bare ? decodeURIComponent(bare) : null;
674
+ }
675
+ const targetUrl = target?.trim() || "about:blank";
676
+ const result = await active.pipe.inject("Target.createTarget", { url: targetUrl });
677
+ writeJson(res, 200, {
678
+ id: String(result.targetId ?? ""),
679
+ type: "page",
680
+ url: targetUrl
681
+ });
682
+ }
683
+ async handleCloseTab(targetId, res) {
684
+ const active = this.active;
685
+ if (!active || active.pipe.closed || !targetId) {
686
+ writeJson(res, 404, { error: "no such target" });
687
+ return;
688
+ }
689
+ await active.pipe.inject("Target.closeTarget", { targetId });
690
+ res.writeHead(200, { "content-type": "text/plain" });
691
+ res.end("Target is closing");
692
+ }
693
+ // ------------------------------------------------------------- upgrade --
694
+ handleUpgrade(req, socket, head) {
695
+ socket.on("error", () => socket.destroy());
696
+ const url = new URL(req.url ?? "/", `http://127.0.0.1:${this.config.listenPort}`);
697
+ if (!this.isAuthorized(url)) {
698
+ destroyWithHttpError(socket, 401, "Unauthorized", "missing or invalid token");
699
+ return;
700
+ }
701
+ const match = /^\/devtools\/broker\/([A-Za-z0-9-]+)$/.exec(url.pathname);
702
+ if (!match) {
703
+ destroyWithHttpError(socket, 404, "Not Found", "unknown websocket path");
704
+ return;
705
+ }
706
+ if (this.stopping) {
707
+ destroyWithHttpError(socket, 503, "Service Unavailable", "broker is shutting down");
708
+ return;
709
+ }
710
+ const leaseId = match[1];
711
+ const markGone = () => {
712
+ liveness.gone = true;
713
+ };
714
+ const liveness = {
715
+ gone: false,
716
+ detach: () => {
717
+ socket.off("close", markGone);
718
+ socket.off("end", markGone);
719
+ }
720
+ };
721
+ socket.once("close", markGone);
722
+ socket.once("end", markGone);
723
+ this.upgradeChain = this.upgradeChain.then(() => this.runUpgradeFlow(req, socket, head, leaseId, liveness)).catch(() => {
724
+ });
725
+ }
726
+ async runUpgradeFlow(req, socket, head, leaseId, liveness) {
727
+ if (this.stopping) {
728
+ liveness.detach();
729
+ destroyWithHttpError(socket, 503, "Service Unavailable", "broker is shutting down");
730
+ return;
731
+ }
732
+ if (liveness.gone || socket.destroyed) {
733
+ liveness.detach();
734
+ socket.destroy();
735
+ return;
736
+ }
737
+ let held;
738
+ try {
739
+ held = await this.acquireSession(leaseId);
740
+ } catch (err) {
741
+ const message = err instanceof Error ? err.message : String(err);
742
+ this.logger.warn?.(`session acquire failed: ${message}`);
743
+ liveness.detach();
744
+ if (liveness.gone) {
745
+ socket.destroy();
746
+ } else {
747
+ destroyWithHttpError(socket, 503, "Service Unavailable", message);
748
+ }
749
+ return;
750
+ }
751
+ const minted = held.session;
752
+ if (liveness.gone) {
753
+ liveness.detach();
754
+ socket.destroy();
755
+ this.holdForAdoption(minted, held.upstream);
756
+ return;
757
+ }
758
+ let upstream = held.upstream && held.upstream.readyState === held.upstream.OPEN ? held.upstream : null;
759
+ if (upstream === null) {
760
+ held.upstream?.terminate();
761
+ try {
762
+ upstream = await this.dialUpstream(minted.connectUrl);
763
+ } catch (err) {
764
+ const message = err instanceof Error ? err.message : String(err);
765
+ this.logger.warn?.(`upstream dial failed for session ${minted.sessionId}: ${message}`);
766
+ await this.releaseQuietly(minted.sessionId, "upstream-dial-failed");
767
+ this.cooldownUntil = Date.now() + this.config.context.cooldownSeconds * 1e3;
768
+ liveness.detach();
769
+ if (liveness.gone) {
770
+ socket.destroy();
771
+ } else {
772
+ destroyWithHttpError(socket, 502, "Bad Gateway", `Browserbase connect failed: ${message}`);
773
+ }
774
+ return;
775
+ }
776
+ }
777
+ if (liveness.gone) {
778
+ liveness.detach();
779
+ socket.destroy();
780
+ this.holdForAdoption(minted, upstream);
781
+ return;
782
+ }
783
+ liveness.detach();
784
+ this.wss.handleUpgrade(req, socket, head, (downstream) => {
785
+ this.attachPipe(downstream, upstream, minted);
786
+ });
787
+ }
788
+ attachPipe(downstream, upstream, minted) {
789
+ if (upstream.readyState !== upstream.OPEN || downstream.readyState !== downstream.OPEN) {
790
+ upstream.terminate();
791
+ if (downstream.readyState === downstream.OPEN) {
792
+ downstream.close(1011, "upstream connection lost during attach");
793
+ } else {
794
+ downstream.terminate();
795
+ }
796
+ this.cooldownUntil = Date.now() + this.config.context.cooldownSeconds * 1e3;
797
+ void this.releaseQuietly(minted.sessionId, "attach-aborted");
798
+ return;
799
+ }
800
+ const pipe = new CdpPipe(downstream, upstream, {
801
+ onClose: (reason) => this.onPipeClosed(minted.sessionId, reason)
802
+ });
803
+ this.active = {
804
+ sessionId: minted.sessionId,
805
+ leaseId: minted.leaseId,
806
+ contextId: minted.contextId,
807
+ ...minted.expiresAt ? { expiresAt: minted.expiresAt } : {},
808
+ startedAt: Date.now(),
809
+ pipe
810
+ };
811
+ this.logger.info?.(
812
+ `attached session ${minted.sessionId} (lease ${minted.leaseId}, context ${minted.contextId})`
813
+ );
814
+ }
815
+ onPipeClosed(sessionId, reason) {
816
+ if (this.active?.sessionId !== sessionId) {
817
+ return;
818
+ }
819
+ this.active = null;
820
+ this.cooldownUntil = Date.now() + this.config.context.cooldownSeconds * 1e3;
821
+ this.logger.info?.(`session ${sessionId} disconnected (${reason}); releasing`);
822
+ void this.releaseQuietly(sessionId, reason).finally(() => {
823
+ this.onActiveReleased?.();
824
+ });
825
+ }
826
+ // ---------------------------------------------------------------- mint --
827
+ async acquireSession(leaseId) {
828
+ const adopted = this.takeAdoptable();
829
+ if (adopted) {
830
+ this.logger.info?.(
831
+ `adopting held session ${adopted.session.sessionId} (minted for lease ${adopted.session.leaseId}, requested by lease ${leaseId})`
832
+ );
833
+ return adopted;
834
+ }
835
+ const busyDeadline = Date.now() + this.config.context.waitMs;
836
+ while (this.active && !this.active.pipe.closed) {
837
+ if (Date.now() >= busyDeadline) {
838
+ throw new Error(
839
+ "context busy: another Browserbase session is active for this coworker; retry after it finishes"
840
+ );
841
+ }
842
+ await sleep(BUSY_POLL_INTERVAL_MS);
843
+ }
844
+ const cooldownRemaining = this.cooldownUntil - Date.now();
845
+ if (cooldownRemaining > 0) {
846
+ await sleep(cooldownRemaining);
847
+ }
848
+ return { session: await this.mintSession(leaseId) };
849
+ }
850
+ async mintSession(leaseId) {
851
+ const contextId = await this.ensureContext();
852
+ const payload = buildSessionPayload(this.config, {
853
+ contextId,
854
+ leaseId,
855
+ bootId: this.bootId,
856
+ instanceKey: this.instanceKey
857
+ });
858
+ let session;
859
+ try {
860
+ session = await this.client.createSession(payload);
861
+ } catch (err) {
862
+ if (err instanceof BrowserbaseApiError && err.isRateLimit && err.retryAfterMs !== void 0 && err.retryAfterMs <= RATE_LIMIT_RETRY_BUDGET_MS) {
863
+ this.logger.warn?.(`Browserbase rate limited; retrying mint in ${err.retryAfterMs}ms`);
864
+ await sleep(err.retryAfterMs);
865
+ session = await this.client.createSession(payload);
866
+ } else {
867
+ throw err;
868
+ }
869
+ }
870
+ if (!session.connectUrl) {
871
+ await this.releaseQuietly(session.id, "missing-connect-url");
872
+ throw new Error(`Browserbase session ${session.id} returned no connectUrl`);
873
+ }
874
+ this.logger.info?.(`minted session ${session.id} (lease ${leaseId}, context ${contextId})`);
875
+ return {
876
+ sessionId: session.id,
877
+ connectUrl: session.connectUrl,
878
+ contextId,
879
+ leaseId,
880
+ ...session.expiresAt ? { expiresAt: session.expiresAt } : {}
881
+ };
882
+ }
883
+ async ensureContext() {
884
+ const existing = this.contextStore.getContextId(this.config.projectId);
885
+ if (existing) {
886
+ return existing;
887
+ }
888
+ const created = await this.client.createContext();
889
+ this.contextStore.setContextId(this.config.projectId, created.id);
890
+ this.logger.info?.(`created Browserbase context ${created.id} for project ${this.config.projectId}`);
891
+ return created.id;
892
+ }
893
+ dialUpstream(connectUrl) {
894
+ return new Promise((resolve, reject) => {
895
+ const upstream = new WebSocket(connectUrl, {
896
+ handshakeTimeout: this.config.upstreamConnectTimeoutMs,
897
+ maxPayload: WS_MAX_PAYLOAD_BYTES,
898
+ perMessageDeflate: false
899
+ });
900
+ upstream.once("open", () => {
901
+ upstream.removeAllListeners("error");
902
+ resolve(upstream);
903
+ });
904
+ upstream.once("error", (err) => {
905
+ upstream.terminate();
906
+ reject(err instanceof Error ? err : new Error(String(err)));
907
+ });
908
+ });
909
+ }
910
+ holdForAdoption(session, upstream) {
911
+ this.logger.info?.(
912
+ `holding session ${session.sessionId} for adoption (client disconnected mid-handshake, upstream ${upstream ? "open" : "not dialed"})`
913
+ );
914
+ const timer = setTimeout(() => {
915
+ if (this.adoptable?.session.sessionId === session.sessionId) {
916
+ const held = this.adoptable;
917
+ this.adoptable = null;
918
+ held.upstream?.terminate();
919
+ void this.releaseQuietly(session.sessionId, "adoption-expired");
920
+ }
921
+ }, ADOPTION_WINDOW_MS);
922
+ timer.unref();
923
+ let onUpstreamLost;
924
+ if (upstream) {
925
+ onUpstreamLost = () => {
926
+ if (this.adoptable?.session.sessionId === session.sessionId && this.adoptable.upstream === upstream) {
927
+ clearTimeout(this.adoptable.timer);
928
+ this.adoptable = null;
929
+ void this.releaseQuietly(session.sessionId, "adoption-upstream-lost");
930
+ }
931
+ };
932
+ upstream.once("close", onUpstreamLost);
933
+ upstream.once("error", onUpstreamLost);
934
+ }
935
+ this.adoptable = {
936
+ session,
937
+ ...upstream ? { upstream } : {},
938
+ ...onUpstreamLost ? { onUpstreamLost } : {},
939
+ timer
940
+ };
941
+ }
942
+ takeAdoptable() {
943
+ const held = this.adoptable;
944
+ if (!held) {
945
+ return null;
946
+ }
947
+ clearTimeout(held.timer);
948
+ if (held.upstream && held.onUpstreamLost) {
949
+ held.upstream.off("close", held.onUpstreamLost);
950
+ held.upstream.off("error", held.onUpstreamLost);
951
+ }
952
+ this.adoptable = null;
953
+ return {
954
+ session: held.session,
955
+ ...held.upstream ? { upstream: held.upstream } : {}
956
+ };
957
+ }
958
+ // ------------------------------------------------------------- cleanup --
959
+ async releaseQuietly(sessionId, reason) {
960
+ try {
961
+ await this.client.releaseSession(sessionId);
962
+ this.logger.info?.(`released session ${sessionId} (${reason})`);
963
+ } catch (err) {
964
+ this.logger.debug?.(`release of ${sessionId} failed (${reason}): ${String(err)}`);
965
+ }
966
+ }
967
+ sweepIdle() {
968
+ const active = this.active;
969
+ if (!active || this.config.idleDisconnectMinutes <= 0) {
970
+ return;
971
+ }
972
+ const idleMs = Date.now() - active.pipe.lastActivityAt;
973
+ if (idleMs >= this.config.idleDisconnectMinutes * 6e4) {
974
+ this.logger.info?.(
975
+ `idle for ${Math.round(idleMs / 1e3)}s; disconnecting session ${active.sessionId} (login state persists in context)`
976
+ );
977
+ active.pipe.close("idle-disconnect");
978
+ }
979
+ }
980
+ async reapStaleSessions(trigger) {
981
+ let sessions;
982
+ try {
983
+ const listed = await this.client.listRunningSessions();
984
+ if (!Array.isArray(listed)) {
985
+ this.logger.warn?.(`reaper list returned a non-array response (${trigger}); skipping sweep`);
986
+ return;
987
+ }
988
+ sessions = listed;
989
+ } catch (err) {
990
+ this.logger.warn?.(`reaper list failed (${trigger}): ${String(err)}`);
991
+ return;
992
+ }
993
+ for (const session of sessions) {
994
+ const meta = session.userMetadata;
995
+ const isOurs = typeof meta === "object" && meta !== null && meta.broker === "velanir-browserbase" && meta.instance === this.instanceKey;
996
+ if (!isOurs) {
997
+ continue;
998
+ }
999
+ if (session.id === this.active?.sessionId || session.id === this.adoptable?.session.sessionId) {
1000
+ continue;
1001
+ }
1002
+ this.logger.warn?.(`reaper releasing stale session ${session.id} (${trigger})`);
1003
+ await this.releaseQuietly(session.id, `reaper-${trigger}`);
1004
+ }
1005
+ }
1006
+ // --------------------------------------------------------------- tools --
1007
+ getStatus() {
1008
+ const active = this.active;
1009
+ return {
1010
+ running: this.server !== null && !this.stopping,
1011
+ listenPort: this.config.listenPort,
1012
+ region: this.config.region,
1013
+ projectId: this.config.projectId,
1014
+ ...this.contextStore.getContextId(this.config.projectId) ? { contextId: this.contextStore.getContextId(this.config.projectId) } : {},
1015
+ recordSession: this.config.defaults.recordSession,
1016
+ cooldownRemainingMs: Math.max(0, this.cooldownUntil - Date.now()),
1017
+ active: active && !active.pipe.closed ? {
1018
+ sessionId: active.sessionId,
1019
+ startedAt: new Date(active.startedAt).toISOString(),
1020
+ ...active.expiresAt ? { expiresAt: active.expiresAt } : {},
1021
+ idleMs: Date.now() - active.pipe.lastActivityAt
1022
+ } : null
1023
+ };
1024
+ }
1025
+ async getLiveViewUrls() {
1026
+ const active = this.active;
1027
+ if (!active || active.pipe.closed) {
1028
+ return null;
1029
+ }
1030
+ return this.client.getDebugUrls(active.sessionId);
1031
+ }
1032
+ /** Returns true when there was an active session to release. */
1033
+ releaseActiveSession() {
1034
+ const active = this.active;
1035
+ if (!active || active.pipe.closed) {
1036
+ return false;
1037
+ }
1038
+ active.pipe.close("manual-release");
1039
+ return true;
1040
+ }
1041
+ };
1042
+
1043
+ // src/tools.ts
1044
+ import { Type } from "typebox";
1045
+ var NO_PARAMS = Type.Object({}, { additionalProperties: false });
1046
+ function text(message) {
1047
+ return { content: [{ type: "text", text: message }] };
1048
+ }
1049
+ var NOT_RUNNING = "The Browserbase broker is not running on this gateway. Browser tasks use the local profile until it is configured.";
1050
+ var NO_SESSION = "No active Browserbase session. The next browser action starts one automatically; saved logins persist in this coworker's browser context.";
1051
+ function createBrowserbaseTools(getBroker) {
1052
+ return [
1053
+ {
1054
+ name: "browserbase_session_status",
1055
+ label: "Browserbase Session Status",
1056
+ description: "Show the current Browserbase cloud-browser session for this coworker: session id, started/expires timestamps, idle time, and the persistent browser-context id. Use before long browser tasks to check remaining session time.",
1057
+ parameters: NO_PARAMS,
1058
+ execute: () => {
1059
+ const broker = getBroker();
1060
+ if (!broker) {
1061
+ return text(NOT_RUNNING);
1062
+ }
1063
+ const status = broker.getStatus();
1064
+ if (!status.active) {
1065
+ const lines2 = [
1066
+ NO_SESSION,
1067
+ `Region: ${status.region}. Recording: ${status.recordSession ? "on" : "off"}.`,
1068
+ status.contextId ? `Browser context: ${status.contextId}` : "Browser context: created on first use."
1069
+ ];
1070
+ return text(lines2.join("\n"));
1071
+ }
1072
+ const lines = [
1073
+ `Active Browserbase session ${status.active.sessionId}`,
1074
+ `Started: ${status.active.startedAt}`,
1075
+ status.active.expiresAt ? `Expires: ${status.active.expiresAt} (the session ends then even mid-task; finish or checkpoint before that)` : "Expires: unknown",
1076
+ `Idle: ${Math.round(status.active.idleMs / 1e3)}s`,
1077
+ `Context: ${status.contextId ?? "unknown"} (login state persists here across sessions)`,
1078
+ `Recording: ${status.recordSession ? "on" : "off"}. Region: ${status.region}.`
1079
+ ];
1080
+ return text(lines.join("\n"));
1081
+ }
1082
+ },
1083
+ {
1084
+ name: "browserbase_live_view",
1085
+ label: "Browserbase Live View",
1086
+ description: "Get the interactive Live View URL for the current Browserbase session so a human can watch or take over the browser \u2014 required when a login needs a human to complete 2FA/SSO. Treat the URL like a credential: share it only with the human who must act, never in shared channels.",
1087
+ parameters: NO_PARAMS,
1088
+ execute: async () => {
1089
+ const broker = getBroker();
1090
+ if (!broker) {
1091
+ return text(NOT_RUNNING);
1092
+ }
1093
+ let urls;
1094
+ try {
1095
+ urls = await broker.getLiveViewUrls();
1096
+ } catch (err) {
1097
+ return text(`Could not fetch Live View URLs from Browserbase: ${String(err)}. Retry in a few seconds.`);
1098
+ }
1099
+ if (!urls) {
1100
+ return text(`${NO_SESSION}
1101
+ Open the page that needs human help first, then call this tool again.`);
1102
+ }
1103
+ const lines = ["Browserbase Live View (interactive; anyone with this URL controls the browser):"];
1104
+ if (urls.debuggerFullscreenUrl) {
1105
+ lines.push(`Fullscreen: ${urls.debuggerFullscreenUrl}`);
1106
+ }
1107
+ if (urls.debuggerUrl) {
1108
+ lines.push(`Debugger: ${urls.debuggerUrl}`);
1109
+ }
1110
+ for (const page of urls.pages ?? []) {
1111
+ if (page.debuggerFullscreenUrl && page.url) {
1112
+ lines.push(`Tab ${page.title || page.url}: ${page.debuggerFullscreenUrl}`);
1113
+ }
1114
+ }
1115
+ if (lines.length === 1) {
1116
+ return text("The session is active but Browserbase returned no Live View URLs; retry in a few seconds.");
1117
+ }
1118
+ return text(lines.join("\n"));
1119
+ }
1120
+ },
1121
+ {
1122
+ name: "browserbase_session_release",
1123
+ label: "Browserbase Session Release",
1124
+ description: "Release the current Browserbase session immediately when browsing for this task is done. Saved logins persist in the coworker's browser context; the next browser action starts a fresh session. Use for cost hygiene after finishing browser work.",
1125
+ parameters: NO_PARAMS,
1126
+ execute: () => {
1127
+ const broker = getBroker();
1128
+ if (!broker) {
1129
+ return text(NOT_RUNNING);
1130
+ }
1131
+ const released = broker.releaseActiveSession();
1132
+ return text(
1133
+ released ? "Browserbase session released. Login state is saved in the persistent context; the next browser action starts a fresh session." : "No active Browserbase session to release."
1134
+ );
1135
+ }
1136
+ }
1137
+ ];
1138
+ }
1139
+
1140
+ // src/index.ts
1141
+ var PLUGIN_ID = "velanir-browserbase";
1142
+ var index_default = definePluginEntry({
1143
+ id: PLUGIN_ID,
1144
+ name: "Velanir Browserbase Sessions",
1145
+ description: "Brokers Browserbase sessions behind a loopback CDP endpoint for the bundled browser tool: explicit session creation with a persistent per-coworker context, deterministic release, idle disconnect, and stale-session reaping.",
1146
+ register(api) {
1147
+ const shared = {};
1148
+ api.registerService({
1149
+ id: "browserbase-session-broker",
1150
+ async start(ctx) {
1151
+ const config = normalizeConfig(api.pluginConfig);
1152
+ const broker = new BrowserbaseBroker(config, {
1153
+ logger: ctx.logger,
1154
+ stateDir: ctx.stateDir
1155
+ });
1156
+ await broker.start();
1157
+ shared.broker = broker;
1158
+ },
1159
+ async stop() {
1160
+ const broker = shared.broker;
1161
+ shared.broker = void 0;
1162
+ await broker?.stop();
1163
+ }
1164
+ });
1165
+ for (const tool of createBrowserbaseTools(() => shared.broker)) {
1166
+ api.registerTool(tool);
1167
+ }
1168
+ }
1169
+ });
1170
+ export {
1171
+ PLUGIN_ID,
1172
+ index_default as default
1173
+ };