opera-devtools-mcp 0.6.1 → 0.8.0
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/McpContext.js +78 -41
- package/build/src/McpPage.js +5 -1
- package/build/src/ToolHandler.js +54 -76
- package/build/src/bin/chrome-devtools-mcp-main.js +4 -4
- package/build/src/bin/chrome-devtools.js +56 -124
- package/build/src/bin/opera-browser-cli.js +102 -0
- package/build/src/bin/opera-devtools-cli-options.js +1 -1
- package/build/src/bin/opera-devtools-mcp-cli-options.js +1 -1
- package/build/src/bin/opera-devtools-mcp.js +20 -1
- package/build/src/browser.js +23 -25
- package/build/src/config/browser-options.js +126 -0
- package/build/src/config/category-options.js +81 -0
- package/build/src/{bin/chrome-devtools-cli-options.js → config/cli-options.js} +368 -26
- package/build/src/{bin/chrome-devtools-mcp-cli-options.js → config/mcp-options.js} +143 -164
- package/build/src/daemon/client.js +55 -40
- package/build/src/daemon/daemon.js +62 -39
- package/build/src/daemon/utils.js +6 -0
- package/build/src/devtools/DevtoolsUtils.js +27 -21
- package/build/src/formatters/NetworkFormatter.js +5 -2
- package/build/src/index.js +166 -100
- 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/processors/HeapSnapshotManager.js +12 -0
- package/build/src/telemetry/ClearcutLogger.js +19 -6
- package/build/src/telemetry/transformation.js +4 -0
- package/build/src/telemetry/types.js +4 -0
- package/build/src/third_party/THIRD_PARTY_NOTICES +5 -5
- package/build/src/third_party/bundled-packages.json +3 -3
- package/build/src/third_party/devtools-formatter-worker.js +23 -0
- package/build/src/third_party/devtools-heap-snapshot-worker.js +101 -20
- package/build/src/third_party/index.js +15460 -14256
- package/build/src/third_party/issue-descriptions/federatedAuthRequestAccountsBlockedByConnectionAllowlist.md +1 -0
- package/build/src/third_party/issue-descriptions/federatedAuthRequestConfigBlockedByConnectionAllowlist.md +1 -0
- package/build/src/third_party/issue-descriptions/federatedAuthRequestIdTokenBlockedByConnectionAllowlist.md +1 -0
- package/build/src/third_party/issue-descriptions/federatedAuthRequestWellKnownBlockedByConnectionAllowlist.md +1 -0
- package/build/src/tools/ToolDefinition.js +15 -0
- package/build/src/tools/categories.js +0 -6
- package/build/src/tools/comments.js +90 -0
- package/build/src/tools/console.js +1 -1
- package/build/src/tools/emulation.js +1 -1
- package/build/src/tools/extensions.js +1 -1
- package/build/src/tools/memory.js +60 -6
- package/build/src/tools/network.js +2 -2
- package/build/src/tools/pages.js +26 -15
- package/build/src/tools/performance.js +4 -3
- package/build/src/tools/screencast.js +3 -2
- package/build/src/tools/screenshot.js +39 -8
- package/build/src/tools/script.js +17 -4
- package/build/src/tools/slim/tools.js +41 -33
- package/build/src/tools/snapshot.js +1 -1
- package/build/src/tools/tools.js +2 -0
- package/build/src/utils/WaitForHelper.js +12 -1
- package/build/src/utils/bytes.js +105 -0
- package/build/src/utils/url.js +79 -0
- package/build/src/version.js +1 -1
- package/package.json +29 -8
- package/build/src/bin/opera-devtools.js +0 -10
|
@@ -7,42 +7,45 @@
|
|
|
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';
|
|
17
|
-
import { commands } from '
|
|
18
|
-
import {
|
|
21
|
+
import { commands } from '../config/cli-options.js';
|
|
22
|
+
import { mcpOptions, parseArguments, getMcpOptionsForViaCli, } from '../config/mcp-options.js';
|
|
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.`);
|
|
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');
|
|
21
33
|
async function start(args, sessionId) {
|
|
22
|
-
const combinedArgs = [...
|
|
34
|
+
const combinedArgs = [...DEFAULT_CLI_ARGS, ...args];
|
|
23
35
|
await startDaemon(combinedArgs, sessionId);
|
|
24
36
|
logDisclaimers(parseArguments(VERSION, combinedArgs));
|
|
25
37
|
}
|
|
26
|
-
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
};
|
|
30
|
-
// Missing CLI serialization.
|
|
31
|
-
delete
|
|
32
|
-
// Change the defaults for the CLI.
|
|
33
|
-
delete
|
|
34
|
-
delete
|
|
35
|
-
|
|
36
|
-
if (!('default' in cliOptions.headless)) {
|
|
37
|
-
throw new Error('headless cli option unexpectedly does not have a default');
|
|
38
|
+
function getCliOptions() {
|
|
39
|
+
const options = {
|
|
40
|
+
...getMcpOptionsForViaCli(),
|
|
41
|
+
};
|
|
42
|
+
// Missing CLI serialization.
|
|
43
|
+
delete options.viewport;
|
|
44
|
+
// Change the defaults for the CLI.
|
|
45
|
+
delete options.experimentalStructuredContent;
|
|
46
|
+
delete options.experimentalInteropTools;
|
|
47
|
+
return options;
|
|
38
48
|
}
|
|
39
|
-
if ('default' in cliOptions.isolated) {
|
|
40
|
-
throw new Error('isolated cli option unexpectedly has a default');
|
|
41
|
-
}
|
|
42
|
-
startCliOptions.headless.default = true;
|
|
43
|
-
startCliOptions.isolated.description =
|
|
44
|
-
'If specified, creates a temporary user-data-dir that is automatically cleaned up after the browser is closed. Defaults to true unless userDataDir is provided.';
|
|
45
|
-
startCliOptions.categoryExtensions.default = true;
|
|
46
49
|
const y = yargs(hideBin(process.argv))
|
|
47
50
|
.locale('en') // Force English to ensure error string matching works in .fail, all custom messages we output are in English anyways
|
|
48
51
|
.scriptName(CLI_BIN_NAME)
|
|
@@ -60,7 +63,7 @@ const y = yargs(hideBin(process.argv))
|
|
|
60
63
|
},
|
|
61
64
|
})
|
|
62
65
|
.demandCommand()
|
|
63
|
-
.version(
|
|
66
|
+
.version(versionBanner)
|
|
64
67
|
.strict()
|
|
65
68
|
.help(true)
|
|
66
69
|
.wrap(120)
|
|
@@ -72,36 +75,45 @@ const y = yargs(hideBin(process.argv))
|
|
|
72
75
|
msg.includes('Unknown arguments')) {
|
|
73
76
|
console.error('\n=========================================');
|
|
74
77
|
console.error('💡 TIP FOR AI AGENT / DEVELOPER:');
|
|
75
|
-
console.error(
|
|
78
|
+
console.error(`In the \`${CLI_BIN_NAME}\` CLI:`);
|
|
76
79
|
console.error('1. Required parameters MUST be passed as positional arguments (without flags).');
|
|
77
|
-
console.error(
|
|
78
|
-
console.error(
|
|
79
|
-
console.error('2. Optional parameters are passed as double-dash options/flags (e.g. --
|
|
80
|
+
console.error(` - INCORRECT: ${CLI_BIN_NAME} click --pageId 1 --uid "1_2"`);
|
|
81
|
+
console.error(` - CORRECT: ${CLI_BIN_NAME} click 1 "1_2"`);
|
|
82
|
+
console.error('2. Optional parameters are passed as double-dash options/flags (e.g. --dblClick true).');
|
|
80
83
|
console.error('3. Make sure to escape quotes properly for your shell environment.');
|
|
81
|
-
console.error(
|
|
84
|
+
console.error(`Run \`${CLI_BIN_NAME} <command> --help\` to see exact positional and optional parameters.`);
|
|
82
85
|
console.error('=========================================');
|
|
83
86
|
}
|
|
84
87
|
}
|
|
85
88
|
else if (err) {
|
|
86
89
|
console.error(err);
|
|
87
90
|
}
|
|
88
|
-
|
|
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);
|
|
89
94
|
});
|
|
90
95
|
y.command('start', `Start or restart ${MCP_BIN_NAME}`, y => y
|
|
91
|
-
.options(
|
|
96
|
+
.options(getCliOptions())
|
|
92
97
|
.example('$0 start --browserUrl http://localhost:9222', 'Start the server connecting to an existing browser')
|
|
93
98
|
.strict(), async (argv) => {
|
|
94
99
|
if (isDaemonRunning(argv.sessionId)) {
|
|
95
100
|
await stopDaemon(argv.sessionId);
|
|
96
101
|
}
|
|
97
102
|
// Defaults but we do not want to affect the yargs conflict resolution.
|
|
98
|
-
if (argv.isolated === undefined &&
|
|
103
|
+
if (argv.isolated === undefined &&
|
|
104
|
+
argv.userDataDir === undefined &&
|
|
105
|
+
!argv.autoConnect &&
|
|
106
|
+
!argv.browserUrl &&
|
|
107
|
+
!argv.wsEndpoint) {
|
|
99
108
|
argv.isolated = true;
|
|
100
109
|
}
|
|
101
|
-
if (argv.headless === undefined
|
|
110
|
+
if (argv.headless === undefined &&
|
|
111
|
+
!argv.autoConnect &&
|
|
112
|
+
!argv.browserUrl &&
|
|
113
|
+
!argv.wsEndpoint) {
|
|
102
114
|
argv.headless = true;
|
|
103
115
|
}
|
|
104
|
-
const args = serializeArgs(
|
|
116
|
+
const args = serializeArgs(mcpOptions, argv);
|
|
105
117
|
await start(args, argv.sessionId);
|
|
106
118
|
process.exit(0);
|
|
107
119
|
}).strict(); // Re-enable strict validation for other commands; this is applied to the yargs instance itself
|
|
@@ -114,9 +126,12 @@ y.command('status', `Checks if ${MCP_BIN_NAME} is running`, y => y, async (argv)
|
|
|
114
126
|
if (response.success) {
|
|
115
127
|
const data = JSON.parse(response.result);
|
|
116
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)}`);
|
|
117
132
|
console.log(`args=${JSON.stringify(data.args)}`);
|
|
118
133
|
if (data.version !== VERSION) {
|
|
119
|
-
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.`);
|
|
120
135
|
}
|
|
121
136
|
}
|
|
122
137
|
else {
|
|
@@ -137,96 +152,13 @@ y.command('stop', `Stop ${MCP_BIN_NAME} if any`, y => y, async (argv) => {
|
|
|
137
152
|
await stopDaemon(sessionId);
|
|
138
153
|
process.exit(0);
|
|
139
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 });
|
|
140
160
|
for (const [commandName, commandDef] of Object.entries(commands)) {
|
|
141
|
-
|
|
142
|
-
const requiredArgNames = Object.keys(args).filter(name => args[name].required);
|
|
143
|
-
const optionalArgNames = Object.keys(args).filter(name => !args[name].required);
|
|
144
|
-
let commandStr = commandName;
|
|
145
|
-
for (const arg of requiredArgNames) {
|
|
146
|
-
commandStr += ` <${arg}>`;
|
|
147
|
-
}
|
|
148
|
-
for (const arg of optionalArgNames) {
|
|
149
|
-
commandStr += ` [--${arg}]`;
|
|
150
|
-
}
|
|
151
|
-
y.command(commandStr, commandDef.description, y => {
|
|
152
|
-
y.option('output-format', {
|
|
153
|
-
choices: ['md', 'json'],
|
|
154
|
-
default: 'md',
|
|
155
|
-
});
|
|
156
|
-
for (const [argName, opt] of Object.entries(args)) {
|
|
157
|
-
const type = opt.type === 'integer' || opt.type === 'number'
|
|
158
|
-
? 'number'
|
|
159
|
-
: opt.type === 'boolean'
|
|
160
|
-
? 'boolean'
|
|
161
|
-
: opt.type === 'array'
|
|
162
|
-
? 'array'
|
|
163
|
-
: 'string';
|
|
164
|
-
if (opt.required) {
|
|
165
|
-
const options = {
|
|
166
|
-
describe: opt.description,
|
|
167
|
-
type: type,
|
|
168
|
-
};
|
|
169
|
-
if (opt.default !== undefined) {
|
|
170
|
-
options.default = opt.default;
|
|
171
|
-
}
|
|
172
|
-
if (opt.enum) {
|
|
173
|
-
options.choices = opt.enum;
|
|
174
|
-
}
|
|
175
|
-
y.positional(argName, options);
|
|
176
|
-
}
|
|
177
|
-
else {
|
|
178
|
-
const options = {
|
|
179
|
-
describe: opt.description,
|
|
180
|
-
type: type,
|
|
181
|
-
};
|
|
182
|
-
if (opt.default !== undefined) {
|
|
183
|
-
options.default = opt.default;
|
|
184
|
-
}
|
|
185
|
-
if (opt.enum) {
|
|
186
|
-
options.choices = opt.enum;
|
|
187
|
-
}
|
|
188
|
-
y.option(argName, options);
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
}, async (argv) => {
|
|
192
|
-
const sessionId = argv.sessionId;
|
|
193
|
-
try {
|
|
194
|
-
const versionWarningPromise = isDaemonRunning(sessionId)
|
|
195
|
-
? verifyDaemonVersion(sessionId, VERSION)
|
|
196
|
-
: Promise.resolve(undefined);
|
|
197
|
-
if (!isDaemonRunning(sessionId)) {
|
|
198
|
-
await start(serializeArgs(cliOptions, argv), sessionId);
|
|
199
|
-
}
|
|
200
|
-
const commandArgs = {};
|
|
201
|
-
for (const argName of Object.keys(args)) {
|
|
202
|
-
if (argName in argv) {
|
|
203
|
-
commandArgs[argName] = argv[argName];
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
const response = await sendCommand({
|
|
207
|
-
method: 'invoke_tool',
|
|
208
|
-
tool: commandName,
|
|
209
|
-
args: commandArgs,
|
|
210
|
-
}, sessionId);
|
|
211
|
-
if (response.success) {
|
|
212
|
-
console.log(await handleResponse(JSON.parse(response.result), argv['output-format']));
|
|
213
|
-
}
|
|
214
|
-
else {
|
|
215
|
-
console.error('Error:', response.error);
|
|
216
|
-
}
|
|
217
|
-
const versionWarning = await versionWarningPromise;
|
|
218
|
-
if (versionWarning) {
|
|
219
|
-
console.warn(versionWarning);
|
|
220
|
-
}
|
|
221
|
-
if (!response.success) {
|
|
222
|
-
process.exit(1);
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
catch (error) {
|
|
226
|
-
console.error('Failed to execute command:', error);
|
|
227
|
-
process.exit(1);
|
|
228
|
-
}
|
|
229
|
-
});
|
|
161
|
+
registerToolCommand(y, commandName, commandDef, { start });
|
|
230
162
|
}
|
|
231
163
|
await y.parse();
|
|
232
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
|
|
@@ -4,5 +4,5 @@
|
|
|
4
4
|
* SPDX-License-Identifier: Apache-2.0
|
|
5
5
|
*/
|
|
6
6
|
// Compatibility shim. The canonical file is generated by `npm run cli:generate`.
|
|
7
|
-
export * from '
|
|
7
|
+
export * from '../config/cli-options.js';
|
|
8
8
|
//# sourceMappingURL=opera-devtools-cli-options.js.map
|
|
@@ -4,5 +4,5 @@
|
|
|
4
4
|
* SPDX-License-Identifier: Apache-2.0
|
|
5
5
|
*/
|
|
6
6
|
// Compatibility shim. Prefer importing the canonical path directly.
|
|
7
|
-
export * from '
|
|
7
|
+
export * from '../config/mcp-options.js';
|
|
8
8
|
//# sourceMappingURL=opera-devtools-mcp-cli-options.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,29 +9,20 @@ 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';
|
|
16
|
+
import { isAllowedUrl } from './utils/url.js';
|
|
14
17
|
let browser;
|
|
15
18
|
let browserMode;
|
|
16
|
-
function makeTargetFilter(enableExtensions = false) {
|
|
17
|
-
const ignoredPrefixes = new Set(['chrome://', 'chrome-untrusted://']);
|
|
18
|
-
if (!enableExtensions) {
|
|
19
|
-
ignoredPrefixes.add('chrome-extension://');
|
|
20
|
-
}
|
|
19
|
+
export function makeTargetFilter(enableExtensions = false) {
|
|
21
20
|
return function targetFilter(target) {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
}
|
|
25
|
-
// Could be the only page opened in the browser.
|
|
26
|
-
if (target.url().startsWith('chrome://inspect')) {
|
|
21
|
+
const url = target.url();
|
|
22
|
+
if (!url) {
|
|
27
23
|
return true;
|
|
28
24
|
}
|
|
29
|
-
|
|
30
|
-
if (target.url().startsWith(prefix)) {
|
|
31
|
-
return false;
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
return true;
|
|
25
|
+
return isAllowedUrl(url, { categoryExtensions: enableExtensions });
|
|
35
26
|
};
|
|
36
27
|
}
|
|
37
28
|
export async function ensureBrowserConnected(options) {
|
|
@@ -84,9 +75,7 @@ export async function ensureBrowserConnected(options) {
|
|
|
84
75
|
connectOptions.browserWSEndpoint = browserWSEndpoint;
|
|
85
76
|
}
|
|
86
77
|
catch (error) {
|
|
87
|
-
throw new Error(
|
|
88
|
-
cause: error,
|
|
89
|
-
});
|
|
78
|
+
throw new Error(noDevToolsEndpoint(userDataDir), { cause: error });
|
|
90
79
|
}
|
|
91
80
|
}
|
|
92
81
|
else {
|
|
@@ -109,9 +98,7 @@ export async function ensureBrowserConnected(options) {
|
|
|
109
98
|
browser = connected;
|
|
110
99
|
}
|
|
111
100
|
catch (err) {
|
|
112
|
-
throw new Error(
|
|
113
|
-
cause: err,
|
|
114
|
-
});
|
|
101
|
+
throw new Error(attachFailed(options, autoConnect), { cause: err });
|
|
115
102
|
}
|
|
116
103
|
logger?.('Connected Puppeteer');
|
|
117
104
|
return browser;
|
|
@@ -201,9 +188,10 @@ export async function launch(options) {
|
|
|
201
188
|
catch (error) {
|
|
202
189
|
if (userDataDir &&
|
|
203
190
|
error.message.includes('The browser is already running')) {
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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 });
|
|
207
195
|
}
|
|
208
196
|
throw error;
|
|
209
197
|
}
|
|
@@ -214,6 +202,12 @@ export async function ensureBrowserLaunched(options) {
|
|
|
214
202
|
}
|
|
215
203
|
// Assign mode before browser; see the connect path above for rationale.
|
|
216
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);
|
|
217
211
|
browserMode = 'launched';
|
|
218
212
|
browser = launched;
|
|
219
213
|
return browser;
|
|
@@ -237,6 +231,9 @@ export async function closeBrowser() {
|
|
|
237
231
|
return;
|
|
238
232
|
}
|
|
239
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();
|
|
240
237
|
await b.close().catch(err => {
|
|
241
238
|
logger?.('Failed to close browser', err);
|
|
242
239
|
});
|
|
@@ -248,6 +245,7 @@ export async function closeBrowser() {
|
|
|
248
245
|
}
|
|
249
246
|
export async function closeBrowserIfOpen() {
|
|
250
247
|
if (browser?.connected) {
|
|
248
|
+
disarmBrowserOrphanCleanup();
|
|
251
249
|
try {
|
|
252
250
|
await browser.close();
|
|
253
251
|
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Google LLC
|
|
4
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
*
|
|
6
|
+
* Modified by Opera Software AS.
|
|
7
|
+
*/
|
|
8
|
+
import { CACHE_DIR_NAME, MCP_BIN_NAME, REPO_URL } from '../opera/branding.js';
|
|
9
|
+
export const browserOptions = {
|
|
10
|
+
autoConnect: {
|
|
11
|
+
type: 'boolean',
|
|
12
|
+
description: 'If specified, automatically connects to a browser (Chrome 144+) running locally from the user data directory identified by the channel param (default channel is stable). Requires the remote debugging server to be started in the Chrome instance via chrome://inspect/#remote-debugging.',
|
|
13
|
+
conflicts: ['isolated', 'executablePath'],
|
|
14
|
+
default: false,
|
|
15
|
+
coerce: (value) => {
|
|
16
|
+
if (!value) {
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
return value;
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
browserUrl: {
|
|
23
|
+
type: 'string',
|
|
24
|
+
description: `Connect to a running, debuggable Chrome instance (e.g. \`http://127.0.0.1:9222\`). For more details see: ${REPO_URL}#connecting-to-a-running-chrome-instance.`,
|
|
25
|
+
alias: 'u',
|
|
26
|
+
conflicts: ['wsEndpoint'],
|
|
27
|
+
coerce: (url) => {
|
|
28
|
+
if (!url) {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
new URL(url);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
throw new Error(`Provided browserUrl ${url} is not valid URL.`);
|
|
36
|
+
}
|
|
37
|
+
return url;
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
wsEndpoint: {
|
|
41
|
+
type: 'string',
|
|
42
|
+
description: 'WebSocket endpoint to connect to a running Chrome instance (e.g., ws://127.0.0.1:9222/devtools/browser/<id>). Alternative to --browserUrl.',
|
|
43
|
+
alias: 'w',
|
|
44
|
+
conflicts: ['browserUrl'],
|
|
45
|
+
coerce: (url) => {
|
|
46
|
+
if (!url) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
const parsed = new URL(url);
|
|
51
|
+
if (parsed.protocol !== 'ws:' && parsed.protocol !== 'wss:') {
|
|
52
|
+
throw new Error(`Provided wsEndpoint ${url} must use ws:// or wss:// protocol.`);
|
|
53
|
+
}
|
|
54
|
+
return url;
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
if (error.message.includes('ws://')) {
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
throw new Error(`Provided wsEndpoint ${url} is not valid URL.`);
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
wsHeaders: {
|
|
65
|
+
type: 'string',
|
|
66
|
+
description: 'Custom headers for WebSocket connection in JSON format (e.g., \'{"Authorization":"Bearer token"}\'). Only works with --wsEndpoint.',
|
|
67
|
+
implies: 'wsEndpoint',
|
|
68
|
+
coerce: (val) => {
|
|
69
|
+
if (!val) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
const parsed = JSON.parse(val);
|
|
74
|
+
if (typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
75
|
+
throw new Error('Headers must be a JSON object');
|
|
76
|
+
}
|
|
77
|
+
return parsed;
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
throw new Error(`Invalid JSON for wsHeaders: ${error.message}`);
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
headless: {
|
|
85
|
+
type: 'boolean',
|
|
86
|
+
description: 'Whether to run in headless (no UI) mode.',
|
|
87
|
+
default: false,
|
|
88
|
+
},
|
|
89
|
+
executablePath: {
|
|
90
|
+
type: 'string',
|
|
91
|
+
description: 'Path to custom Chrome executable.',
|
|
92
|
+
conflicts: ['browserUrl', 'wsEndpoint'],
|
|
93
|
+
alias: 'e',
|
|
94
|
+
},
|
|
95
|
+
isolated: {
|
|
96
|
+
type: 'boolean',
|
|
97
|
+
description: 'If specified, creates a temporary user-data-dir that is automatically cleaned up after the browser is closed. Defaults to false.',
|
|
98
|
+
},
|
|
99
|
+
userDataDir: {
|
|
100
|
+
type: 'string',
|
|
101
|
+
description: `Path to the user data directory for Chrome. Default is $HOME/.cache/${CACHE_DIR_NAME}/chrome-profile$CHANNEL_SUFFIX_IF_NON_STABLE`,
|
|
102
|
+
conflicts: ['browserUrl', 'wsEndpoint', 'isolated'],
|
|
103
|
+
},
|
|
104
|
+
channel: {
|
|
105
|
+
type: 'string',
|
|
106
|
+
description: 'Specify a different Chrome channel that should be used. The default is the stable channel version.',
|
|
107
|
+
choices: ['canary', 'dev', 'beta', 'stable'],
|
|
108
|
+
conflicts: ['browserUrl', 'wsEndpoint', 'executablePath'],
|
|
109
|
+
},
|
|
110
|
+
proxyServer: {
|
|
111
|
+
type: 'string',
|
|
112
|
+
description: `Proxy server configuration for Chrome passed as --proxy-server when launching the browser. See https://www.chromium.org/developers/design-documents/network-settings/ for details.`,
|
|
113
|
+
},
|
|
114
|
+
chromeArg: {
|
|
115
|
+
type: 'array',
|
|
116
|
+
describe: `Additional arguments for Chrome. Only applies when Chrome is launched by ${MCP_BIN_NAME}.`,
|
|
117
|
+
},
|
|
118
|
+
ignoreDefaultChromeArg: {
|
|
119
|
+
type: 'array',
|
|
120
|
+
describe: `Explicitly disable default arguments for Chrome. Only applies when Chrome is launched by ${MCP_BIN_NAME}.`,
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
export function getBrowserOptions() {
|
|
124
|
+
return browserOptions;
|
|
125
|
+
}
|
|
126
|
+
//# sourceMappingURL=browser-options.js.map
|