browse 0.9.5 → 0.9.6

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
@@ -277,10 +277,19 @@ export BROWSERBASE_API_KEY=bb_live_...
277
277
 
278
278
  Local driver commands (`--local`) work without an API key.
279
279
 
280
+ `browse` also auto-loads a `.env` file from the current directory on startup, and prints a
281
+ one-time deprecation warning to stderr when it actually pulls a variable from `.env` this way.
282
+ This is deprecated because a CLI used across many unrelated projects can silently pick up a
283
+ stale key from the wrong project's `.env`; a future release will disable auto-loading by
284
+ default so `browse` only reads `process.env`. Set `BROWSE_LOAD_DOTENV=0` (or `false`/`no`) to
285
+ opt out of `.env` auto-loading now, ahead of that change, or `BROWSE_LOAD_DOTENV=1` to keep
286
+ auto-loading with no warning.
287
+
280
288
  | Variable | Description |
281
289
  |----------|-------------|
282
290
  | `BROWSERBASE_API_KEY` | Enables `--remote` sessions and all `browse cloud` / `functions` commands |
283
291
  | `BROWSE_SESSION` | Default session name (alternative to `-s, --session`) |
292
+ | `BROWSE_LOAD_DOTENV` | Controls `.env` auto-loading: unset = load + warn once (default, deprecated), `0`/`false`/`no` = skip loading, anything else = load silently |
284
293
 
285
294
  ## Links
286
295
 
package/bin/run.js CHANGED
@@ -1,5 +1,31 @@
1
1
  #!/usr/bin/env node
2
- import "dotenv/config";
2
+ import { config as loadDotenvConfig } from "dotenv";
3
+
4
+ // `BROWSE_LOAD_DOTENV` controls whether `browse` auto-loads a `.env` file
5
+ // from the current working directory. This is deprecated: a future release
6
+ // will flip the default to "off" so `browse` only reads `process.env`,
7
+ // matching how most CLIs used across many unrelated projects behave. Until
8
+ // then we keep loading `.env` by default (unset behaves exactly as before)
9
+ // but warn once when we actually pull a value from it, so users relying on
10
+ // the implicit default get a heads-up before the default changes.
11
+ const dotenvToggle = process.env.BROWSE_LOAD_DOTENV;
12
+ const dotenvOptedOut =
13
+ dotenvToggle !== undefined &&
14
+ ["0", "false", "no"].includes(dotenvToggle.toLowerCase());
15
+
16
+ if (!dotenvOptedOut) {
17
+ const keysBeforeLoad = new Set(Object.keys(process.env));
18
+ const { parsed } = loadDotenvConfig();
19
+ const appliedFromDotenv = Object.keys(parsed ?? {}).filter(
20
+ (key) => !keysBeforeLoad.has(key),
21
+ );
22
+
23
+ if (appliedFromDotenv.length > 0 && dotenvToggle === undefined) {
24
+ console.error(
25
+ `[browse] Loaded ${appliedFromDotenv.join(", ")} from .env. Auto-loading .env is deprecated and will be disabled by default in a future release -- export these variables in your shell instead, or set BROWSE_LOAD_DOTENV=1 to keep this behavior explicitly once that happens. Set BROWSE_LOAD_DOTENV=0 to opt out today. Run \`browse doctor\` to check for conflicts with your shell environment.`,
26
+ );
27
+ }
28
+ }
3
29
 
