browse 0.8.4 → 0.8.5-alpha-8b83bb7
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 +4 -0
- package/dist/commands/screenshot.js +82 -11
- package/dist/hooks/command-not-found.js +45 -0
- package/dist/lib/command-suggestions.js +112 -0
- package/dist/lib/driver/command-cli.js +7 -2
- package/dist/lib/driver/commands/selectors.js +2 -1
- package/dist/lib/driver/daemon/client.js +13 -6
- package/dist/lib/driver/daemon/protocol.js +2 -0
- package/dist/lib/driver/daemon/server.js +14 -3
- package/dist/lib/driver/errors.js +16 -0
- package/dist/lib/driver/flags.js +13 -0
- package/dist/lib/driver/mode.js +60 -3
- package/dist/lib/driver/remote.disabled.js +15 -0
- package/dist/lib/driver/remote.js +35 -0
- package/dist/lib/driver/session-manager.js +90 -9
- package/dist/lib/skills/install.js +70 -13
- package/dist/lib/telemetry.js +20 -0
- package/oclif.manifest.json +741 -4
- package/package.json +7 -4
- package/skills/browse/SKILL.md +98 -22
package/README.md
CHANGED
|
@@ -57,10 +57,14 @@ Every driver command accepts the same flags to pick where the browser runs. Mix
|
|
|
57
57
|
| `--auto-connect` | Auto-discover and attach to a local Chrome with remote debugging enabled |
|
|
58
58
|
| `--cdp <url\|port>` | Attach directly to a CDP endpoint (port, `http(s)://`, or `ws(s)://`) |
|
|
59
59
|
| `--target-id <id>` | Select a specific CDP target when attaching to an existing browser |
|
|
60
|
+
| `--chrome-arg <flag>` | Append a Chrome launch arg on top of the defaults (repeatable, managed-local only) |
|
|
61
|
+
| `--ignore-default-chrome-arg <flag>` | Drop a specific Chrome default launch arg (repeatable, managed-local only) |
|
|
62
|
+
| `--no-default-chrome-args` | Launch without any of Chrome's default args (managed-local only) |
|
|
60
63
|
|
|
61
64
|
```bash
|
|
62
65
|
browse open https://example.com # default target
|
|
63
66
|
browse open https://example.com --local --headed
|
|
67
|
+
browse open https://example.com --local --headed --chrome-arg=--no-focus-on-navigate
|
|
64
68
|
browse open https://example.com --remote
|
|
65
69
|
browse open https://example.com --auto-connect
|
|
66
70
|
browse open https://example.com --cdp 9222
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Errors } from "@oclif/core";
|
|
2
|
+
import { suggestCommand } from "../lib/command-suggestions.js";
|
|
3
|
+
import { captureCommandNotFound } from "../lib/telemetry.js";
|
|
4
|
+
function toSpaced(commandId) {
|
|
5
|
+
return commandId.replaceAll(":", " ");
|
|
6
|
+
}
|
|
7
|
+
const hook = async function ({ config, id }) {
|
|
8
|
+
let attempted = "";
|
|
9
|
+
let suggestion = null;
|
|
10
|
+
try {
|
|
11
|
+
const commandIds = config.commands
|
|
12
|
+
.filter((command) => !command.hidden)
|
|
13
|
+
.map((command) => command.id);
|
|
14
|
+
const result = suggestCommand(id, commandIds);
|
|
15
|
+
attempted = result.attempted;
|
|
16
|
+
suggestion = result.suggestion;
|
|
17
|
+
if (suggestion &&
|
|
18
|
+
!config.findCommand(suggestion) &&
|
|
19
|
+
!config.findTopic(suggestion)) {
|
|
20
|
+
// Guards against alias targets drifting out of the command tree.
|
|
21
|
+
suggestion = null;
|
|
22
|
+
}
|
|
23
|
+
const displayAttempted = toSpaced(attempted || (id.split(":")[0] ?? id));
|
|
24
|
+
const didYouMean = suggestion
|
|
25
|
+
? ` Did you mean "${config.bin} ${toSpaced(suggestion)}"?`
|
|
26
|
+
: "";
|
|
27
|
+
process.stderr.write(`"${config.bin} ${displayAttempted}" is not a ${config.bin} command.${didYouMean} Run ${config.bin} --help for all commands.\n`);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// Suggestions are best-effort and must never mask the not-found error.
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
// Awaited so the event is delivered before the process exits; the
|
|
34
|
+
// transport aborts after a short timeout, so this cannot hang the CLI.
|
|
35
|
+
await captureCommandNotFound(config.version, attempted || null, suggestion);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// Best-effort telemetry should never affect CLI behavior.
|
|
39
|
+
}
|
|
40
|
+
// Re-throw oclif's standard not-found error. Returning normally from a
|
|
41
|
+
// command_not_found hook makes oclif treat the invocation as handled
|
|
42
|
+
// (exit 0), so this throw preserves the default error and exit code 2.
|
|
43
|
+
throw new Errors.CLIError(`command ${id} not found`);
|
|
44
|
+
};
|
|
45
|
+
export default hook;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Suggestion engine for unknown commands.
|
|
3
|
+
*
|
|
4
|
+
* oclif's spaced-topic parsing glues unknown leading argv tokens into the
|
|
5
|
+
* attempted command id (e.g. `browse opne https://example.com` arrives as
|
|
6
|
+
* `opne:https://example.com`), so the id may contain user-provided values.
|
|
7
|
+
* Everything here works on a sanitized token prefix and never returns raw
|
|
8
|
+
* argv content beyond the tokens that matched a known command shape.
|
|
9
|
+
*/
|
|
10
|
+
import { distance } from "fastest-levenshtein";
|
|
11
|
+
/**
|
|
12
|
+
* Old Commander-era syntax (and common agent guesses) mapped to the current
|
|
13
|
+
* command tree. Keys and values are colon-separated oclif ids; values may
|
|
14
|
+
* also be topics (e.g. `cloud:contexts`, which prints topic help).
|
|
15
|
+
*/
|
|
16
|
+
export const aliasSuggestions = new Map([
|
|
17
|
+
["sessions", "cloud:sessions:list"],
|
|
18
|
+
["sessions:list", "cloud:sessions:list"],
|
|
19
|
+
["sessions:create", "cloud:sessions:create"],
|
|
20
|
+
["sessions:get", "cloud:sessions:get"],
|
|
21
|
+
["projects", "cloud:projects:list"],
|
|
22
|
+
["projects:list", "cloud:projects:list"],
|
|
23
|
+
["contexts", "cloud:contexts"],
|
|
24
|
+
["extensions", "cloud:extensions"],
|
|
25
|
+
["search", "cloud:search"],
|
|
26
|
+
["fetch", "cloud:fetch"],
|
|
27
|
+
["goto", "open"],
|
|
28
|
+
["navigate", "open"],
|
|
29
|
+
]);
|
|
30
|
+
const safeTokenPattern = /^[A-Za-z][A-Za-z0-9_-]*$/;
|
|
31
|
+
const maxCommandTokens = 4;
|
|
32
|
+
const maxSuggestionDistance = 5;
|
|
33
|
+
/**
|
|
34
|
+
* Extracts the leading command-shaped tokens from an attempted id, stopping
|
|
35
|
+
* at the first token that does not look like a command word (URLs, selectors,
|
|
36
|
+
* flags, and other argument-like values).
|
|
37
|
+
*/
|
|
38
|
+
export function extractCommandTokens(id) {
|
|
39
|
+
const tokens = [];
|
|
40
|
+
for (const token of id.split(":")) {
|
|
41
|
+
if (tokens.length >= maxCommandTokens || !safeTokenPattern.test(token)) {
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
tokens.push(token.toLowerCase());
|
|
45
|
+
}
|
|
46
|
+
return tokens;
|
|
47
|
+
}
|
|
48
|
+
function tokenThreshold(token) {
|
|
49
|
+
return Math.max(2, Math.floor(token.length / 3));
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Segment-aligned fuzzy distance between a token prefix and a command id.
|
|
53
|
+
* Only command ids with the same number of segments are considered, and every
|
|
54
|
+
* token must be within its own edit-distance threshold of the corresponding
|
|
55
|
+
* segment. This means a trailing token can only ever be retained in the
|
|
56
|
+
* attempted command when it itself looks like a typo of a real command
|
|
57
|
+
* segment — free-form user values can never ride along.
|
|
58
|
+
*/
|
|
59
|
+
function prefixDistance(tokens, commandId) {
|
|
60
|
+
const segments = commandId.split(":");
|
|
61
|
+
if (segments.length !== tokens.length) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
let total = 0;
|
|
65
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
66
|
+
const token = tokens[i] ?? "";
|
|
67
|
+
const segmentDistance = distance(token, segments[i] ?? "");
|
|
68
|
+
if (segmentDistance > tokenThreshold(token)) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
total += segmentDistance;
|
|
72
|
+
}
|
|
73
|
+
return total > maxSuggestionDistance ? null : total;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Computes a suggestion for an unknown command id. Explicit aliases win over
|
|
77
|
+
* fuzzy matches, and longer token prefixes win over shorter ones so that
|
|
78
|
+
* `auth status` resolves before `auth`. Returns only the matched prefix as
|
|
79
|
+
* `attempted` (or the first token when nothing matches) so user-provided
|
|
80
|
+
* values never escape into messaging or telemetry.
|
|
81
|
+
*/
|
|
82
|
+
export function suggestCommand(id, commandIds) {
|
|
83
|
+
const tokens = extractCommandTokens(id);
|
|
84
|
+
if (tokens.length === 0) {
|
|
85
|
+
return { attempted: "", suggestion: null };
|
|
86
|
+
}
|
|
87
|
+
for (let length = tokens.length; length >= 1; length--) {
|
|
88
|
+
const attempted = tokens.slice(0, length).join(":");
|
|
89
|
+
const alias = aliasSuggestions.get(attempted);
|
|
90
|
+
if (alias) {
|
|
91
|
+
return { attempted, suggestion: alias };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
let best;
|
|
95
|
+
for (let length = tokens.length; length >= 1; length--) {
|
|
96
|
+
const prefix = tokens.slice(0, length);
|
|
97
|
+
for (const commandId of commandIds) {
|
|
98
|
+
const prefixDist = prefixDistance(prefix, commandId);
|
|
99
|
+
if (prefixDist !== null && (!best || prefixDist < best.distance)) {
|
|
100
|
+
best = {
|
|
101
|
+
attempted: prefix.join(":"),
|
|
102
|
+
suggestion: commandId,
|
|
103
|
+
distance: prefixDist,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (best) {
|
|
109
|
+
return { attempted: best.attempted, suggestion: best.suggestion };
|
|
110
|
+
}
|
|
111
|
+
return { attempted: tokens[0] ?? "", suggestion: null };
|
|
112
|
+
}
|
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
import { Flags } from "@oclif/core";
|
|
2
|
-
import { autoConnectFlag, cdpFlag, headedFlag, headlessFlag, localFlag, remoteFlag, sessionFlag, sessionName, targetIdFlag, } from "./flags.js";
|
|
2
|
+
import { autoConnectFlag, cdpFlag, chromeArgFlag, headedFlag, headlessFlag, ignoreDefaultChromeArgFlag, localFlag, noDefaultChromeArgsFlag, remoteFlag, sessionFlag, sessionName, targetIdFlag, } from "./flags.js";
|
|
3
3
|
import { getDriverStatus } from "./daemon/client.js";
|
|
4
|
-
import { resolveConnectionTarget } from "./mode.js";
|
|
4
|
+
import { hasChromeArgFlags, resolveConnectionTarget, } from "./mode.js";
|
|
5
5
|
import { outputJson } from "../output.js";
|
|
6
6
|
import { runDriverCommandWithTarget } from "./runtime.js";
|
|
7
7
|
export const driverCommandFlags = {
|
|
8
8
|
"auto-connect": autoConnectFlag,
|
|
9
9
|
cdp: cdpFlag,
|
|
10
|
+
"chrome-arg": chromeArgFlag,
|
|
10
11
|
headed: headedFlag,
|
|
11
12
|
headless: headlessFlag,
|
|
13
|
+
"ignore-default-chrome-arg": ignoreDefaultChromeArgFlag,
|
|
12
14
|
local: localFlag,
|
|
15
|
+
"no-default-chrome-args": noDefaultChromeArgsFlag,
|
|
13
16
|
remote: remoteFlag,
|
|
14
17
|
session: sessionFlag,
|
|
15
18
|
"target-id": targetIdFlag,
|
|
@@ -52,6 +55,7 @@ export function hasExplicitDriverTarget(flags) {
|
|
|
52
55
|
flags.remote ||
|
|
53
56
|
flags["auto-connect"] ||
|
|
54
57
|
flags.cdp ||
|
|
58
|
+
hasChromeArgFlags(flags) ||
|
|
55
59
|
flags["target-id"] ||
|
|
56
60
|
flags.headed ||
|
|
57
61
|
flags.headless);
|
|
@@ -60,6 +64,7 @@ function hasModeOnlyFlag(flags) {
|
|
|
60
64
|
return Boolean((flags.local || flags.remote) &&
|
|
61
65
|
!flags["auto-connect"] &&
|
|
62
66
|
!flags.cdp &&
|
|
67
|
+
!hasChromeArgFlags(flags) &&
|
|
63
68
|
!flags["target-id"] &&
|
|
64
69
|
!flags.headed &&
|
|
65
70
|
!flags.headless &&
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { DriverError } from "../errors.js";
|
|
1
2
|
export function emptyRefMaps() {
|
|
2
3
|
return { urlMap: {}, xpathMap: {} };
|
|
3
4
|
}
|
|
@@ -24,7 +25,7 @@ export function resolveSelector(selector, refMaps) {
|
|
|
24
25
|
return selector;
|
|
25
26
|
const xpath = refMaps.xpathMap[ref];
|
|
26
27
|
if (!xpath) {
|
|
27
|
-
throw new
|
|
28
|
+
throw new DriverError(`Unknown ref "${ref}" - run browse snapshot first to populate refs (have ${Object.keys(refMaps.xpathMap).length} refs).`, { code: "stale_ref" });
|
|
28
29
|
}
|
|
29
30
|
return xpath;
|
|
30
31
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { promises as fs } from "node:fs";
|
|
3
3
|
import net from "node:net";
|
|
4
|
-
import { fail } from "../../errors.js";
|
|
4
|
+
import { CommandFailure, fail } from "../../errors.js";
|
|
5
5
|
import { targetsCompatible } from "../mode.js";
|
|
6
6
|
import { cleanupDaemonFiles, ensureRuntimeDir, getLockPath, getPidPath, getSocketPath, PRIVATE_FILE_MODE, } from "./paths.js";
|
|
7
7
|
import { isProcessAlive } from "./process.js";
|
|
@@ -15,7 +15,7 @@ export async function ensureDriverDaemon({ session, target, }) {
|
|
|
15
15
|
}
|
|
16
16
|
const locked = await acquireLock(session);
|
|
17
17
|
if (!locked) {
|
|
18
|
-
fail(`Timed out waiting for driver daemon lock for session "${session}"
|
|
18
|
+
fail(`Timed out waiting for driver daemon lock for session "${session}".`, 1, { resultCode: "daemon_lock_timeout" });
|
|
19
19
|
}
|
|
20
20
|
try {
|
|
21
21
|
const afterLock = await tryDriverStatus(session);
|
|
@@ -24,7 +24,7 @@ export async function ensureDriverDaemon({ session, target, }) {
|
|
|
24
24
|
return;
|
|
25
25
|
}
|
|
26
26
|
if (await isDaemonPidAlive(session)) {
|
|
27
|
-
fail(`Driver daemon session "${session}" is running but not responding. Run browse stop --session ${session} --force to clean it up
|
|
27
|
+
fail(`Driver daemon session "${session}" is running but not responding. Run browse stop --session ${session} --force to clean it up.`, 1, { resultCode: "daemon_unresponsive" });
|
|
28
28
|
}
|
|
29
29
|
spawnDaemon(session, target);
|
|
30
30
|
await waitForSocketReady(getSocketPath(session), 30_000);
|
|
@@ -123,7 +123,7 @@ async function sendDriverRequest(session, request) {
|
|
|
123
123
|
return new Error(`Driver daemon session "${session}" closed ${detail}.`);
|
|
124
124
|
};
|
|
125
125
|
const timeout = setTimeout(() => {
|
|
126
|
-
failRequest(new
|
|
126
|
+
failRequest(new CommandFailure(`Timed out waiting for driver daemon session "${session}".`, 1, { resultCode: "daemon_socket_timeout" }));
|
|
127
127
|
}, 35_000);
|
|
128
128
|
socket.on("connect", () => {
|
|
129
129
|
socket.write(`${JSON.stringify(request)}\n`);
|
|
@@ -136,7 +136,12 @@ async function sendDriverRequest(session, request) {
|
|
|
136
136
|
try {
|
|
137
137
|
const response = ResponseSchema.parse(JSON.parse(buffer.slice(0, newline)));
|
|
138
138
|
if (response.type === "error") {
|
|
139
|
-
failRequest(new
|
|
139
|
+
failRequest(new CommandFailure(response.error, 1, {
|
|
140
|
+
...(response.code ? { resultCode: response.code } : {}),
|
|
141
|
+
...(response.httpStatus !== undefined
|
|
142
|
+
? { httpStatus: response.httpStatus }
|
|
143
|
+
: {}),
|
|
144
|
+
}));
|
|
140
145
|
return;
|
|
141
146
|
}
|
|
142
147
|
completeRequest(response.data);
|
|
@@ -161,7 +166,9 @@ async function sendDriverRequest(session, request) {
|
|
|
161
166
|
function spawnDaemon(session, target) {
|
|
162
167
|
const entrypoint = process.argv[1];
|
|
163
168
|
if (!entrypoint) {
|
|
164
|
-
fail("Unable to locate browse CLI entrypoint for daemon startup."
|
|
169
|
+
fail("Unable to locate browse CLI entrypoint for daemon startup.", 1, {
|
|
170
|
+
resultCode: "daemon_spawn_failed",
|
|
171
|
+
});
|
|
165
172
|
}
|
|
166
173
|
const child = spawn(process.execPath, [
|
|
167
174
|
entrypoint,
|
|
@@ -32,7 +32,9 @@ export const SuccessResponseSchema = z.object({
|
|
|
32
32
|
type: z.literal("success"),
|
|
33
33
|
});
|
|
34
34
|
export const ErrorResponseSchema = z.object({
|
|
35
|
+
code: z.string().optional(),
|
|
35
36
|
error: z.string(),
|
|
37
|
+
httpStatus: z.number().optional(),
|
|
36
38
|
id: z.string().optional(),
|
|
37
39
|
type: z.literal("error"),
|
|
38
40
|
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import net from "node:net";
|
|
2
2
|
import readline from "node:readline";
|
|
3
|
+
import { DriverError } from "../errors.js";
|
|
3
4
|
import { DriverSessionManager } from "../session-manager.js";
|
|
4
5
|
import { cleanupDaemonFiles, ensureRuntimeDir, getPidPath, getSocketPath, writePrivateFile, } from "./paths.js";
|
|
5
6
|
import { parseRequest, serializeResponse, } from "./protocol.js";
|
|
@@ -83,7 +84,7 @@ async function handleLine(line, socket, manager) {
|
|
|
83
84
|
request = parseRequest(line);
|
|
84
85
|
}
|
|
85
86
|
catch (error) {
|
|
86
|
-
await writeResponse(socket, {
|
|
87
|
+
await writeResponse(socket, { ...formatError(error), type: "error" });
|
|
87
88
|
return false;
|
|
88
89
|
}
|
|
89
90
|
try {
|
|
@@ -124,7 +125,7 @@ async function handleLine(line, socket, manager) {
|
|
|
124
125
|
}
|
|
125
126
|
catch (error) {
|
|
126
127
|
await writeResponse(socket, {
|
|
127
|
-
|
|
128
|
+
...formatError(error),
|
|
128
129
|
id: request.id,
|
|
129
130
|
type: "error",
|
|
130
131
|
});
|
|
@@ -161,5 +162,15 @@ function endSocket(socket) {
|
|
|
161
162
|
});
|
|
162
163
|
}
|
|
163
164
|
function formatError(error) {
|
|
164
|
-
|
|
165
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
166
|
+
if (error instanceof DriverError) {
|
|
167
|
+
return {
|
|
168
|
+
code: error.code,
|
|
169
|
+
error: message,
|
|
170
|
+
...(error.httpStatus !== undefined
|
|
171
|
+
? { httpStatus: error.httpStatus }
|
|
172
|
+
: {}),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
return { error: message };
|
|
165
176
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed driver error. The daemon serializes `code`/`httpStatus` into error
|
|
3
|
+
* responses so the client can record a telemetry result code and agents get
|
|
4
|
+
* a stable, machine-readable failure reason alongside the human message.
|
|
5
|
+
*/
|
|
6
|
+
export class DriverError extends Error {
|
|
7
|
+
code;
|
|
8
|
+
httpStatus;
|
|
9
|
+
constructor(message, options) {
|
|
10
|
+
super(message, options.cause === undefined ? {} : { cause: options.cause });
|
|
11
|
+
this.name = "DriverError";
|
|
12
|
+
this.code = options.code;
|
|
13
|
+
if (options.httpStatus !== undefined)
|
|
14
|
+
this.httpStatus = options.httpStatus;
|
|
15
|
+
}
|
|
16
|
+
}
|
package/dist/lib/driver/flags.js
CHANGED
|
@@ -27,6 +27,19 @@ export const targetIdFlag = Flags.string({
|
|
|
27
27
|
description: "Select a specific CDP target when attaching to an existing browser.",
|
|
28
28
|
helpValue: "<target-id>",
|
|
29
29
|
});
|
|
30
|
+
export const chromeArgFlag = Flags.string({
|
|
31
|
+
description: "Add a Chrome launch arg for managed local sessions. Repeatable.",
|
|
32
|
+
helpValue: "<flag>",
|
|
33
|
+
multiple: true,
|
|
34
|
+
});
|
|
35
|
+
export const ignoreDefaultChromeArgFlag = Flags.string({
|
|
36
|
+
description: "Drop one of Chrome's default launch args for managed local sessions. Repeatable.",
|
|
37
|
+
helpValue: "<flag>",
|
|
38
|
+
multiple: true,
|
|
39
|
+
});
|
|
40
|
+
export const noDefaultChromeArgsFlag = Flags.boolean({
|
|
41
|
+
description: "Launch managed local Chrome without any of its default launch args.",
|
|
42
|
+
});
|
|
30
43
|
export function sessionName(value) {
|
|
31
44
|
return value ?? process.env.BROWSE_SESSION ?? "default";
|
|
32
45
|
}
|
package/dist/lib/driver/mode.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from "node:util";
|
|
1
2
|
import { fail } from "../errors.js";
|
|
2
3
|
import { getRemote } from "./remote-binding.js";
|
|
3
4
|
import { resolveWsTarget } from "./resolve-ws.js";
|
|
@@ -11,10 +12,25 @@ function resolveHeadless(flags) {
|
|
|
11
12
|
return true;
|
|
12
13
|
return true;
|
|
13
14
|
}
|
|
15
|
+
export function hasChromeArgFlags(flags) {
|
|
16
|
+
return chromeArgFlagsInUse(flags).length > 0;
|
|
17
|
+
}
|
|
18
|
+
function chromeArgFlagsInUse(flags) {
|
|
19
|
+
const names = [];
|
|
20
|
+
if (flags["chrome-arg"]?.length)
|
|
21
|
+
names.push("--chrome-arg");
|
|
22
|
+
if (flags["ignore-default-chrome-arg"]?.length)
|
|
23
|
+
names.push("--ignore-default-chrome-arg");
|
|
24
|
+
if (flags["no-default-chrome-args"])
|
|
25
|
+
names.push("--no-default-chrome-args");
|
|
26
|
+
return names;
|
|
27
|
+
}
|
|
14
28
|
export async function resolveConnectionTarget(flags) {
|
|
29
|
+
const chromeArgFlags = chromeArgFlagsInUse(flags);
|
|
15
30
|
if (flags.cdp) {
|
|
16
31
|
failOnConflictingFlags("--cdp", [
|
|
17
32
|
flags["auto-connect"] ? "--auto-connect" : null,
|
|
33
|
+
...chromeArgFlags,
|
|
18
34
|
flags.local ? "--local" : null,
|
|
19
35
|
flags.remote ? "--remote" : null,
|
|
20
36
|
flags.headed ? "--headed" : null,
|
|
@@ -31,6 +47,7 @@ export async function resolveConnectionTarget(flags) {
|
|
|
31
47
|
}
|
|
32
48
|
if (flags["auto-connect"]) {
|
|
33
49
|
failOnConflictingFlags("--auto-connect", [
|
|
50
|
+
...chromeArgFlags,
|
|
34
51
|
flags.local ? "--local" : null,
|
|
35
52
|
flags.remote ? "--remote" : null,
|
|
36
53
|
flags.headed ? "--headed" : null,
|
|
@@ -43,36 +60,76 @@ export async function resolveConnectionTarget(flags) {
|
|
|
43
60
|
}
|
|
44
61
|
if (flags.remote) {
|
|
45
62
|
failOnConflictingFlags("--remote", [
|
|
63
|
+
...chromeArgFlags,
|
|
46
64
|
flags.headed ? "--headed" : null,
|
|
47
65
|
flags.headless ? "--headless" : null,
|
|
48
66
|
]);
|
|
49
67
|
return (await getRemote()).resolveExplicitRemoteTarget(flags);
|
|
50
68
|
}
|
|
51
69
|
if (flags.local) {
|
|
52
|
-
return
|
|
70
|
+
return managedLocalTarget(resolveHeadless(flags), resolveChromeArgs(flags));
|
|
53
71
|
}
|
|
54
72
|
const autoRemote = (await getRemote()).autoSelectRemoteTarget();
|
|
55
73
|
if (autoRemote) {
|
|
56
74
|
failOnConflictingFlags("remote mode", [
|
|
75
|
+
...chromeArgFlags,
|
|
57
76
|
flags.headed ? "--headed" : null,
|
|
58
77
|
flags.headless ? "--headless" : null,
|
|
59
78
|
]);
|
|
60
79
|
return autoRemote;
|
|
61
80
|
}
|
|
62
|
-
return
|
|
81
|
+
return managedLocalTarget(resolveHeadless(flags), resolveChromeArgs(flags));
|
|
82
|
+
}
|
|
83
|
+
function managedLocalTarget(headless, chromeArgs) {
|
|
84
|
+
return {
|
|
85
|
+
...(chromeArgs.args?.length ? { chromeArgs: chromeArgs.args } : {}),
|
|
86
|
+
...(chromeArgs.ignoreDefaultArgs !== undefined
|
|
87
|
+
? { ignoreDefaultArgs: chromeArgs.ignoreDefaultArgs }
|
|
88
|
+
: {}),
|
|
89
|
+
kind: "managed-local",
|
|
90
|
+
headless,
|
|
91
|
+
};
|
|
63
92
|
}
|
|
64
93
|
function failOnConflictingFlags(flag, candidates) {
|
|
65
94
|
const conflicts = candidates.filter((candidate) => Boolean(candidate));
|
|
66
95
|
if (conflicts.length > 0)
|
|
67
96
|
fail(`${flag} cannot be combined with ${conflicts.join(", ")}.`);
|
|
68
97
|
}
|
|
98
|
+
function resolveChromeArgs(flags) {
|
|
99
|
+
const args = flags["chrome-arg"]?.filter((arg) => arg.length > 0);
|
|
100
|
+
const ignoreDefaults = flags["ignore-default-chrome-arg"]?.filter((arg) => arg.length > 0);
|
|
101
|
+
const noDefaults = flags["no-default-chrome-args"] === true;
|
|
102
|
+
if (noDefaults && ignoreDefaults?.length) {
|
|
103
|
+
fail("--no-default-chrome-args cannot be combined with --ignore-default-chrome-arg.");
|
|
104
|
+
}
|
|
105
|
+
const resolved = {};
|
|
106
|
+
if (args?.length)
|
|
107
|
+
resolved.args = args;
|
|
108
|
+
if (noDefaults) {
|
|
109
|
+
resolved.ignoreDefaultArgs = true;
|
|
110
|
+
}
|
|
111
|
+
else if (ignoreDefaults?.length) {
|
|
112
|
+
resolved.ignoreDefaultArgs = ignoreDefaults;
|
|
113
|
+
}
|
|
114
|
+
return resolved;
|
|
115
|
+
}
|
|
69
116
|
export function targetsCompatible(left, right) {
|
|
70
117
|
if (left.kind !== right.kind)
|
|
71
118
|
return false;
|
|
72
119
|
if (left.kind === "managed-local" && right.kind === "managed-local")
|
|
73
|
-
return left.headless === right.headless
|
|
120
|
+
return (left.headless === right.headless &&
|
|
121
|
+
chromeArgsEqual(left.chromeArgs, right.chromeArgs) &&
|
|
122
|
+
ignoreDefaultArgsEqual(left.ignoreDefaultArgs, right.ignoreDefaultArgs));
|
|
74
123
|
if (left.kind === "cdp" && right.kind === "cdp") {
|
|
75
124
|
return left.endpoint === right.endpoint && left.targetId === right.targetId;
|
|
76
125
|
}
|
|
77
126
|
return true;
|
|
78
127
|
}
|
|
128
|
+
function chromeArgsEqual(left, right) {
|
|
129
|
+
return isDeepStrictEqual(left ?? [], right ?? []);
|
|
130
|
+
}
|
|
131
|
+
function ignoreDefaultArgsEqual(left, right) {
|
|
132
|
+
if (left === true || right === true)
|
|
133
|
+
return left === right;
|
|
134
|
+
return chromeArgsEqual(Array.isArray(left) ? left : undefined, Array.isArray(right) ? right : undefined);
|
|
135
|
+
}
|
|
@@ -13,6 +13,21 @@ export function autoSelectRemoteTarget() {
|
|
|
13
13
|
export function remoteStagehandOptions() {
|
|
14
14
|
throw new Error(DISABLED_MESSAGE);
|
|
15
15
|
}
|
|
16
|
+
export function classifyRemoteInitError(error) {
|
|
17
|
+
// Remote targets cannot be selected in a local-only build, so this is only
|
|
18
|
+
// reachable if a remote error somehow surfaces anyway; preserve it as-is.
|
|
19
|
+
return {
|
|
20
|
+
code: "remote_session_create_failed",
|
|
21
|
+
message: error instanceof Error ? error.message : String(error),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export function driverInitHints() {
|
|
25
|
+
// Key-free variants: a local-only artifact must not mention the API key.
|
|
26
|
+
return {
|
|
27
|
+
chromeNotFound: "No Chrome or Chromium found on this machine. Install one (Linux: apt install chromium \u00b7 macOS: brew install --cask google-chrome, or Chromium with CHROME_PATH set) or attach to a running browser with --cdp <port>.",
|
|
28
|
+
repeatedInitFailure: " (failing repeatedly — check your browser setup or run browse doctor)",
|
|
29
|
+
};
|
|
30
|
+
}
|
|
16
31
|
export function remoteDoctorCheck() {
|
|
17
32
|
return { ok: true, message: "remote disabled (local-only build)" };
|
|
18
33
|
}
|