opera-devtools-mcp 0.7.0 → 0.8.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/README.md +1 -1
- package/build/src/ToolHandler.js +9 -2
- package/build/src/bin/chrome-devtools.js +30 -97
- package/build/src/bin/opera-browser-cli.js +102 -0
- package/build/src/bin/opera-devtools-mcp.js +20 -1
- package/build/src/browser.js +18 -9
- package/build/src/daemon/client.js +46 -40
- package/build/src/daemon/daemon.js +62 -39
- package/build/src/opera/branding.js +4 -2
- package/build/src/opera/browserActivity.js +62 -0
- package/build/src/opera/browserCleanup.js +123 -0
- package/build/src/opera/browserErrors.js +66 -0
- package/build/src/opera/browserFlags.js +184 -38
- package/build/src/opera/browserTarget.js +513 -0
- package/build/src/opera/cdpErrors.js +391 -0
- package/build/src/opera/cliCommands.js +378 -0
- package/build/src/opera/cliOutput.js +284 -0
- package/build/src/opera/compactSnapshot.js +525 -0
- package/build/src/opera/config.js +166 -0
- package/build/src/opera/daemonLifecycle.js +257 -0
- package/build/src/opera/daemonLog.js +103 -0
- package/build/src/opera/daemonPidFile.js +83 -0
- package/build/src/opera/daemonShutdown.js +66 -0
- package/build/src/opera/daemonSocket.js +87 -0
- package/build/src/opera/daemonStreaming.js +130 -0
- package/build/src/opera/daemonToolCall.js +26 -0
- package/build/src/opera/detect.js +114 -0
- package/build/src/opera/doctor.js +317 -0
- package/build/src/opera/envConfig.js +229 -0
- package/build/src/opera/launcherNotice.js +116 -0
- package/build/src/opera/legacyBridgeCleanup.js +297 -0
- package/build/src/opera/logs.js +133 -0
- package/build/src/opera/mcpServerSupervisor.js +128 -0
- package/build/src/opera/migrationShared.js +164 -0
- package/build/src/opera/operaPages.js +56 -0
- package/build/src/opera/pageIdRouting.js +35 -0
- package/build/src/opera/pageRecovery.js +53 -0
- package/build/src/opera/profile.js +270 -0
- package/build/src/opera/refArgs.js +36 -0
- package/build/src/opera/serviceWorkerRetry.js +46 -4
- package/build/src/opera/setup.js +290 -0
- package/build/src/opera/skills/SKILL.md +160 -0
- package/build/src/opera/streamingTools.js +73 -0
- package/build/src/opera/suggestions.js +67 -0
- package/build/src/opera/toolHandlerHooks.js +25 -1
- package/build/src/opera/tools/opera.js +107 -38
- package/build/src/opera/urlResolver.js +69 -0
- package/build/src/opera/webStorageWarning.js +92 -0
- package/build/src/third_party/devtools-formatter-worker.js +1 -0
- package/build/src/third_party/devtools-heap-snapshot-worker.js +1 -0
- package/build/src/third_party/index.js +2 -1
- package/build/src/utils/url.js +6 -0
- package/build/src/version.js +1 -1
- package/package.json +12 -10
- package/build/src/bin/opera-devtools.js +0 -10
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ control and inspect a live browser. It acts as a Model-Context-Protocol
|
|
|
10
10
|
DevTools for reliable automation, in-depth debugging, and performance analysis.
|
|
11
11
|
When connected to Opera Neon, it also exposes Opera's built-in AI capabilities.
|
|
12
12
|
|
|
13
|
-
## [Tool reference](./docs/tool-reference.md) | [Changelog](./CHANGELOG.md) | [Contributing](./CONTRIBUTING.md) | [Troubleshooting](./docs/troubleshooting.md) | [Design Principles](./docs/design-principles.md)
|
|
13
|
+
## [Tool reference](./docs/tool-reference.md) | [Changelog](./CHANGELOG.md) | [Contributing](./CONTRIBUTING.md) | [Troubleshooting](./docs/troubleshooting.md) | [Design Principles](./docs/design-principles.md) | [Stress testing](./docs/stress-testing.md)
|
|
14
14
|
|
|
15
15
|
## Key features
|
|
16
16
|
|
package/build/src/ToolHandler.js
CHANGED
|
@@ -5,10 +5,13 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Modified by Opera Software AS: optional `hooks` seam (see
|
|
7
7
|
* ./opera/toolHandlerHooks.ts) for mutex bypass, browser relaunch and log
|
|
8
|
-
* streaming
|
|
8
|
+
* streaming, plus one page-resolution call that tolerates a selection the user
|
|
9
|
+
* closed (see ./opera/pageRecovery.ts). Keep the diff to the three
|
|
10
|
+
* `this.hooks?.` call sites and that line.
|
|
9
11
|
*/
|
|
10
12
|
import { McpResponse } from './McpResponse.js';
|
|
11
13
|
import { CLI_BIN_NAME } from './opera/branding.js';
|
|
14
|
+
import { resolveSelectedPage } from './opera/pageRecovery.js';
|
|
12
15
|
import { SlimMcpResponse } from './SlimMcpResponse.js';
|
|
13
16
|
import { ClearcutLogger } from './telemetry/ClearcutLogger.js';
|
|
14
17
|
import { zod } from './third_party/index.js';
|
|
@@ -204,7 +207,7 @@ export class ToolHandler {
|
|
|
204
207
|
pageId !== undefined &&
|
|
205
208
|
!this.serverArgs.slim
|
|
206
209
|
? context.getPageById(pageId)
|
|
207
|
-
: context
|
|
210
|
+
: await resolveSelectedPage(context, response);
|
|
208
211
|
response.setPage(page);
|
|
209
212
|
if (this.tool.blockedByDialog) {
|
|
210
213
|
page.throwIfDialogOpen();
|
|
@@ -265,6 +268,10 @@ export class ToolHandler {
|
|
|
265
268
|
};
|
|
266
269
|
}
|
|
267
270
|
finally {
|
|
271
|
+
// Before telemetry and the mutex release: the claim `beforeInvoke` took
|
|
272
|
+
// has to be released even when the invocation failed, or the next Opera
|
|
273
|
+
// AI tool waits on a browser this one only looks like it is using.
|
|
274
|
+
this.hooks?.afterInvoke(this.tool);
|
|
268
275
|
void ClearcutLogger.get()?.logToolInvocation({
|
|
269
276
|
toolName: this.tool.name,
|
|
270
277
|
params,
|
|
@@ -7,10 +7,14 @@
|
|
|
7
7
|
* Modified by Opera Software AS.
|
|
8
8
|
*/
|
|
9
9
|
import process from 'node:process';
|
|
10
|
-
import { startDaemon, stopDaemon, sendCommand
|
|
10
|
+
import { startDaemon, stopDaemon, sendCommand } from '../daemon/client.js';
|
|
11
11
|
import { isDaemonRunning, serializeArgs, assertValidSessionId, } from '../daemon/utils.js';
|
|
12
12
|
import { logDisclaimers } from '../index.js';
|
|
13
13
|
import { CLI_BIN_NAME, MCP_BIN_NAME, PACKAGE_NAME } from '../opera/branding.js';
|
|
14
|
+
import { describeBrowserMode } from '../opera/browserFlags.js';
|
|
15
|
+
import { EXIT_CODES } from '../opera/cdpErrors.js';
|
|
16
|
+
import { registerOperaCommands, registerToolCommand, } from '../opera/cliCommands.js';
|
|
17
|
+
import { launcherMigrationNotice } from '../opera/launcherNotice.js';
|
|
14
18
|
import { hideBin, yargs } from '../third_party/index.js';
|
|
15
19
|
import { checkForUpdates } from '../utils/check-for-updates.js';
|
|
16
20
|
import { VERSION } from '../version.js';
|
|
@@ -19,6 +23,13 @@ import { mcpOptions, parseArguments, getMcpOptionsForViaCli, } from '../config/m
|
|
|
19
23
|
process.title = CLI_BIN_NAME;
|
|
20
24
|
await checkForUpdates(`Run \`npm install -g ${PACKAGE_NAME}@latest\` and \`${CLI_BIN_NAME} start\` to update and restart the daemon.`);
|
|
21
25
|
const DEFAULT_CLI_ARGS = ['--viaCli'];
|
|
26
|
+
// `--version` is also what a user runs when something looks wrong, so it has to
|
|
27
|
+
// carry the phase-2 recipe when the compatibility launcher is what ran this CLI
|
|
28
|
+
// (`src/opera/launcherNotice.ts`). Without the marker the banner is the plain
|
|
29
|
+
// version, exactly as before.
|
|
30
|
+
const versionBanner = [VERSION, launcherMigrationNotice()]
|
|
31
|
+
.filter(Boolean)
|
|
32
|
+
.join('\n\n');
|
|
22
33
|
async function start(args, sessionId) {
|
|
23
34
|
const combinedArgs = [...DEFAULT_CLI_ARGS, ...args];
|
|
24
35
|
await startDaemon(combinedArgs, sessionId);
|
|
@@ -52,7 +63,7 @@ const y = yargs(hideBin(process.argv))
|
|
|
52
63
|
},
|
|
53
64
|
})
|
|
54
65
|
.demandCommand()
|
|
55
|
-
.version(
|
|
66
|
+
.version(versionBanner)
|
|
56
67
|
.strict()
|
|
57
68
|
.help(true)
|
|
58
69
|
.wrap(120)
|
|
@@ -64,20 +75,22 @@ const y = yargs(hideBin(process.argv))
|
|
|
64
75
|
msg.includes('Unknown arguments')) {
|
|
65
76
|
console.error('\n=========================================');
|
|
66
77
|
console.error('💡 TIP FOR AI AGENT / DEVELOPER:');
|
|
67
|
-
console.error(
|
|
78
|
+
console.error(`In the \`${CLI_BIN_NAME}\` CLI:`);
|
|
68
79
|
console.error('1. Required parameters MUST be passed as positional arguments (without flags).');
|
|
69
|
-
console.error(
|
|
70
|
-
console.error(
|
|
80
|
+
console.error(` - INCORRECT: ${CLI_BIN_NAME} click --pageId 1 --uid "1_2"`);
|
|
81
|
+
console.error(` - CORRECT: ${CLI_BIN_NAME} click 1 "1_2"`);
|
|
71
82
|
console.error('2. Optional parameters are passed as double-dash options/flags (e.g. --dblClick true).');
|
|
72
83
|
console.error('3. Make sure to escape quotes properly for your shell environment.');
|
|
73
|
-
console.error(
|
|
84
|
+
console.error(`Run \`${CLI_BIN_NAME} <command> --help\` to see exact positional and optional parameters.`);
|
|
74
85
|
console.error('=========================================');
|
|
75
86
|
}
|
|
76
87
|
}
|
|
77
88
|
else if (err) {
|
|
78
89
|
console.error(err);
|
|
79
90
|
}
|
|
80
|
-
|
|
91
|
+
// A parse failure is exactly what exit code 2 means — "fix the command" — so
|
|
92
|
+
// it is reported as one rather than as an unknown failure.
|
|
93
|
+
process.exit(msg ? EXIT_CODES.VALIDATION_ERROR : EXIT_CODES.UNKNOWN);
|
|
81
94
|
});
|
|
82
95
|
y.command('start', `Start or restart ${MCP_BIN_NAME}`, y => y
|
|
83
96
|
.options(getCliOptions())
|
|
@@ -113,9 +126,12 @@ y.command('status', `Checks if ${MCP_BIN_NAME} is running`, y => y, async (argv)
|
|
|
113
126
|
if (response.success) {
|
|
114
127
|
const data = JSON.parse(response.result);
|
|
115
128
|
console.log(`pid=${data.pid} socket=${data.socketPath} start-date=${data.startDate} version=${data.version}`);
|
|
129
|
+
// Who owns the browser decides what recovery to expect from it: we
|
|
130
|
+
// relaunch what we launched, and never touch a browser we attached to.
|
|
131
|
+
console.log(`browser=${describeBrowserMode(data.args)}`);
|
|
116
132
|
console.log(`args=${JSON.stringify(data.args)}`);
|
|
117
133
|
if (data.version !== VERSION) {
|
|
118
|
-
console.warn(`Warning: Daemon server version (${data.version}) does not match CLI version (${VERSION}). Run '
|
|
134
|
+
console.warn(`Warning: Daemon server version (${data.version}) does not match CLI version (${VERSION}). Run '${CLI_BIN_NAME} start' to update and restart the daemon.`);
|
|
119
135
|
}
|
|
120
136
|
}
|
|
121
137
|
else {
|
|
@@ -136,96 +152,13 @@ y.command('stop', `Stop ${MCP_BIN_NAME} if any`, y => y, async (argv) => {
|
|
|
136
152
|
await stopDaemon(sessionId);
|
|
137
153
|
process.exit(0);
|
|
138
154
|
});
|
|
155
|
+
// The fork's own commands (`setup`, `doctor`, `logs`, `url`) and the wrapper
|
|
156
|
+
// that turns a generated tool definition into a runnable command live in
|
|
157
|
+
// `src/opera/cliCommands.ts`, along with the exit-code and streaming plumbing
|
|
158
|
+
// of a tool call. `start` stays here: it is upstream's, and prepends `--viaCli`.
|
|
159
|
+
registerOperaCommands(y, { start });
|
|
139
160
|
for (const [commandName, commandDef] of Object.entries(commands)) {
|
|
140
|
-
|
|
141
|
-
const requiredArgNames = Object.keys(args).filter(name => args[name].required);
|
|
142
|
-
const optionalArgNames = Object.keys(args).filter(name => !args[name].required);
|
|
143
|
-
let commandStr = commandName;
|
|
144
|
-
for (const arg of requiredArgNames) {
|
|
145
|
-
commandStr += ` <${arg}>`;
|
|
146
|
-
}
|
|
147
|
-
for (const arg of optionalArgNames) {
|
|
148
|
-
commandStr += ` [--${arg}]`;
|
|
149
|
-
}
|
|
150
|
-
y.command(commandStr, commandDef.description, y => {
|
|
151
|
-
y.option('output-format', {
|
|
152
|
-
choices: ['md', 'json'],
|
|
153
|
-
default: 'md',
|
|
154
|
-
});
|
|
155
|
-
for (const [argName, opt] of Object.entries(args)) {
|
|
156
|
-
const type = opt.type === 'integer' || opt.type === 'number'
|
|
157
|
-
? 'number'
|
|
158
|
-
: opt.type === 'boolean'
|
|
159
|
-
? 'boolean'
|
|
160
|
-
: opt.type === 'array'
|
|
161
|
-
? 'array'
|
|
162
|
-
: 'string';
|
|
163
|
-
if (opt.required) {
|
|
164
|
-
const options = {
|
|
165
|
-
describe: opt.description,
|
|
166
|
-
type: type,
|
|
167
|
-
};
|
|
168
|
-
if (opt.default !== undefined) {
|
|
169
|
-
options.default = opt.default;
|
|
170
|
-
}
|
|
171
|
-
if (opt.enum) {
|
|
172
|
-
options.choices = opt.enum;
|
|
173
|
-
}
|
|
174
|
-
y.positional(argName, options);
|
|
175
|
-
}
|
|
176
|
-
else {
|
|
177
|
-
const options = {
|
|
178
|
-
describe: opt.description,
|
|
179
|
-
type: type,
|
|
180
|
-
};
|
|
181
|
-
if (opt.default !== undefined) {
|
|
182
|
-
options.default = opt.default;
|
|
183
|
-
}
|
|
184
|
-
if (opt.enum) {
|
|
185
|
-
options.choices = opt.enum;
|
|
186
|
-
}
|
|
187
|
-
y.option(argName, options);
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
}, async (argv) => {
|
|
191
|
-
const sessionId = argv.sessionId;
|
|
192
|
-
try {
|
|
193
|
-
const versionWarningPromise = isDaemonRunning(sessionId)
|
|
194
|
-
? verifyDaemonVersion(sessionId, VERSION)
|
|
195
|
-
: Promise.resolve(undefined);
|
|
196
|
-
if (!isDaemonRunning(sessionId)) {
|
|
197
|
-
await start(serializeArgs(mcpOptions, argv), sessionId);
|
|
198
|
-
}
|
|
199
|
-
const commandArgs = {};
|
|
200
|
-
for (const argName of Object.keys(args)) {
|
|
201
|
-
if (argName in argv) {
|
|
202
|
-
commandArgs[argName] = argv[argName];
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
const response = await sendCommand({
|
|
206
|
-
method: 'invoke_tool',
|
|
207
|
-
tool: commandName,
|
|
208
|
-
args: commandArgs,
|
|
209
|
-
}, sessionId);
|
|
210
|
-
if (response.success) {
|
|
211
|
-
console.log(await handleResponse(JSON.parse(response.result), argv['output-format']));
|
|
212
|
-
}
|
|
213
|
-
else {
|
|
214
|
-
console.error('Error:', response.error);
|
|
215
|
-
}
|
|
216
|
-
const versionWarning = await versionWarningPromise;
|
|
217
|
-
if (versionWarning) {
|
|
218
|
-
console.warn(versionWarning);
|
|
219
|
-
}
|
|
220
|
-
if (!response.success) {
|
|
221
|
-
process.exit(1);
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
catch (error) {
|
|
225
|
-
console.error('Failed to execute command:', error);
|
|
226
|
-
process.exit(1);
|
|
227
|
-
}
|
|
228
|
-
});
|
|
161
|
+
registerToolCommand(y, commandName, commandDef, { start });
|
|
229
162
|
}
|
|
230
163
|
await y.parse();
|
|
231
164
|
//# sourceMappingURL=chrome-devtools.js.map
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @license
|
|
4
|
+
* Copyright 2026 Opera Software AS.
|
|
5
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
6
|
+
*/
|
|
7
|
+
// Opera-named bin entry (see `package.json` `bin`). The implementation lives in
|
|
8
|
+
// the upstream-owned file and is branded via `src/opera/branding.ts`.
|
|
9
|
+
//
|
|
10
|
+
// The guard comes FIRST, and has to stay first: it installs on import, and the
|
|
11
|
+
// imports below it reach `third_party`, whose bundled `debug` reads the
|
|
12
|
+
// `localStorage` global and prints Node's Web Storage warning. An import listed
|
|
13
|
+
// before this one that reaches `third_party` puts the warning back in front of
|
|
14
|
+
// every command's output.
|
|
15
|
+
import { preloadWebStorageWarningGuardInChildren } from '../opera/webStorageWarning.js';
|
|
16
|
+
import { CLI_BIN_NAME } from '../opera/branding.js';
|
|
17
|
+
import { extractTakeoverFlag, preflightBrowser, sessionIdFromArgv, } from '../opera/browserTarget.js';
|
|
18
|
+
import { formatError } from '../opera/cliOutput.js';
|
|
19
|
+
import { autoConfigure, shouldAutoConfigure } from '../opera/config.js';
|
|
20
|
+
import { applyEnvToArgv, loadOperaCliConfig } from '../opera/envConfig.js';
|
|
21
|
+
import { runLegacyMigrationGuard } from '../opera/legacyBridgeCleanup.js';
|
|
22
|
+
/**
|
|
23
|
+
* Configure a fresh machine in place, without asking. Skips pure queries and
|
|
24
|
+
* the (future) `setup`/`logs` commands; reports what it did on stderr so a
|
|
25
|
+
* scripted user sees the same first-run note as opera-browser-cli.
|
|
26
|
+
*/
|
|
27
|
+
function ensureConfigured(argv) {
|
|
28
|
+
if (!shouldAutoConfigure(argv)) {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
const command = argv[2];
|
|
32
|
+
const result = autoConfigure();
|
|
33
|
+
if (result.status === 'configured') {
|
|
34
|
+
const ai = result.browser.isNeon
|
|
35
|
+
? 'Opera AI available'
|
|
36
|
+
: 'chat only — install Opera Neon for invoke-do/make/research';
|
|
37
|
+
process.stderr.write(`configured: ${result.browser.name} (${ai}) — run \`${CLI_BIN_NAME} setup\` to change\n`);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (result.status === 'no-browser' && command !== 'doctor') {
|
|
41
|
+
process.stderr.write(`hint: no Opera installation found — run \`${CLI_BIN_NAME} setup\`, or set OPERA_CLI_EXECUTABLE_PATH\n`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
// Promote ~/.opera-browser-cli/config into process.env (and warn on unknown
|
|
45
|
+
// keys) before the CLI spawns the daemon, which inherits this environment.
|
|
46
|
+
loadOperaCliConfig();
|
|
47
|
+
// A machine that upgraded from the two-package era may still have the old HTTP
|
|
48
|
+
// bridge running, holding port 9225 and a browser of its own. Nothing runs at
|
|
49
|
+
// install time any more — npm ≥12 blocks install scripts unless the user opts in
|
|
50
|
+
// — so this is the path that always runs: the launcher spawns this CLI for every
|
|
51
|
+
// command, `--version` and `--help` included. One file read when there is no
|
|
52
|
+
// bridge; the port probe behind it runs once per boot. Never throws.
|
|
53
|
+
//
|
|
54
|
+
// The home is the *invoking* user's, not `os.homedir()`: under `sudo`, the
|
|
55
|
+
// bridge (and its PID file) belongs to the user who started it.
|
|
56
|
+
await runLegacyMigrationGuard();
|
|
57
|
+
// The daemon cannot import this module before its own static imports reach
|
|
58
|
+
// `third_party`, so the guard travels to it (and to the MCP server it spawns)
|
|
59
|
+
// through NODE_OPTIONS instead.
|
|
60
|
+
preloadWebStorageWarningGuardInChildren();
|
|
61
|
+
// On a machine that has never been configured, detect the installed Opera
|
|
62
|
+
// build and write a config + set OPERA_CLI_* so this first command works.
|
|
63
|
+
ensureConfigured(process.argv);
|
|
64
|
+
// `--takeover` decides how a profile conflict is settled, and belongs to the
|
|
65
|
+
// preflight rather than to any command — every command parser is strict, so it
|
|
66
|
+
// is read and removed before one of them sees it. It is left in the environment
|
|
67
|
+
// because the preflight is not the only place a conflict is settled: a tool
|
|
68
|
+
// call that fails on one settles it too, one process deeper than any argument
|
|
69
|
+
// the command parser kept (`cliCommands.ts`).
|
|
70
|
+
const takeover = extractTakeoverFlag(process.argv);
|
|
71
|
+
if (takeover) {
|
|
72
|
+
process.env.OPERA_CLI_TAKEOVER = '1';
|
|
73
|
+
}
|
|
74
|
+
// Which browser to drive is decided here, before the command starts a daemon:
|
|
75
|
+
// settling a conflict may need to ask the user, and the daemon is detached with
|
|
76
|
+
// no terminal. The decision travels as OPERA_CLI_* environment, which the
|
|
77
|
+
// daemon and the MCP server it spawns both inherit — and, for `start`, as the
|
|
78
|
+
// flags translated from it immediately below.
|
|
79
|
+
try {
|
|
80
|
+
await preflightBrowser(process.argv.slice(2), sessionIdFromArgv(process.argv), takeover);
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
const { output, exitCode } = await formatError(error);
|
|
84
|
+
console.error(output);
|
|
85
|
+
process.exit(exitCode);
|
|
86
|
+
}
|
|
87
|
+
// `start` is the command that decides the daemon's browser options, and its own
|
|
88
|
+
// defaults (`--headless` from the viaCli options, `--isolated`) are serialized
|
|
89
|
+
// as if the user had passed them — so the MCP server bin's `applyEnvToArgv`,
|
|
90
|
+
// which skips a flag already on argv, can no longer see OPERA_CLI_HEADED or
|
|
91
|
+
// OPERA_CLI_USER_DATA_DIR. Translating the environment into explicit flags
|
|
92
|
+
// here, before yargs parses the command, lets the config beat those defaults.
|
|
93
|
+
// Only `start` takes the browser flags; `status`, `stop` and the tool commands
|
|
94
|
+
// have strict parsers that would reject them, and a directly-run MCP server
|
|
95
|
+
// still does the translation in its own bin.
|
|
96
|
+
if (process.argv[2] === 'start') {
|
|
97
|
+
applyEnvToArgv(process.argv);
|
|
98
|
+
}
|
|
99
|
+
// Dynamic import: static imports are hoisted above the module body, so the
|
|
100
|
+
// config load above would otherwise run after the CLI already parsed argv.
|
|
101
|
+
await import('./chrome-devtools.js');
|
|
102
|
+
//# sourceMappingURL=opera-browser-cli.js.map
|
|
@@ -6,5 +6,24 @@
|
|
|
6
6
|
*/
|
|
7
7
|
// Opera-named bin entry (see `package.json` `bin`). The implementation lives in
|
|
8
8
|
// the upstream-owned file and is branded via `src/opera/branding.ts`.
|
|
9
|
-
import '
|
|
9
|
+
import { applyEnvToArgv, loadOperaCliConfig } from '../opera/envConfig.js';
|
|
10
|
+
import { runLegacyMigrationGuard } from '../opera/legacyBridgeCleanup.js';
|
|
11
|
+
// Import for its side effect: silences Node's Web Storage warning before
|
|
12
|
+
// `third_party` reads `localStorage`. See `opera/webStorageWarning.ts`.
|
|
13
|
+
import '../opera/webStorageWarning.js';
|
|
14
|
+
// Apply ~/.opera-browser-cli/config and OPERA_CLI_* env vars before the
|
|
15
|
+
// upstream implementation reads `process.argv`.
|
|
16
|
+
loadOperaCliConfig();
|
|
17
|
+
applyEnvToArgv(process.argv);
|
|
18
|
+
// An MCP client starts this bin directly, with no CLI invocation anywhere, so
|
|
19
|
+
// the migration guard has to run here too: a machine that upgraded from the
|
|
20
|
+
// two-package era may still have the old HTTP bridge (port 9225) holding a
|
|
21
|
+
// browser. Nothing runs at install time — npm ≥12 blocks install scripts unless
|
|
22
|
+
// the user opts in — and this file is also the server the daemon spawns, so both
|
|
23
|
+
// shapes of MCP start are covered. One file read when there is no bridge; the
|
|
24
|
+
// port probe behind it runs once per boot. Never throws, never writes to stdout.
|
|
25
|
+
await runLegacyMigrationGuard();
|
|
26
|
+
// Dynamic import: static imports are hoisted above the module body, so the
|
|
27
|
+
// env/config setup above would otherwise run after the server parsed argv.
|
|
28
|
+
await import('./chrome-devtools-mcp.js');
|
|
10
29
|
//# sourceMappingURL=opera-devtools-mcp.js.map
|
package/build/src/browser.js
CHANGED
|
@@ -9,6 +9,8 @@ import { execSync } from 'node:child_process';
|
|
|
9
9
|
import fs from 'node:fs';
|
|
10
10
|
import os from 'node:os';
|
|
11
11
|
import path from 'node:path';
|
|
12
|
+
import { disarmBrowserOrphanCleanup, watchBrowserForOrphans, } from './opera/browserCleanup.js';
|
|
13
|
+
import { attachFailed, noDevToolsEndpoint, profileInUse, } from './opera/browserErrors.js';
|
|
12
14
|
import { puppeteer } from './third_party/index.js';
|
|
13
15
|
import { logger, puppeteerLogger } from './utils/logger.js';
|
|
14
16
|
import { isAllowedUrl } from './utils/url.js';
|
|
@@ -73,9 +75,7 @@ export async function ensureBrowserConnected(options) {
|
|
|
73
75
|
connectOptions.browserWSEndpoint = browserWSEndpoint;
|
|
74
76
|
}
|
|
75
77
|
catch (error) {
|
|
76
|
-
throw new Error(
|
|
77
|
-
cause: error,
|
|
78
|
-
});
|
|
78
|
+
throw new Error(noDevToolsEndpoint(userDataDir), { cause: error });
|
|
79
79
|
}
|
|
80
80
|
}
|
|
81
81
|
else {
|
|
@@ -98,9 +98,7 @@ export async function ensureBrowserConnected(options) {
|
|
|
98
98
|
browser = connected;
|
|
99
99
|
}
|
|
100
100
|
catch (err) {
|
|
101
|
-
throw new Error(
|
|
102
|
-
cause: err,
|
|
103
|
-
});
|
|
101
|
+
throw new Error(attachFailed(options, autoConnect), { cause: err });
|
|
104
102
|
}
|
|
105
103
|
logger?.('Connected Puppeteer');
|
|
106
104
|
return browser;
|
|
@@ -190,9 +188,10 @@ export async function launch(options) {
|
|
|
190
188
|
catch (error) {
|
|
191
189
|
if (userDataDir &&
|
|
192
190
|
error.message.includes('The browser is already running')) {
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
191
|
+
// The wording lives in `opera/browserErrors.ts`: upstream's answer to a
|
|
192
|
+
// profile in use ("use --isolated") is the wrong one for the case that
|
|
193
|
+
// matters — driving the browser you already have.
|
|
194
|
+
throw new Error(profileInUse(userDataDir), { cause: error });
|
|
196
195
|
}
|
|
197
196
|
throw error;
|
|
198
197
|
}
|
|
@@ -203,6 +202,12 @@ export async function ensureBrowserLaunched(options) {
|
|
|
203
202
|
}
|
|
204
203
|
// Assign mode before browser; see the connect path above for rationale.
|
|
205
204
|
const launched = await launch(options);
|
|
205
|
+
// Chrome's helpers sit in the browser's process group, not under it in the
|
|
206
|
+
// process tree, so a browser that dies without closing — SIGKILL, a crash,
|
|
207
|
+
// an OOM kill — leaves them running as orphans. `disconnected` is the only
|
|
208
|
+
// signal that the browser is gone, and the group kill it arms is the only
|
|
209
|
+
// teardown that reaches the helpers; see `opera/browserCleanup.ts`.
|
|
210
|
+
watchBrowserForOrphans(launched);
|
|
206
211
|
browserMode = 'launched';
|
|
207
212
|
browser = launched;
|
|
208
213
|
return browser;
|
|
@@ -226,6 +231,9 @@ export async function closeBrowser() {
|
|
|
226
231
|
return;
|
|
227
232
|
}
|
|
228
233
|
if (mode === 'launched') {
|
|
234
|
+
// The close below emits `disconnected` too; without this the orphan-cleanup
|
|
235
|
+
// handler would SIGKILL the group mid-shutdown. See `opera/browserCleanup.ts`.
|
|
236
|
+
disarmBrowserOrphanCleanup();
|
|
229
237
|
await b.close().catch(err => {
|
|
230
238
|
logger?.('Failed to close browser', err);
|
|
231
239
|
});
|
|
@@ -237,6 +245,7 @@ export async function closeBrowser() {
|
|
|
237
245
|
}
|
|
238
246
|
export async function closeBrowserIfOpen() {
|
|
239
247
|
if (browser?.connected) {
|
|
248
|
+
disarmBrowserOrphanCleanup();
|
|
240
249
|
try {
|
|
241
250
|
await browser.close();
|
|
242
251
|
}
|
|
@@ -6,9 +6,11 @@
|
|
|
6
6
|
import { spawn } from 'node:child_process';
|
|
7
7
|
import fs from 'node:fs';
|
|
8
8
|
import net from 'node:net';
|
|
9
|
-
import {
|
|
9
|
+
import { ensureCleanStart, readExitReason } from '../opera/daemonLifecycle.js';
|
|
10
|
+
import { nameSpawnFailure, openDaemonLog } from '../opera/daemonLog.js';
|
|
11
|
+
import { requestOverSocket } from '../opera/daemonStreaming.js';
|
|
10
12
|
import { getTempFilePath } from '../utils/files.js';
|
|
11
|
-
import { logger
|
|
13
|
+
import { logger } from '../utils/logger.js';
|
|
12
14
|
import { DAEMON_SCRIPT_PATH, getSocketPath, getPidFilePath, isDaemonRunning, } from './utils.js';
|
|
13
15
|
const FILE_TIMEOUT = 10_000;
|
|
14
16
|
const READY_CHECK_INTERVAL = 100;
|
|
@@ -77,64 +79,68 @@ async function waitForDaemonReady(sessionId) {
|
|
|
77
79
|
throw new Error(`Timeout: daemon not ready within ${FILE_TIMEOUT}ms`, lastError === undefined ? undefined : { cause: lastError });
|
|
78
80
|
}
|
|
79
81
|
export async function startDaemon(mcpArgs = [], sessionId) {
|
|
80
|
-
|
|
82
|
+
// Not `isDaemonRunning`: that reads the pid file, and a daemon whose pid file
|
|
83
|
+
// is gone (or whose socket a racing starter unlinked) is invisible to it.
|
|
84
|
+
// `ensureCleanStart` probes the socket and reaps whatever the last daemon left
|
|
85
|
+
// behind, so the daemon we are about to fork is the only one in the session.
|
|
86
|
+
if (await ensureCleanStart(sessionId)) {
|
|
81
87
|
logger?.('Daemon is already running');
|
|
82
88
|
await waitForDaemonReady(sessionId);
|
|
83
89
|
return;
|
|
84
90
|
}
|
|
91
|
+
logger?.('Starting daemon...', ...mcpArgs);
|
|
85
92
|
const pidFilePath = getPidFilePath(sessionId);
|
|
86
|
-
|
|
87
|
-
|
|
93
|
+
const logFd = openDaemonLog(sessionId);
|
|
94
|
+
let child;
|
|
95
|
+
try {
|
|
96
|
+
child = spawn(process.execPath, [DAEMON_SCRIPT_PATH, ...mcpArgs], {
|
|
97
|
+
detached: true,
|
|
98
|
+
// Not 'ignore' unless there was nothing to open: the daemon is detached
|
|
99
|
+
// with no terminal, so discarding its output leaves a daemon that dies
|
|
100
|
+
// silently unexplainable. The file is what the mid-command failure below
|
|
101
|
+
// points the user at.
|
|
102
|
+
stdio: logFd === 'ignore' ? 'ignore' : ['ignore', logFd, logFd],
|
|
103
|
+
env: { ...process.env, CHROME_DEVTOOLS_MCP_SESSION_ID: sessionId },
|
|
104
|
+
cwd: process.cwd(),
|
|
105
|
+
windowsHide: true,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
finally {
|
|
109
|
+
// The child holds its own descriptor; the parent's copy would outlive it.
|
|
110
|
+
if (logFd !== 'ignore') {
|
|
111
|
+
fs.closeSync(logFd);
|
|
112
|
+
}
|
|
88
113
|
}
|
|
89
|
-
logger?.('Starting daemon...', ...mcpArgs);
|
|
90
|
-
const child = spawn(process.execPath, [DAEMON_SCRIPT_PATH, ...mcpArgs], {
|
|
91
|
-
detached: true,
|
|
92
|
-
stdio: 'ignore',
|
|
93
|
-
env: { ...process.env, CHROME_DEVTOOLS_MCP_SESSION_ID: sessionId },
|
|
94
|
-
cwd: process.cwd(),
|
|
95
|
-
windowsHide: true,
|
|
96
|
-
});
|
|
97
114
|
child.unref();
|
|
98
|
-
|
|
115
|
+
// The pid file and the spawn's own failure, whichever comes first. See
|
|
116
|
+
// `opera/daemonLog.ts` for why a failed spawn must not surface as a timeout.
|
|
117
|
+
await Promise.race([
|
|
118
|
+
waitForFile(pidFilePath),
|
|
119
|
+
nameSpawnFailure(child, sessionId),
|
|
120
|
+
]);
|
|
99
121
|
await waitForDaemonReady(sessionId);
|
|
100
122
|
}
|
|
101
123
|
const SEND_COMMAND_TIMEOUT = 60_000; // ms
|
|
102
124
|
/**
|
|
103
125
|
* `sendCommand` opens a socket connection sends a single command and disconnects.
|
|
126
|
+
*
|
|
127
|
+
* The frame protocol — including `onLog`, which opts the request into the
|
|
128
|
+
* streaming variant and hands each chunk over as it arrives — lives in
|
|
129
|
+
* `opera/daemonStreaming.ts`'s `requestOverSocket`.
|
|
104
130
|
*/
|
|
105
|
-
export async function sendCommand(command, sessionId, timeout = SEND_COMMAND_TIMEOUT) {
|
|
131
|
+
export async function sendCommand(command, sessionId, timeout = SEND_COMMAND_TIMEOUT, onLog) {
|
|
106
132
|
// Before connecting and sending, verify the daemon is still alive.
|
|
107
133
|
if (!isDaemonRunning(sessionId)) {
|
|
108
|
-
|
|
134
|
+
// A daemon that tore itself down leaves a reason behind; surfacing it is
|
|
135
|
+
// the difference between "Daemon is not running." and knowing why.
|
|
136
|
+
const reason = readExitReason(sessionId);
|
|
137
|
+
throw new Error(reason ? `Daemon is not running: ${reason}` : 'Daemon is not running.');
|
|
109
138
|
}
|
|
110
139
|
const socketPath = getSocketPath(sessionId);
|
|
111
140
|
const socket = net.createConnection({
|
|
112
141
|
path: socketPath,
|
|
113
142
|
});
|
|
114
|
-
return
|
|
115
|
-
const timer = setTimeout(() => {
|
|
116
|
-
socket.destroy();
|
|
117
|
-
reject(new Error('Timeout waiting for daemon response'));
|
|
118
|
-
}, timeout);
|
|
119
|
-
const transport = new PipeTransport(socket, socket, puppeteerLogger);
|
|
120
|
-
transport.onmessage = async (message) => {
|
|
121
|
-
clearTimeout(timer);
|
|
122
|
-
logger?.('onmessage', message);
|
|
123
|
-
resolve(JSON.parse(message));
|
|
124
|
-
};
|
|
125
|
-
socket.on('error', error => {
|
|
126
|
-
clearTimeout(timer);
|
|
127
|
-
logger?.('Socket error:', error);
|
|
128
|
-
reject(error);
|
|
129
|
-
});
|
|
130
|
-
socket.on('close', () => {
|
|
131
|
-
clearTimeout(timer);
|
|
132
|
-
logger?.('Socket closed:');
|
|
133
|
-
reject(new Error('Socket closed'));
|
|
134
|
-
});
|
|
135
|
-
logger?.('Sending message', command);
|
|
136
|
-
transport.send(JSON.stringify(command));
|
|
137
|
-
});
|
|
143
|
+
return requestOverSocket({ socket, command, sessionId, timeout, onLog });
|
|
138
144
|
}
|
|
139
145
|
export async function stopDaemon(sessionId) {
|
|
140
146
|
if (!isDaemonRunning(sessionId)) {
|