4
30
  globalThis.oclif = {
5
31
  ...globalThis.oclif,
@@ -1,19 +1,46 @@
1
- import { Args } from "@oclif/core";
1
+ import { Args, Flags } from "@oclif/core";
2
2
  import { createBrowserbaseClient, outputJson, withBrowserbaseApi, } from "../../../lib/cloud/api.js";
3
3
  import { apiCommonFlags, toApiOptions } from "../../../lib/cloud/flags.js";
4
+ import { reduceLogs } from "../../../lib/cloud/reduce-logs.js";
4
5
  import { BrowseCommand } from "../../../base.js";
5
6
  export default class SessionsLogs extends BrowseCommand {
6
7
  static description = "Get Browserbase session logs.";
7
- static examples = ["browse cloud sessions logs <session-id>"];
8
+ static examples = [
9
+ "browse cloud sessions logs <session-id>",
10
+ "browse cloud sessions logs <session-id> --only-errors",
11
+ "browse cloud sessions logs <session-id> --only-errors --failed-requests",
12
+ ];
8
13
  static args = {
9
14
  id: Args.string({ required: true, description: "Session ID." }),
10
15
  };
11
- static flags = { ...apiCommonFlags };
16
+ static flags = {
17
+ ...apiCommonFlags,
18
+ "only-errors": Flags.boolean({
19
+ description: "Return only the high-signal error records (console errors/exceptions, HTTP 4xx/5xx, network failures) instead of the full CDP firehose.",
20
+ default: false,
21
+ }),
22
+ "failed-requests": Flags.boolean({
23
+ description: "With --only-errors, narrow to failed / error-status network requests only.",
24
+ default: false,
25
+ dependsOn: ["only-errors"],
26
+ }),
27
+ };
12
28
  async run() {
13
29
  const { args, flags } = await this.parse(SessionsLogs);
14
30
  await withBrowserbaseApi("sessions", async () => {
15
31
  const client = createBrowserbaseClient(toApiOptions(flags));
16
- outputJson(await client.sessions.logs.list(args.id));
32
+ const raw = await client.sessions.logs.list(args.id);
33
+ if (flags["only-errors"]) {
34
+ const arr = Array.isArray(raw)
35
+ ? raw
36
+ : (raw?.data ?? []);
37
+ outputJson(reduceLogs(arr, {
38
+ failedRequests: flags["failed-requests"],
39
+ }));
40
+ }
41
+ else {
42
+ outputJson(raw);
43
+ }
17
44
  });
18
45
  }
19
46
  }
@@ -0,0 +1,107 @@
1
+ // Deterministic reducer for `browse cloud sessions logs --only-errors`.
2
+ // Turns the raw CDP firehose (~hundreds of events per session) into the high-signal error slice:
3
+ // console errors/warnings/asserts, uncaught exceptions, HTTP 4xx/5xx responses, and net-level load
4
+ // failures. No LLM — pure allowlist + severity/status filter + field projection + dedupe + stack trim.
5
+ function paramsOf(e) {
6
+ try {
7
+ const parsed = JSON.parse(e.request?.rawBody ?? "{}");
8
+ return parsed.params ?? {};
9
+ }
10
+ catch {
11
+ return {};
12
+ }
13
+ }
14
+ // Keep the message line + the app (`/src/`) stack frames; drop framework/vendor frames and hosts.
15
+ function trimStack(s) {
16
+ return (s || "")
17
+ .split("\n")
18
+ .filter((l, i) => i === 0 || (/\/src\//.test(l) && !/node_modules|\.vite/.test(l)))
19
+ .slice(0, 4)
20
+ .map((l) => l
21
+ .replace(/https?:\/\/[^/)]+/g, "")
22
+ .replace(/\?[^):]*/, "")
23
+ .trim())
24
+ .join("\n");
25
+ }
26
+ export function reduceLogs(raw, opts = {}) {
27
+ const out = [];
28
+ const seen = new Set();
29
+ const push = (rec) => {
30
+ const k = JSON.stringify(rec);
31
+ if (!seen.has(k)) {
32
+ seen.add(k);
33
+ out.push(rec);
34
+ }
35
+ };
36
+ for (const e of raw) {
37
+ const p = paramsOf(e);
38
+ const m = e.method;
39
+ const responseStatus = p.response?.status;
40
+ let rec = null;
41
+ if (m === "Runtime.consoleAPICalled" &&
42
+ typeof p.type === "string" &&
43
+ ["error", "warning", "assert"].includes(p.type)) {
44
+ const text = (Array.isArray(p.args) ? p.args : [])
45
+ .map((a) => a && typeof a === "object" ? a.description || a.value || "" : "")
46
+ .filter(Boolean)
47
+ .join(" ");
48
+ if (text && !/^%[os]/.test(text))
49
+ rec = {
50
+ kind: `console.${p.type}`,
51
+ domain: "Runtime",
52
+ severity: p.type,
53
+ text: trimStack(text),
54
+ };
55
+ }
56
+ else if (m === "Runtime.exceptionThrown") {
57
+ rec = {
58
+ kind: "exception",
59
+ domain: "Runtime",
60
+ severity: "error",
61
+ text: trimStack(p.exceptionDetails?.exception?.description ??
62
+ p.exceptionDetails?.text ??
63
+ ""),
64
+ };
65
+ }
66
+ else if (m === "Log.entryAdded" &&
67
+ typeof p.entry?.level === "string" &&
68
+ ["error", "warning"].includes(p.entry?.level)) {
69
+ rec = {
70
+ kind: `log.${p.entry.level}`,
71
+ domain: "Log",
72
+ severity: p.entry.level,
73
+ text: p.entry.text,
74
+ url: p.entry.url,
75
+ };
76
+ }
77
+ else if (m === "Network.responseReceived" &&
78
+ typeof responseStatus === "number" &&
79
+ responseStatus >= 400) {
80
+ rec = {
81
+ kind: "network",
82
+ domain: "Network",
83
+ status: responseStatus,
84
+ url: p.response?.url,
85
+ type: p.type,
86
+ };
87
+ }
88
+ else if (m === "Network.loadingFailed" &&
89
+ p.errorText !== "net::ERR_ABORTED") {
90
+ rec = {
91
+ kind: "network.failed",
92
+ domain: "Network",
93
+ error: p.errorText,
94
+ type: p.type,
95
+ };
96
+ }
97
+ else {
98
+ continue; // everything else (byte-chunk / lifecycle events) is noise
99
+ }
100
+ if (!rec)
101
+ continue; // e.g. a console.error whose text was empty / formatting noise
102
+ if (opts.failedRequests && rec.domain !== "Network")
103
+ continue;
104
+ push(rec);
105
+ }
106
+ return out;
107
+ }
@@ -70,6 +70,12 @@ export async function stopDriverDaemon(session, force = false) {
70
70
  });
