browse 0.9.0 → 0.9.1

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/base.js CHANGED
@@ -1,7 +1,17 @@
1
1
  import { Command } from "@oclif/core";
2
2
  import { CommandFailure } from "./lib/errors.js";
3
+ import { setCliVersion } from "./lib/identity.js";
3
4
  import { recordCommandError } from "./lib/telemetry.js";
4
5
  export class BrowseCommand extends Command {
6
+ async init() {
7
+ await super.init();
8
+ // Seed the CLI version from oclif's Config (the single source of truth) so
9
+ // non-command contexts — remote session userMetadata and cloud API headers
10
+ // — can stamp the real version without any filesystem read. This runs in
11
+ // every process before run(), including the background `browse daemon` that
12
+ // creates Browserbase sessions, so cli_version never regresses to "unknown".
13
+ setCliVersion(this.config.version);
14
+ }
5
15
  async catch(err) {
6
16
  if (err instanceof CommandFailure) {
7
17
  recordCommandError("runtime", "COMMAND_FAILURE", err.telemetry);
@@ -2,6 +2,7 @@ import { Flags } from "@oclif/core";
2
2
  import { createBrowserbaseClient, deepMerge, outputJson, resolveBody, withBrowserbaseApi, } from "../../../lib/cloud/api.js";
3
3
  import { apiCommonFlags, toApiOptions } from "../../../lib/cloud/flags.js";
4
4
  import { fail } from "../../../lib/errors.js";
5
+ import { getCliVersion, resolveInstallId, toMetadataValue, } from "../../../lib/identity.js";
5
6
  import { BrowseCommand } from "../../../base.js";
6
7
  const REGIONS = [
7
8
  "us-west-2",
@@ -56,6 +57,39 @@ function buildSessionCreateBody(flags) {
56
57
  }
57
58
  return body;
58
59
  }
60
+ /**
61
+ * Stamp anonymous CLI attribution onto the session-create `userMetadata` so
62
+ * every CLI-created cloud session is attributable to the CLI (matching the
63
+ * driver `open --remote` path). Any user-supplied `userMetadata` (via --body or
64
+ * --stdin) is preserved; our attribution keys (browse_cli/install_id/
65
+ * cli_version) are authoritative and override caller values for those keys.
66
+ *
67
+ * Resolving the install id is best-effort and never throws; if it can't be
68
+ * resolved we still send browse_cli + cli_version. Values are run through
69
+ * toMetadataValue() so the session-create validator never 400s on a stray
70
+ * character or an over-length value.
71
+ */
72
+ async function applyCliAttribution(body) {
73
+ const rawExisting = body.userMetadata && typeof body.userMetadata === "object"
74
+ ? body.userMetadata
75
+ : {};
76
+ // Strip any caller-supplied install_id before merging so it cannot be
77
+ // spoofed when resolution fails (our authoritative value is set below).
78
+ const existing = Object.fromEntries(Object.entries(rawExisting).filter(([k]) => k !== "install_id"));
79
+ const userMetadata = {
80
+ ...existing,
81
+ browse_cli: "true",
82
+ cli_version: toMetadataValue(getCliVersion()),
83
+ };
84
+ const installId = await resolveInstallId(process.env).catch(() => undefined);
85
+ if (installId) {
86
+ const sanitized = toMetadataValue(installId);
87
+ if (sanitized) {
88
+ userMetadata.install_id = sanitized;
89
+ }
90
+ }
91
+ body.userMetadata = userMetadata;
92
+ }
59
93
  export default class SessionsCreate extends BrowseCommand {
60
94
  static description = "Create a new browser session. Use flags for common options, or --body/--stdin for the full API.";
61
95
  static examples = [
@@ -136,6 +170,7 @@ export default class SessionsCreate extends BrowseCommand {
136
170
  });
137
171
  const flagBody = buildSessionCreateBody(flags);
138
172
  const body = deepMerge(jsonBody, flagBody);
173
+ await applyCliAttribution(body);
139
174
  outputJson(await client.sessions.create(body));
140
175
  });
141
176
  }
@@ -4,10 +4,30 @@ import { access, mkdir, writeFile } from "node:fs/promises";
4
4
  import { dirname, resolve } from "node:path";
5
5
  import { Readable } from "node:stream";
6
6
  import { CommandFailure, fail } from "../errors.js";
7
+ import { getCliVersion, peekInstallId, resolveInstallId } from "../identity.js";
7
8
  import { setRunTelemetryCompletion } from "../run-telemetry.js";
8
9
  export { outputJson } from "../output.js";
9
10
  const defaultBrowserbaseApiUrl = "https://api.browserbase.com";
10
11
  const browserbaseSettingsUrl = "https://browserbase.com/settings";
12
+ // Kick off install-id resolution at module load so the value is usually cached
13
+ // by the time a cloud request runs. Resolution is best-effort and never throws.
14
+ void resolveInstallId(process.env).catch(() => undefined);
15
+ /**
16
+ * Attribution headers stamped onto cloud (Search/Fetch) requests so CLI usage
17
+ * is attributable to an install. `x-bb-client` is always sent;
18
+ * `x-bb-install-id` is included only when already resolved (we never block a
19
+ * request on disk I/O) and never sent as an empty string.
20
+ */
21
+ function attributionHeaders() {
22
+ const headers = {
23
+ "x-bb-client": `browse-cli/${getCliVersion()}`,
24
+ };
25
+ const installId = peekInstallId();
26
+ if (installId) {
27
+ headers["x-bb-install-id"] = installId;
28
+ }
29
+ return headers;
30
+ }
11
31
  export function resolveApiKey(args) {
12
32
  const apiKey = args.apiKey ?? process.env.BROWSERBASE_API_KEY;
13
33
  return (apiKey ||
@@ -32,6 +52,7 @@ export function createBrowserbaseClient(args) {
32
52
  return new Browserbase({
33
53
  apiKey: resolveApiKey(args),
34
54
  baseURL: resolveBaseUrl(args),
55
+ defaultHeaders: attributionHeaders(),
35
56
  });
36
57
  }
37
58
  export async function withBrowserbaseApi(command, operation) {
@@ -104,6 +125,7 @@ export async function requestBrowserbase(args, pathname, init = {}) {
104
125
  ...init,
105
126
  headers: {
106
127
  "x-bb-api-key": resolveApiKey(args),
128
+ ...attributionHeaders(),
107
129
  ...(init.headers ?? {}),
108
130
  },
109
131
  });
@@ -10,7 +10,7 @@ export function resolveExplicitRemoteTarget() {
10
10
  export function autoSelectRemoteTarget() {
11
11
  return null;
12
12
  }
13
- export function remoteStagehandOptions() {
13
+ export async function remoteStagehandOptions() {
14
14
  throw new Error(DISABLED_MESSAGE);
15
15
  }
16
16
  export function classifyRemoteInitError(error) {
@@ -1,4 +1,5 @@
1
1
  import { StatusCodes } from "http-status-codes";
2
+ import { getCliVersion, resolveInstallId, toMetadataValue, } from "../identity.js";
2
3
  /**
3
4
  * Real Browserbase capability. This is the ONLY module that reads
4
5
  * `BROWSERBASE_API_KEY`; it is excluded from `build:local-only` so that
@@ -10,15 +11,26 @@ export function resolveExplicitRemoteTarget() {
10
11
  export function autoSelectRemoteTarget() {
11
12
  return process.env.BROWSERBASE_API_KEY ? { kind: "remote" } : null;
12
13
  }
13
- export function remoteStagehandOptions() {
14
+ export async function remoteStagehandOptions() {
14
15
  const apiKey = process.env.BROWSERBASE_API_KEY;
15
16
  if (!apiKey) {
16
17
  throw new Error("Missing BROWSERBASE_API_KEY for remote mode. Pass --local to run a managed local browser (no key needed), or set BROWSERBASE_API_KEY for cloud sessions.");
17
18
  }
19
+ // Stamp anonymous attribution onto the session. Resolving the install id is
20
+ // best-effort and never throws; if it can't be resolved we still send
21
+ // browse_cli + cli_version so the session stays attributable to the CLI.
22
+ const userMetadata = {
23
+ browse_cli: "true",
24
+ cli_version: toMetadataValue(getCliVersion()),
25
+ };
26
+ const installId = await resolveInstallId(process.env).catch(() => undefined);
27
+ if (installId) {
28
+ userMetadata.install_id = toMetadataValue(installId);
29
+ }
18
30
  return {
19
31
  apiKey,
20
32
  browserbaseSessionCreateParams: {
21
- userMetadata: { browse_cli: "true" },
33
+ userMetadata,
22
34
  },
23
35
  disableAPI: true,
24
36
  disablePino: true,
@@ -262,7 +262,7 @@ export class DriverSessionManager {
262
262
  }
263
263
  async stagehandOptions(target) {
264
264
  if (target.kind === "remote") {
265
- return (await getRemote()).remoteStagehandOptions();
265
+ return await (await getRemote()).remoteStagehandOptions();
266
266
  }
267
267
  if (target.kind === "managed-local") {
268
268
  return {
@@ -0,0 +1,257 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdir, open, readFile, writeFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ /**
6
+ * Shared anonymous-install identity for the CLI.
7
+ *
8
+ * The install id is a stable, per-machine anonymous UUID persisted to a marker
9
+ * file. It is reused for (a) telemetry, (b) remote browser session
10
+ * `userMetadata`, and (c) cloud API request headers so that CLI-driven usage is
11
+ * attributable to a single install without identifying the user.
12
+ *
13
+ * The marker lives in the standardized Browserbase config dir on every platform
14
+ * (`(XDG_CONFIG_HOME||~/.config)/browserbase/install-id`, honoring
15
+ * `BROWSERBASE_CONFIG_DIR`), matching where core and the CLI's own
16
+ * skills/sessions already live. An earlier build wrote per-OS marker paths; the
17
+ * id is forward-migrated from those legacy locations on first resolution so an
18
+ * install keeps its stable id across the path change.
19
+ *
20
+ * Resolution is best-effort and must never throw: a read/write failure falls
21
+ * back to an in-memory UUID. After the first async resolution, the value is
22
+ * cached so it can be read synchronously via {@link peekInstallId}.
23
+ */
24
+ let cachedInstallId;
25
+ let inFlightResolution;
26
+ /**
27
+ * Resolve the anonymous install id, reading (or creating) the marker file.
28
+ * Memoizes the result so repeated calls share one resolution. Reads the
29
+ * canonical marker, forward-migrates an id from a legacy per-OS marker if one
30
+ * exists, mints a UUID on a true miss, and swallows write failures.
31
+ */
32
+ export async function resolveInstallId(env, fallbackId) {
33
+ if (cachedInstallId !== undefined) {
34
+ return cachedInstallId;
35
+ }
36
+ inFlightResolution ??= resolveAnonymousInstallId(env, fallbackId).then((id) => {
37
+ cachedInstallId = id;
38
+ return id;
39
+ });
40
+ return inFlightResolution;
41
+ }
42
+ /**
43
+ * Read the install id synchronously if it has already been resolved. Returns
44
+ * `undefined` when resolution has not completed yet — callers must not block on
45
+ * it (e.g. cloud API headers omit the id rather than wait for disk I/O).
46
+ */
47
+ export function peekInstallId() {
48
+ return cachedInstallId;
49
+ }
50
+ const INSTALL_ID_MAX_ATTEMPTS = 5;
51
+ /**
52
+ * Resolve (or create) the anonymous install id with a bounded, race-safe loop.
53
+ *
54
+ * The contract is: never return an id that wasn't persisted, and converge so
55
+ * concurrent first-run processes all settle on a single stable id. Resolution
56
+ * is best-effort and never throws — a hard FS failure (e.g. read-only volume)
57
+ * falls back to an in-memory id.
58
+ *
59
+ * Each attempt:
60
+ * 1. Read the marker; if it holds a non-empty id, that id won — use it.
61
+ * 2. Try an exclusive create (`open(path, "wx")`) and write our id; if the
62
+ * create succeeds we won the race — persist and return our id.
63
+ * 3. On EEXIST the file exists but was empty (another process created it and
64
+ * hasn't written yet, or a stale empty marker). Back off briefly and loop
65
+ * so the next read can pick up the winner's id.
66
+ * 4. On any non-EEXIST `open` error (e.g. EACCES/EROFS), the FS is unwritable;
67
+ * return the in-memory id without throwing.
68
+ *
69
+ * If every attempt sees an empty marker (no one ever wrote), we take ownership
70
+ * after the loop with a truncating `writeFile` so we still return a persisted
71
+ * id rather than a non-persistent one.
72
+ */
73
+ async function resolveAnonymousInstallId(env, fallbackId) {
74
+ const installIdPath = resolveInstallIdPath(env);
75
+ const installId = fallbackId ?? randomUUID();
76
+ // Carry an existing id forward from a legacy per-OS marker before minting a
77
+ // new one, so the path change never resets a stable install id. An explicit
78
+ // path override short-circuits migration entirely (tests / callers pinning a
79
+ // specific marker should not silently inherit a legacy id).
80
+ if (!env.BROWSERBASE_TELEMETRY_INSTALL_ID_FILE) {
81
+ const migrated = await migrateLegacyInstallId(env, installIdPath);
82
+ if (migrated) {
83
+ return migrated;
84
+ }
85
+ }
86
+ for (let attempt = 0; attempt < INSTALL_ID_MAX_ATTEMPTS; attempt++) {
87
+ // 1. Prefer an already-persisted, non-empty id.
88
+ try {
89
+ const existing = (await readFile(installIdPath, "utf8")).trim();
90
+ if (existing) {
91
+ return existing;
92
+ }
93
+ }
94
+ catch {
95
+ // No readable file yet — fall through to attempt an exclusive create.
96
+ }
97
+ // 2. Try to win the create race with an exclusive-create write.
98
+ try {
99
+ await mkdir(dirname(installIdPath), { recursive: true });
100
+ const fh = await open(installIdPath, "wx");
101
+ try {
102
+ await fh.writeFile(`${installId}\n`, "utf8");
103
+ }
104
+ finally {
105
+ await fh.close();
106
+ }
107
+ return installId;
108
+ }
109
+ catch (err) {
110
+ if (err.code !== "EEXIST") {
111
+ // 4. FS is unwritable (permissions, read-only) — best-effort fallback.
112
+ return installId;
113
+ }
114
+ // 3. File exists but was empty on our read. Another process is about to
115
+ // write it; back off and loop so the next read picks up its id.
116
+ await delay(1 << attempt); // ~1, 2, 4, 8 ms
117
+ }
118
+ }
119
+ // 5. The marker exists but stayed empty across every attempt (no winner ever
120
+ // wrote). Take ownership with a truncating write so we never return a
121
+ // non-persisted id.
122
+ try {
123
+ await writeFile(installIdPath, `${installId}\n`, "utf8");
124
+ }
125
+ catch {
126
+ // If even this fails, return the in-memory id without throwing.
127
+ }
128
+ return installId;
129
+ }
130
+ function delay(ms) {
131
+ return new Promise((resolve) => setTimeout(resolve, ms));
132
+ }
133
+ export function resolveInstallIdPath(env) {
134
+ const overriddenPath = env.BROWSERBASE_TELEMETRY_INSTALL_ID_FILE;
135
+ if (overriddenPath) {
136
+ // An explicit override short-circuits everything, including migration.
137
+ return overriddenPath;
138
+ }
139
+ return join(resolveConfigDir(env), "install-id");
140
+ }
141
+ /**
142
+ * The shared Browserbase config dir, matching core (`BROWSERBASE_CONFIG_DIR`)
143
+ * and the CLI's own skills/sessions locations. Honors BROWSERBASE_CONFIG_DIR
144
+ * when set (already includes the `browserbase` segment, e.g. `~/.config/browserbase`);
145
+ * otherwise `(XDG_CONFIG_HOME||~/.config)/browserbase` on every platform.
146
+ */
147
+ function resolveConfigDir(env) {
148
+ if (env.BROWSERBASE_CONFIG_DIR) {
149
+ return env.BROWSERBASE_CONFIG_DIR;
150
+ }
151
+ const xdg = env.XDG_CONFIG_HOME ?? join(homedir(), ".config");
152
+ return join(xdg, "browserbase");
153
+ }
154
+ /**
155
+ * The OLD per-platform marker locations the install id used to live in, in
156
+ * priority order. We always include the XDG-style legacy path on every platform
157
+ * (it makes the migration unit-testable via XDG_CONFIG_HOME), plus the
158
+ * platform-specific one. Deduped so linux (platform path == xdg path) yields one
159
+ * entry.
160
+ */
161
+ function legacyInstallIdPaths(env) {
162
+ const paths = [];
163
+ if (process.platform === "win32") {
164
+ const baseDir = env.APPDATA ?? env.LOCALAPPDATA ?? join(homedir(), "AppData", "Roaming");
165
+ paths.push(join(baseDir, "Browserbase", "cli", "telemetry-id"));
166
+ }
167
+ else if (process.platform === "darwin") {
168
+ paths.push(join(homedir(), "Library", "Application Support", "Browserbase", "cli", "telemetry-id"));
169
+ }
170
+ const xdg = env.XDG_CONFIG_HOME ?? join(homedir(), ".config");
171
+ paths.push(join(xdg, "browserbase", "cli", "telemetry-id"));
172
+ return [...new Set(paths)];
173
+ }
174
+ /**
175
+ * One-time path migration. If the canonical marker already exists, return its
176
+ * id. Otherwise, if a legacy per-platform marker holds an id, copy that id
177
+ * forward to the canonical path (best-effort, atomic exclusive-create) and
178
+ * return it, so the install keeps its stable id across the path change. Returns
179
+ * `undefined` when no id exists anywhere (truly first run) — the caller's create
180
+ * loop then mints a fresh id. Never throws. The legacy file is left in place
181
+ * (harmless, avoids a destructive op).
182
+ */
183
+ async function migrateLegacyInstallId(env, canonicalPath) {
184
+ try {
185
+ const existing = (await readFile(canonicalPath, "utf8")).trim();
186
+ if (existing) {
187
+ return existing;
188
+ }
189
+ }
190
+ catch {
191
+ // Canonical marker missing — fall through to the legacy lookup.
192
+ }
193
+ for (const legacyPath of legacyInstallIdPaths(env)) {
194
+ if (legacyPath === canonicalPath) {
195
+ continue;
196
+ }
197
+ let legacyId;
198
+ try {
199
+ legacyId = (await readFile(legacyPath, "utf8")).trim();
200
+ }
201
+ catch {
202
+ continue;
203
+ }
204
+ if (!legacyId) {
205
+ continue;
206
+ }
207
+ try {
208
+ await mkdir(dirname(canonicalPath), { recursive: true });
209
+ const fh = await open(canonicalPath, "wx");
210
+ try {
211
+ await fh.writeFile(`${legacyId}\n`, "utf8");
212
+ }
213
+ finally {
214
+ await fh.close();
215
+ }
216
+ }
217
+ catch {
218
+ // Race lost or FS unwritable — returning the legacy id still keeps it
219
+ // stable for this run.
220
+ }
221
+ return legacyId;
222
+ }
223
+ return undefined;
224
+ }
225
+ /**
226
+ * Sanitize a value for use in Browserbase `userMetadata`. The session-create
227
+ * validator only accepts characters matching `[\w\-_,;:.()&$%#@!?~]` and
228
+ * enforces a total length limit; this function strips everything else and
229
+ * truncates to `max` characters (default 64) so a semver `+build` suffix or
230
+ * any other unexpected character cannot cause a 400 on every remote session.
231
+ */
232
+ export function toMetadataValue(v, max = 64) {
233
+ return v.replace(/[^\w\-_,;:.()&$%#@!?~]/g, "").slice(0, max);
234
+ }
235
+ let cachedCliVersion;
236
+ /**
237
+ * Seed the CLI version from oclif's `Config.version` (the single source of
238
+ * truth). This is called once at startup from `BrowseCommand.init()` in base.ts
239
+ * — and because every command (including the background `browse daemon` that
240
+ * creates Browserbase sessions) extends `BrowseCommand`, the cache is populated
241
+ * in whichever process builds a session/header. Only truthy values are stored
242
+ * so a missing version leaves the `"unknown"` fallback intact.
243
+ */
244
+ export function setCliVersion(version) {
245
+ if (version) {
246
+ cachedCliVersion = version;
247
+ }
248
+ }
249
+ /**
250
+ * The CLI version for non-command contexts (remote session `userMetadata`,
251
+ * cloud API headers). It is seeded once from `Config.version` in base.ts at
252
+ * startup via {@link setCliVersion}; this reads back the cached value with no
253
+ * filesystem access. Falls back to `"unknown"` if it was never seeded.
254
+ */
255
+ export function getCliVersion() {
256
+ return cachedCliVersion ?? "unknown";
257
+ }
@@ -1,8 +1,5 @@
1
- import { randomUUID } from "node:crypto";
2
- import { mkdir, readFile, writeFile } from "node:fs/promises";
3
- import { homedir } from "node:os";
4
- import { dirname, join } from "node:path";
5
1
  import { detectAgent } from "./agent.js";
2
+ import { resolveInstallId } from "./identity.js";
6
3
  import { getRunTelemetry, resetRunTelemetry } from "./run-telemetry.js";
7
4
  const browserbaseTelemetrySource = "cli";
8
5
  const browserbaseTelemetryHost = "https://us.i.posthog.com";
@@ -76,7 +73,7 @@ function createCliTelemetry(options) {
76
73
  const transport = resolveTransportConfig(env);
77
74
  const telemetryEnabled = !isTelemetryDisabled(env);
78
75
  const distinctIdPromise = telemetryEnabled
79
- ? resolveAnonymousInstallId(env, options.sessionId)
76
+ ? resolveInstallId(env, options.sessionId)
80
77
  : Promise.resolve("");
81
78
  const agentPromise = telemetryEnabled ? detectAgent() : Promise.resolve(null);
82
79
  const baseProperties = {
@@ -237,42 +234,6 @@ function parseTimeoutMs(value) {
237
234
  function normalizeHost(host) {
238
235
  return host.endsWith("/") ? host.slice(0, -1) : host;
239
236
  }
240
- async function resolveAnonymousInstallId(env, fallbackId) {
241
- const installIdPath = resolveInstallIdPath(env);
242
- try {
243
- const existing = (await readFile(installIdPath, "utf8")).trim();
244
- if (existing) {
245
- return existing;
246
- }
247
- }
248
- catch {
249
- // Fall through and create a new anonymous install ID.
250
- }
251
- const installId = fallbackId ?? randomUUID();
252
- try {
253
- await mkdir(dirname(installIdPath), { recursive: true });
254
- await writeFile(installIdPath, `${installId}\n`, "utf8");
255
- }
256
- catch {
257
- // If persistence fails, continue with an in-memory anonymous ID.
258
- }
259
- return installId;
260
- }
261
- function resolveInstallIdPath(env) {
262
- const overriddenPath = env.BROWSERBASE_TELEMETRY_INSTALL_ID_FILE;
263
- if (overriddenPath) {
264
- return overriddenPath;
265
- }
266
- if (process.platform === "win32") {
267
- const baseDir = env.APPDATA ?? env.LOCALAPPDATA ?? join(homedir(), "AppData", "Roaming");
268
- return join(baseDir, "Browserbase", "cli", "telemetry-id");
269
- }
270
- if (process.platform === "darwin") {
271
- return join(homedir(), "Library", "Application Support", "Browserbase", "cli", "telemetry-id");
272
- }
273
- const baseDir = env.XDG_CONFIG_HOME ?? join(homedir(), ".config");
274
- return join(baseDir, "browserbase", "cli", "telemetry-id");
275
- }
276
237
  function isTelemetryDisabled(env) {
277
238
  return (envFlagEnabled(env.DO_NOT_TRACK) ||
278
239
  envFlagEnabled(env.BROWSERBASE_TELEMETRY_DISABLED) ||