browse 0.8.5 → 0.9.0-alpha-263e4d4
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 +10 -0
- package/dist/commands/cloud/sessions/create.js +35 -0
- package/dist/commands/screenshot.js +82 -11
- package/dist/lib/cloud/api.js +22 -0
- package/dist/lib/driver/remote.disabled.js +1 -1
- package/dist/lib/driver/remote.js +14 -2
- package/dist/lib/driver/session-manager.js +1 -1
- package/dist/lib/identity.js +257 -0
- package/dist/lib/skills/install.js +59 -8
- package/dist/lib/telemetry.js +2 -41
- package/oclif.manifest.json +15 -4
- package/package.json +2 -2
- package/skills/browse/SKILL.md +3 -2
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
|
}
|
|
@@ -1,13 +1,17 @@
|
|
|
1
|
+
import { closeSync, openSync, statSync, unlinkSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
1
3
|
import { Flags } from "@oclif/core";
|
|
2
4
|
import { BrowseCommand } from "../base.js";
|
|
3
5
|
import { driverCommandFlags, parseClip, runDriverCommandFromFlags, } from "../lib/driver/command-cli.js";
|
|
4
6
|
export default class Screenshot extends BrowseCommand {
|
|
5
7
|
static description = "Capture a screenshot of the active browser page.";
|
|
6
8
|
static examples = [
|
|
9
|
+
"browse screenshot",
|
|
7
10
|
"browse screenshot --path page.png",
|
|
8
|
-
"browse screenshot --full-page
|
|
11
|
+
"browse screenshot --full-page",
|
|
9
12
|
"browse screenshot --type jpeg --quality 80",
|
|
10
13
|
"browse screenshot --clip 0,0,800,600 --path clipped.png",
|
|
14
|
+
"browse screenshot --base64",
|
|
11
15
|
];
|
|
12
16
|
static flags = {
|
|
13
17
|
...driverCommandFlags,
|
|
@@ -15,6 +19,10 @@ export default class Screenshot extends BrowseCommand {
|
|
|
15
19
|
description: "Whether CSS animations run during capture.",
|
|
16
20
|
options: ["allow", "disabled"],
|
|
17
21
|
}),
|
|
22
|
+
base64: Flags.boolean({
|
|
23
|
+
description: "Print base64 to stdout instead of writing a file (legacy default).",
|
|
24
|
+
exclusive: ["path"],
|
|
25
|
+
}),
|
|
18
26
|
caret: Flags.string({
|
|
19
27
|
description: "Whether text caret is hidden during capture.",
|
|
20
28
|
options: ["hide", "initial"],
|
|
@@ -28,7 +36,7 @@ export default class Screenshot extends BrowseCommand {
|
|
|
28
36
|
}),
|
|
29
37
|
path: Flags.string({
|
|
30
38
|
char: "p",
|
|
31
|
-
description: "Write the screenshot to
|
|
39
|
+
description: "Write the screenshot to this file. Defaults to screenshot-<timestamp>.png (or .jpeg with --type jpeg) in the current directory.",
|
|
32
40
|
helpValue: "<path>",
|
|
33
41
|
}),
|
|
34
42
|
quality: Flags.integer({
|
|
@@ -42,14 +50,77 @@ export default class Screenshot extends BrowseCommand {
|
|
|
42
50
|
};
|
|
43
51
|
async run() {
|
|
44
52
|
const { flags } = await this.parse(Screenshot);
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
53
|
+
const defaultPath = getDefaultPathFromFlags(flags);
|
|
54
|
+
try {
|
|
55
|
+
await runDriverCommandFromFlags("screenshot", {
|
|
56
|
+
animations: flags.animations,
|
|
57
|
+
caret: flags.caret,
|
|
58
|
+
clip: parseClip(flags.clip),
|
|
59
|
+
fullPage: flags["full-page"],
|
|
60
|
+
path: flags.path ?? defaultPath,
|
|
61
|
+
quality: flags.quality,
|
|
62
|
+
type: flags.type,
|
|
63
|
+
}, flags);
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
if (defaultPath)
|
|
67
|
+
removeIfEmpty(defaultPath);
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// Generous upper bound on filename-collision retries; far beyond any real
|
|
73
|
+
// same-second burst, it just guarantees the loop below always terminates.
|
|
74
|
+
const MAX_RESERVE_ATTEMPTS = 1000;
|
|
75
|
+
/**
|
|
76
|
+
* Resolves the file the screenshot should be written to, or undefined when the
|
|
77
|
+
* driver should return base64 (explicit --path is handled separately; --base64
|
|
78
|
+
* opts out of a file entirely).
|
|
79
|
+
*/
|
|
80
|
+
function getDefaultPathFromFlags(flags) {
|
|
81
|
+
if (flags.path || flags.base64)
|
|
82
|
+
return undefined;
|
|
83
|
+
return reserveDefaultScreenshotPath(flags.type);
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Picks the next free screenshot-<timestamp>[-<counter>].<type> name in the
|
|
87
|
+
* current directory and reserves it. "Reserve" = create the file with an
|
|
88
|
+
* exclusive open (`wx` → O_CREAT|O_EXCL), which atomically fails with EEXIST if
|
|
89
|
+
* the name already exists, so two concurrent runs can never claim the same
|
|
90
|
+
* file. On EEXIST we advance the counter and try the next name.
|
|
91
|
+
*/
|
|
92
|
+
function reserveDefaultScreenshotPath(type) {
|
|
93
|
+
const now = new Date();
|
|
94
|
+
const pad = (value) => String(value).padStart(2, "0");
|
|
95
|
+
const stamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}` +
|
|
96
|
+
`-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
|
|
97
|
+
const extension = type === "jpeg" ? "jpeg" : "png";
|
|
98
|
+
for (let counter = 1; counter <= MAX_RESERVE_ATTEMPTS; counter += 1) {
|
|
99
|
+
const suffix = counter === 1 ? "" : `-${counter}`;
|
|
100
|
+
const candidate = resolve(`screenshot-${stamp}${suffix}.${extension}`);
|
|
101
|
+
try {
|
|
102
|
+
// Exclusive create reserves the name; close immediately since the driver
|
|
103
|
+
// (re)writes the file. The empty placeholder is cleaned up on failure.
|
|
104
|
+
closeSync(openSync(candidate, "wx"));
|
|
105
|
+
return candidate;
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
if (error.code !== "EEXIST")
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
throw new Error(`Could not reserve a screenshot filename after ${MAX_RESERVE_ATTEMPTS} attempts; pass --path to choose one.`);
|
|
113
|
+
}
|
|
114
|
+
// Removes the reserved placeholder when the screenshot failed before the driver
|
|
115
|
+
// wrote to it. `path` is always a file we created via openSync above, so the
|
|
116
|
+
// isFile guard is just defensive against an unexpected directory/symlink.
|
|
117
|
+
function removeIfEmpty(path) {
|
|
118
|
+
try {
|
|
119
|
+
const stats = statSync(path);
|
|
120
|
+
if (stats.isFile() && stats.size === 0)
|
|
121
|
+
unlinkSync(path);
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
// Best effort: leave the placeholder behind rather than mask the error.
|
|
54
125
|
}
|
|
55
126
|
}
|
package/dist/lib/cloud/api.js
CHANGED
|
@@ -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
|
|
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
|
+
}
|
|
@@ -13,6 +13,18 @@ const domainPattern = /^[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?$/;
|
|
|
13
13
|
const taskPattern = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/;
|
|
14
14
|
const maxSkillFiles = 100;
|
|
15
15
|
const maxCapturedOutputBytes = 2048;
|
|
16
|
+
const defaultInstallTimeoutMs = 180_000;
|
|
17
|
+
const defaultFetchTimeoutMs = 10_000;
|
|
18
|
+
function envTimeoutMs(name, fallback) {
|
|
19
|
+
const value = Number(process.env[name]);
|
|
20
|
+
return Number.isFinite(value) && value > 0 ? value : fallback;
|
|
21
|
+
}
|
|
22
|
+
function installTimeoutMs() {
|
|
23
|
+
return envTimeoutMs("BROWSE_SKILLS_INSTALL_TIMEOUT_MS", defaultInstallTimeoutMs);
|
|
24
|
+
}
|
|
25
|
+
function fetchTimeoutSignal() {
|
|
26
|
+
return AbortSignal.timeout(envTimeoutMs("BROWSE_SKILLS_FETCH_TIMEOUT_MS", defaultFetchTimeoutMs));
|
|
27
|
+
}
|
|
16
28
|
export function parseSkillId(rawSkillId) {
|
|
17
29
|
const parts = rawSkillId.split("/");
|
|
18
30
|
if (parts.length !== 2) {
|
|
@@ -103,10 +115,14 @@ export async function installBundledCliSkill() {
|
|
|
103
115
|
// message (including a tail of the child's output) when the child exits nonzero
|
|
104
116
|
// so the failure is recorded in telemetry instead of an opaque exit code.
|
|
105
117
|
async function runSkillsInstall(npxPath, args) {
|
|
106
|
-
const
|
|
107
|
-
|
|
118
|
+
const timeoutMs = installTimeoutMs();
|
|
119
|
+
const result = await spawnPassthrough(npxPath, args, timeoutMs);
|
|
120
|
+
if (result.exitCode === 0 && !result.timedOut) {
|
|
108
121
|
return;
|
|
109
122
|
}
|
|
123
|
+
if (result.timedOut) {
|
|
124
|
+
fail(`Skill install timed out after ${Math.round(timeoutMs / 1000)}s waiting for \`npx skills add\`. Check your network connection and rerun \`browse skills add\`.`, 1, { resultCode: "skill_install_timeout" });
|
|
125
|
+
}
|
|
110
126
|
const detail = result.output.trim();
|
|
111
127
|
const reason = detail
|
|
112
128
|
? `: ${detail}`
|
|
@@ -188,9 +204,11 @@ async function fetchSkillFilesFromApi(skillId) {
|
|
|
188
204
|
const url = skillFilesApiUrl(skillId);
|
|
189
205
|
let response;
|
|
190
206
|
try {
|
|
191
|
-
response = await fetch(url);
|
|
207
|
+
response = await fetch(url, { signal: fetchTimeoutSignal() });
|
|
192
208
|
}
|
|
193
209
|
catch {
|
|
210
|
+
// Network failures and timeouts both mean the catalog is unavailable;
|
|
211
|
+
// callers fall back (or fail cleanly) exactly as before.
|
|
194
212
|
return { status: "unavailable" };
|
|
195
213
|
}
|
|
196
214
|
if (response.status === 404) {
|
|
@@ -219,6 +237,7 @@ async function directBlobSkillExists(skillId) {
|
|
|
219
237
|
try {
|
|
220
238
|
response = await fetch(skillBlobUrl(skillId, "SKILL.md"), {
|
|
221
239
|
method: "HEAD",
|
|
240
|
+
signal: fetchTimeoutSignal(),
|
|
222
241
|
});
|
|
223
242
|
}
|
|
224
243
|
catch {
|
|
@@ -294,7 +313,7 @@ async function fetchSkillFile(url, label) {
|
|
|
294
313
|
async function fetchFromUrl(url, label) {
|
|
295
314
|
let response;
|
|
296
315
|
try {
|
|
297
|
-
response = await fetch(url);
|
|
316
|
+
response = await fetch(url, { signal: fetchTimeoutSignal() });
|
|
298
317
|
}
|
|
299
318
|
catch (error) {
|
|
300
319
|
fail(`Could not download ${label}: ${error.message}`);
|
|
@@ -347,15 +366,43 @@ async function findExecutable(command) {
|
|
|
347
366
|
}
|
|
348
367
|
return null;
|
|
349
368
|
}
|
|
369
|
+
// Quotes a single token for the command line Node hands to cmd.exe when
|
|
370
|
+
// `shell: true`. Node joins command+args UNQUOTED into `cmd.exe /d /s /c
|
|
371
|
+
// "..."`, so an unquoted `C:\Program Files\nodejs\npx.cmd` splits at the
|
|
372
|
+
// space and cmd runs `C:\Program`. Wrapping tokens that contain whitespace,
|
|
373
|
+
// quotes, or cmd metacharacters in double quotes (with embedded quotes
|
|
374
|
+
// doubled) keeps them intact; `/s` makes cmd strip only the outer quotes.
|
|
375
|
+
export function quoteForCmdShell(token) {
|
|
376
|
+
if (token === "") {
|
|
377
|
+
return '""';
|
|
378
|
+
}
|
|
379
|
+
return /[\s"^&|<>]/.test(token) ? `"${token.replaceAll('"', '""')}"` : token;
|
|
380
|
+
}
|
|
350
381
|
// Like `stdio: "inherit"` for the human watching, but the child's stdout/stderr
|
|
351
382
|
// are also buffered (tail only) so a nonzero exit can surface a real reason to
|
|
352
|
-
// telemetry instead of a bare exit code.
|
|
353
|
-
|
|
383
|
+
// telemetry instead of a bare exit code. The child is killed after `timeoutMs`
|
|
384
|
+
// (SIGTERM, then SIGKILL) so a hung `npx` cannot stall the install forever.
|
|
385
|
+
// Exported for tests.
|
|
386
|
+
export async function spawnPassthrough(command, args, timeoutMs = installTimeoutMs()) {
|
|
387
|
+
const useWindowsShell = shouldUseWindowsShell(command);
|
|
388
|
+
const spawnCommand = useWindowsShell ? quoteForCmdShell(command) : command;
|
|
389
|
+
const spawnArgs = useWindowsShell ? args.map(quoteForCmdShell) : args;
|
|
354
390
|
return await new Promise((resolvePromise) => {
|
|
355
|
-
const child = spawn(
|
|
391
|
+
const child = spawn(spawnCommand, spawnArgs, {
|
|
356
392
|
stdio: ["inherit", "pipe", "pipe"],
|
|
357
|
-
shell:
|
|
393
|
+
shell: useWindowsShell,
|
|
358
394
|
});
|
|
395
|
+
let timedOut = false;
|
|
396
|
+
let killTimer;
|
|
397
|
+
const deadline = setTimeout(() => {
|
|
398
|
+
timedOut = true;
|
|
399
|
+
child.kill("SIGTERM");
|
|
400
|
+
killTimer = setTimeout(() => child.kill("SIGKILL"), 5_000);
|
|
401
|
+
}, timeoutMs);
|
|
402
|
+
const clearTimers = () => {
|
|
403
|
+
clearTimeout(deadline);
|
|
404
|
+
clearTimeout(killTimer);
|
|
405
|
+
};
|
|
359
406
|
let captured = "";
|
|
360
407
|
const capture = (chunk) => {
|
|
361
408
|
captured += chunk.toString();
|
|
@@ -375,16 +422,20 @@ async function spawnPassthrough(command, args) {
|
|
|
375
422
|
// `skill_install_failed` by runSkillsInstall instead of escaping as an
|
|
376
423
|
// unclassified runtime error.
|
|
377
424
|
child.on("error", (error) => {
|
|
425
|
+
clearTimers();
|
|
378
426
|
const message = error instanceof Error ? error.message : String(error);
|
|
379
427
|
resolvePromise({
|
|
380
428
|
exitCode: 1,
|
|
381
429
|
output: captured ? `${captured}\n${message}` : message,
|
|
430
|
+
timedOut,
|
|
382
431
|
});
|
|
383
432
|
});
|
|
384
433
|
child.on("close", (exitCode, signal) => {
|
|
434
|
+
clearTimers();
|
|
385
435
|
resolvePromise({
|
|
386
436
|
exitCode: signal ? 1 : (exitCode ?? 0),
|
|
387
437
|
output: captured,
|
|
438
|
+
timedOut,
|
|
388
439
|
});
|
|
389
440
|
});
|
|
390
441
|
});
|
package/dist/lib/telemetry.js
CHANGED
|
@@ -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
|
-
?
|
|
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) ||
|
package/oclif.manifest.json
CHANGED
|
@@ -1716,10 +1716,12 @@
|
|
|
1716
1716
|
"args": {},
|
|
1717
1717
|
"description": "Capture a screenshot of the active browser page.",
|
|
1718
1718
|
"examples": [
|
|
1719
|
+
"browse screenshot",
|
|
1719
1720
|
"browse screenshot --path page.png",
|
|
1720
|
-
"browse screenshot --full-page
|
|
1721
|
+
"browse screenshot --full-page",
|
|
1721
1722
|
"browse screenshot --type jpeg --quality 80",
|
|
1722
|
-
"browse screenshot --clip 0,0,800,600 --path clipped.png"
|
|
1723
|
+
"browse screenshot --clip 0,0,800,600 --path clipped.png",
|
|
1724
|
+
"browse screenshot --base64"
|
|
1723
1725
|
],
|
|
1724
1726
|
"flags": {
|
|
1725
1727
|
"auto-connect": {
|
|
@@ -1810,6 +1812,15 @@
|
|
|
1810
1812
|
],
|
|
1811
1813
|
"type": "option"
|
|
1812
1814
|
},
|
|
1815
|
+
"base64": {
|
|
1816
|
+
"description": "Print base64 to stdout instead of writing a file (legacy default).",
|
|
1817
|
+
"exclusive": [
|
|
1818
|
+
"path"
|
|
1819
|
+
],
|
|
1820
|
+
"name": "base64",
|
|
1821
|
+
"allowNo": false,
|
|
1822
|
+
"type": "boolean"
|
|
1823
|
+
},
|
|
1813
1824
|
"caret": {
|
|
1814
1825
|
"description": "Whether text caret is hidden during capture.",
|
|
1815
1826
|
"name": "caret",
|
|
@@ -1837,7 +1848,7 @@
|
|
|
1837
1848
|
},
|
|
1838
1849
|
"path": {
|
|
1839
1850
|
"char": "p",
|
|
1840
|
-
"description": "Write the screenshot to
|
|
1851
|
+
"description": "Write the screenshot to this file. Defaults to screenshot-<timestamp>.png (or .jpeg with --type jpeg) in the current directory.",
|
|
1841
1852
|
"name": "path",
|
|
1842
1853
|
"hasDynamicHelp": false,
|
|
1843
1854
|
"helpValue": "<path>",
|
|
@@ -5992,5 +6003,5 @@
|
|
|
5992
6003
|
]
|
|
5993
6004
|
}
|
|
5994
6005
|
},
|
|
5995
|
-
"version": "0.
|
|
6006
|
+
"version": "0.9.0"
|
|
5996
6007
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "browse",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0-alpha-263e4d4",
|
|
4
4
|
"description": "Unified Browserbase CLI for browser automation and cloud APIs.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"private": false,
|
|
@@ -100,7 +100,7 @@
|
|
|
100
100
|
"tsx": "^4.20.6",
|
|
101
101
|
"ws": "^8.18.3",
|
|
102
102
|
"zod": "^4.2.1",
|
|
103
|
-
"@browserbasehq/stagehand": "3.
|
|
103
|
+
"@browserbasehq/stagehand": "3.6.0"
|
|
104
104
|
},
|
|
105
105
|
"devDependencies": {
|
|
106
106
|
"@eslint/js": "^10.0.1",
|
package/skills/browse/SKILL.md
CHANGED
|
@@ -139,8 +139,9 @@ browse get html "#main"
|
|
|
139
139
|
browse get value "#email"
|
|
140
140
|
browse get markdown body # page/element content as markdown
|
|
141
141
|
browse eval "document.title" # run JavaScript in the active page
|
|
142
|
-
browse screenshot #
|
|
143
|
-
browse screenshot --path page.png
|
|
142
|
+
browse screenshot # saves screenshot-<timestamp>.png, prints { "saved": "<path>" }
|
|
143
|
+
browse screenshot --path page.png # choose the output path
|
|
144
|
+
browse screenshot --base64 # legacy: print base64 JSON to stdout (avoid in agent loops)
|
|
144
145
|
```
|
|
145
146
|
|
|
146
147
|
Interaction:
|