71
71
  }
72
72
  catch (error) {
73
+ if (!force &&
74
+ error instanceof CommandFailure &&
75
+ error.telemetry.resultCode === "daemon_not_running") {
76
+ await cleanupStoppedDaemonFiles(session);
77
+ return { stopped: false };
78
+ }
73
79
  if (!force)
74
80
  throw error;
75
81
  await cleanupDaemonFiles(session);
@@ -154,6 +160,10 @@ async function sendDriverRequest(session, request) {
154
160
  }
155
161
  });
156
162
  socket.on("error", (error) => {
163
+ if (isDaemonUnavailableError(error)) {
164
+ failRequest(daemonNotRunningError(session, request));
165
+ return;
166
+ }
157
167
  failRequest(error);
158
168
  });
159
169
  socket.on("end", () => {
@@ -219,6 +229,20 @@ async function cleanupStaleDaemonFiles(session) {
219
229
  return;
220
230
  await cleanupDaemonFiles(session, { includeLock: false });
221
231
  }
232
+ async function cleanupStoppedDaemonFiles(session) {
233
+ const locked = await acquireLock(session);
234
+ if (!locked)
235
+ return;
236
+ try {
237
+ const status = await tryDriverStatus(session);
238
+ if (status?.session === session)
239
+ return;
240
+ await cleanupDaemonFiles(session, { includeLock: false });
241
+ }
242
+ finally {
243
+ await releaseLock(session);
244
+ }
245
+ }
222
246
  async function acquireLock(session, timeoutMs = 10_000) {
223
247
  const lockPath = getLockPath(session);
224
248
  const start = Date.now();
@@ -281,3 +305,20 @@ async function removeStaleLock(lockPath) {
281
305
  function requestId() {
282
306
  return `${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
283
307
  }
308
+ function isDaemonUnavailableError(error) {
309
+ const code = error.code;
310
+ return code === "ECONNREFUSED" || code === "ENOENT";
311
+ }
312
+ function daemonNotRunningError(session, request) {
313
+ const sessionFlag = session === "default" ? "" : ` --session ${formatCommandArgument(session)}`;
314
+ const startCommand = request.type === "open"
315
+ ? `browse open ${formatCommandArgument(request.url)}${sessionFlag}`
316
+ : `browse open <url>${sessionFlag}`;
317
+ return new CommandFailure(`Driver daemon session "${session}" is not running. Start it with: ${startCommand}`, 1, { resultCode: "daemon_not_running" });
318
+ }
319
+ function formatCommandArgument(value) {
320
+ if (/^[A-Za-z0-9_./:@%+=,-]+$/.test(value))
321
+ return value;
322
+ const escaped = value.replaceAll("'", "'\"'\"'");
323
+ return `'${escaped}'`;
324
+ }