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.
Files changed (55) hide show
  1. package/README.md +1 -1
  2. package/build/src/ToolHandler.js +9 -2
  3. package/build/src/bin/chrome-devtools.js +30 -97
  4. package/build/src/bin/opera-browser-cli.js +102 -0
  5. package/build/src/bin/opera-devtools-mcp.js +20 -1
  6. package/build/src/browser.js +18 -9
  7. package/build/src/daemon/client.js +46 -40
  8. package/build/src/daemon/daemon.js +62 -39
  9. package/build/src/opera/branding.js +4 -2
  10. package/build/src/opera/browserActivity.js +62 -0
  11. package/build/src/opera/browserCleanup.js +123 -0
  12. package/build/src/opera/browserErrors.js +66 -0
  13. package/build/src/opera/browserFlags.js +184 -38
  14. package/build/src/opera/browserTarget.js +513 -0
  15. package/build/src/opera/cdpErrors.js +391 -0
  16. package/build/src/opera/cliCommands.js +378 -0
  17. package/build/src/opera/cliOutput.js +284 -0
  18. package/build/src/opera/compactSnapshot.js +525 -0
  19. package/build/src/opera/config.js +166 -0
  20. package/build/src/opera/daemonLifecycle.js +257 -0
  21. package/build/src/opera/daemonLog.js +103 -0
  22. package/build/src/opera/daemonPidFile.js +83 -0
  23. package/build/src/opera/daemonShutdown.js +66 -0
  24. package/build/src/opera/daemonSocket.js +87 -0
  25. package/build/src/opera/daemonStreaming.js +130 -0
  26. package/build/src/opera/daemonToolCall.js +26 -0
  27. package/build/src/opera/detect.js +114 -0
  28. package/build/src/opera/doctor.js +317 -0
  29. package/build/src/opera/envConfig.js +229 -0
  30. package/build/src/opera/launcherNotice.js +116 -0
  31. package/build/src/opera/legacyBridgeCleanup.js +297 -0
  32. package/build/src/opera/logs.js +133 -0
  33. package/build/src/opera/mcpServerSupervisor.js +128 -0
  34. package/build/src/opera/migrationShared.js +164 -0
  35. package/build/src/opera/operaPages.js +56 -0
  36. package/build/src/opera/pageIdRouting.js +35 -0
  37. package/build/src/opera/pageRecovery.js +53 -0
  38. package/build/src/opera/profile.js +270 -0
  39. package/build/src/opera/refArgs.js +36 -0
  40. package/build/src/opera/serviceWorkerRetry.js +46 -4
  41. package/build/src/opera/setup.js +290 -0
  42. package/build/src/opera/skills/SKILL.md +160 -0
  43. package/build/src/opera/streamingTools.js +73 -0
  44. package/build/src/opera/suggestions.js +67 -0
  45. package/build/src/opera/toolHandlerHooks.js +25 -1
  46. package/build/src/opera/tools/opera.js +107 -38
  47. package/build/src/opera/urlResolver.js +69 -0
  48. package/build/src/opera/webStorageWarning.js +92 -0
  49. package/build/src/third_party/devtools-formatter-worker.js +1 -0
  50. package/build/src/third_party/devtools-heap-snapshot-worker.js +1 -0
  51. package/build/src/third_party/index.js +2 -1
  52. package/build/src/utils/url.js +6 -0
  53. package/build/src/version.js +1 -1
  54. package/package.json +12 -10
  55. package/build/src/bin/opera-devtools.js +0 -10
@@ -5,14 +5,22 @@
5
5
  * This file is an original work developed by Opera.
6
6
  */
7
7
  import { closeBrowserIfOpen, ensureBrowserLaunched, getCurrentBrowser, } from '../browser.js';
8
- import { buildLaunchOptions } from './browserLaunch.js';
9
8
  import { logger } from '../utils/logger.js';
9
+ import { otherBrowserUsers } from './browserActivity.js';
10
+ import { buildLaunchOptions } from './browserLaunch.js';
10
11
  /**
11
12
  * Opera's AI features refuse to run when the page reports itself as
12
13
  * automation-controlled, so the browser has to be launched with this flag for
13
14
  * them. It is deliberately NOT applied to every launch: it changes observable
14
15
  * page behaviour, which would silently alter results for ordinary DevTools
15
16
  * tools.
17
+ *
18
+ * The flags are therefore acquired the first time an Opera AI tool needs them
19
+ * and then kept for the life of that browser — see `ensureBrowserFlagsForTool`.
20
+ * A browser carrying them is never relaunched to take them away again: the
21
+ * daemon serves several terminals at once, and the tool that used to trigger
22
+ * that relaunch was an ordinary one (`take_snapshot` in another terminal), which
23
+ * closed the browser a running `opera_do` was streaming from.
16
24
  */
17
25
  export const OPERA_AUTOMATION_FLAGS = [
18
26
  '--disable-blink-features=AutomationControlled',
@@ -27,59 +35,197 @@ export function toolRequiresOperaFlags(toolName) {
27
35
  return TOOLS_REQUIRING_OPERA_FLAGS.has(toolName);
28
36
  }
29
37
  /**
30
- * Tracks whether the currently running browser was launched with
31
- * {@link OPERA_AUTOMATION_FLAGS}. Module-level rather than per-server because
32
- * the browser itself is a module-level singleton in `../browser.ts`.
38
+ * The browser this server launched with {@link OPERA_AUTOMATION_FLAGS}, held by
39
+ * identity rather than as a flag that outlives it: a browser that died and was
40
+ * relaunched by the next call carries none of them (`index.ts` launches without
41
+ * the Opera flags), and the tools that need them must not be told otherwise.
42
+ * Module-level rather than per-server because the browser itself is a
43
+ * module-level singleton in `../browser.ts`.
33
44
  */
34
- let browserHasOperaFlags = false;
35
- export function browserWasLaunchedWithOperaFlags() {
36
- return browserHasOperaFlags;
37
- }
38
- /** Test seam: forget what we believe about the current browser. */
45
+ let flagsBrowser;
46
+ /** Test seam: forget which browser was launched with the Opera flags. */
39
47
  export function resetOperaFlagState() {
40
- browserHasOperaFlags = false;
48
+ flagsBrowser = undefined;
41
49
  }
50
+ /**
51
+ * One source of truth for "did we launch the browser, or attach to one that was
52
+ * already running". `index.ts` picks the branch with the same three options at
53
+ * invocation time, and the CLI reports the mode from the daemon's stored argv,
54
+ * so the rule must not be re-implemented per caller.
55
+ */
56
+ const ATTACH_OPTIONS = ['browserUrl', 'wsEndpoint', 'autoConnect'];
42
57
  /**
43
58
  * True when this server launched the browser itself. When the user attached to
44
59
  * an existing browser we must never kill and relaunch it.
45
60
  */
46
- function isLaunchMode(serverArgs) {
47
- return (!serverArgs.browserUrl && !serverArgs.wsEndpoint && !serverArgs.autoConnect);
61
+ export function isLaunchMode(serverArgs) {
62
+ return !ATTACH_OPTIONS.some(option => Boolean(serverArgs[option]));
48
63
  }
49
64
  /**
50
- * Makes sure the running browser has (or lacks) the Opera automation flags to
51
- * match what `toolName` needs, relaunching it if not. No-op when we did not
52
- * launch the browser ourselves, or when the flags already match.
65
+ * One canonical spelling for a raw flag name. yargs expands `--browser-url` and
66
+ * `--browserUrl` to the same option, and the daemon stores the argv verbatim, so
67
+ * a read-back that does not normalize would call an attached session "launched".
53
68
  */
54
- export async function ensureBrowserFlagsForTool(toolName, serverArgs, logFile, control,
55
- // Injected so tests do not need a real browser singleton.
56
- deps = { closeBrowserIfOpen, ensureBrowserLaunched }) {
57
- if (!isLaunchMode(serverArgs)) {
58
- return;
69
+ function canonicalOptionName(name) {
70
+ return name.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
71
+ }
72
+ /**
73
+ * The flags of a stored argv in one pass: canonical name → value, `true` for a
74
+ * value-less flag. `--flag=value`, `--flag value`, `--no-flag` and
75
+ * `--flag=false` all arrive on a real command line, and all four have to read
76
+ * the same as `isLaunchMode` reads the parsed form.
77
+ */
78
+ function readFlags(args) {
79
+ const flags = new Map();
80
+ for (let index = 0; index < args.length; index++) {
81
+ const arg = args[index];
82
+ if (!arg.startsWith('--')) {
83
+ continue;
84
+ }
85
+ const body = arg.slice(2);
86
+ const equals = body.indexOf('=');
87
+ if (equals !== -1) {
88
+ flags.set(canonicalOptionName(body.slice(0, equals)), body.slice(equals + 1));
89
+ continue;
90
+ }
91
+ if (body.startsWith('no-')) {
92
+ flags.set(canonicalOptionName(body.slice(3)), false);
93
+ continue;
94
+ }
95
+ const next = args[index + 1];
96
+ if (next !== undefined && !next.startsWith('-')) {
97
+ flags.set(canonicalOptionName(body), next);
98
+ index++;
99
+ continue;
100
+ }
101
+ flags.set(canonicalOptionName(body), true);
59
102
  }
60
- const needsOperaFlags = toolRequiresOperaFlags(toolName);
61
- const browserConnected = getCurrentBrowser()?.connected ?? false;
62
- // A disconnected browser tells us nothing about the flags of the next one.
63
- if (needsOperaFlags && browserHasOperaFlags && browserConnected) {
103
+ return flags;
104
+ }
105
+ /**
106
+ * How a serialized CLI argv (`opera-browser-cli status`) describes the browser
107
+ * it drives: `launched (owned by this daemon)` or `attached to <target>`.
108
+ *
109
+ * The daemon stores the MCP argv it was started with, so this needs no new
110
+ * channel between the two processes — and it is the same predicate the server
111
+ * itself applies, read back from the flags that decided it.
112
+ */
113
+ export function describeBrowserMode(args) {
114
+ const flags = readFlags(args);
115
+ const attachFlag = ATTACH_OPTIONS.find(option => {
116
+ const value = flags.get(option);
117
+ // `--flag=false` and `--no-flag` are how yargs spells "not set", and
118
+ // `isLaunchMode` reads them the same way.
119
+ return value !== undefined && value !== false && value !== 'false';
120
+ });
121
+ if (!attachFlag) {
122
+ return 'launched (owned by this daemon)';
123
+ }
124
+ const value = flags.get(attachFlag);
125
+ return `attached to ${typeof value === 'string' && value ? value : 'an external browser'}`;
126
+ }
127
+ /**
128
+ * The profile a stored argv's launched browser was pointed at, if any.
129
+ *
130
+ * Read back for the same reason `describeBrowserMode` is: a daemon fixes its
131
+ * browser at startup, so its argv is the only record of which profile that
132
+ * browser holds. The CLI uses it to tell its own browser — which never
133
+ * advertises a debug port, because it is launched over a pipe — from the one a
134
+ * user opened, which is the difference between "nothing to do" and "ask".
135
+ */
136
+ export function launchedUserDataDir(args) {
137
+ const value = readFlags(args).get('userDataDir');
138
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
139
+ }
140
+ /**
141
+ * The `--browser-url` a stored argv carries, if it carries one.
142
+ *
143
+ * That is the form the CLI's own endpoint discovery sets (`browserTarget.ts`
144
+ * finds a live `DevToolsActivePort` and passes the URL on), which makes it the
145
+ * one attach target the CLI may re-derive when the browser behind it is gone: a
146
+ * `--wsEndpoint` or `--autoConnect` was typed by the user, and their browser is
147
+ * theirs to start again.
148
+ */
149
+ export function storedBrowserUrl(args) {
150
+ const value = readFlags(args).get('browserUrl');
151
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
152
+ }
153
+ /**
154
+ * How long a relaunch waits for the browser to fall idle before it gives up and
155
+ * reports why, and how often it looks. A mutable object so tests can drive the
156
+ * wait without waiting: faking timers is not workable here (sinon's fake clock
157
+ * replaces the globals `node:test` schedules subtests with).
158
+ */
159
+ export const browserIdleWaitPolicy = {
160
+ timeoutMs: 10_000,
161
+ pollMs: 100,
162
+ };
163
+ function sleep(ms) {
164
+ const { promise, resolve } = Promise.withResolvers();
165
+ setTimeout(resolve, ms);
166
+ return promise;
167
+ }
168
+ const defaultFlagRelaunchDeps = {
169
+ closeBrowserIfOpen,
170
+ ensureBrowserLaunched,
171
+ getCurrentBrowser,
172
+ sleep,
173
+ };
174
+ /**
175
+ * Wait until nothing else is inside a tool invocation, or refuse.
176
+ *
177
+ * A relaunch closes every page in the browser, which is defensible only when
178
+ * nothing is using it: the daemon answers several terminals at once, so the
179
+ * alternative is destroying the pages of an invocation that is still running —
180
+ * the failure this whole module exists to stop.
181
+ */
182
+ async function waitForBrowserIdle(toolName, sleep) {
183
+ const deadline = Date.now() + browserIdleWaitPolicy.timeoutMs;
184
+ for (;;) {
185
+ const users = otherBrowserUsers(toolName);
186
+ if (users.length === 0) {
187
+ return;
188
+ }
189
+ if (Date.now() >= deadline) {
190
+ throw new Error(`${toolName} needs the browser relaunched with Opera's automation flags, and it is in use by ${users.join(', ')} — relaunching it now would close that work. Timed out waiting for the browser to be free; retry when it is.`);
191
+ }
192
+ await sleep(browserIdleWaitPolicy.pollMs);
193
+ }
194
+ }
195
+ /**
196
+ * Make sure the running browser has the Opera automation flags `toolName` needs.
197
+ *
198
+ * One direction only, and that is the point. A browser this daemon launched
199
+ * *gains* the flags when an Opera AI tool first needs them and keeps them for the
200
+ * rest of its life; a tool that needs no flags is not this module's business at
201
+ * all. The reverse relaunch — taking the flags away again for an ordinary
202
+ * DevTools tool — was what closed the browser under a running `opera_do` when a
203
+ * second terminal ran `take_snapshot`, failing both streams with the AI
204
+ * dispatcher's "no target" error, and it is gone rather than made conditional:
205
+ * the flags are a property of the browser, not of the tool that happens to be
206
+ * running.
207
+ *
208
+ * The one relaunch that remains — the acquisition — waits for the browser to fall
209
+ * idle first, and reports why instead of closing anything if it does not.
210
+ */
211
+ export async function ensureBrowserFlagsForTool(toolName, serverArgs, logFile, control, deps = defaultFlagRelaunchDeps) {
212
+ if (!isLaunchMode(serverArgs) || !toolRequiresOperaFlags(toolName)) {
64
213
  return;
65
214
  }
66
- if (!needsOperaFlags && !(browserHasOperaFlags && browserConnected)) {
215
+ const current = deps.getCurrentBrowser();
216
+ if (current?.connected && current === flagsBrowser) {
67
217
  return;
68
218
  }
69
- logger?.(`Relaunching browser ${needsOperaFlags ? 'with' : 'without'} Opera automation flags for ${toolName}`);
219
+ await waitForBrowserIdle(toolName, deps.sleep);
220
+ logger?.(`Relaunching browser with Opera's automation flags for ${toolName}`);
70
221
  control.resetContext();
71
- browserHasOperaFlags = false;
222
+ flagsBrowser = undefined;
72
223
  await deps.closeBrowserIfOpen();
73
- if (needsOperaFlags) {
74
- // Launch options come from the shared `buildLaunchOptions` so the relaunch
75
- // applies exactly the same flags (including blocklist/allowlist and proxy)
76
- // as the normal launch in `index.ts`, plus the automation flags.
77
- await deps.ensureBrowserLaunched(buildLaunchOptions(serverArgs, logFile, {
78
- extraChromeArgs: OPERA_AUTOMATION_FLAGS,
79
- }));
80
- browserHasOperaFlags = true;
81
- }
82
- // Otherwise leave the browser closed: getContext() relaunches it without the
83
- // Opera flags on the next call.
224
+ // Launch options come from the shared `buildLaunchOptions` so the relaunch
225
+ // applies exactly the same flags (including blocklist/allowlist and proxy)
226
+ // as the normal launch in `index.ts`, plus the automation flags.
227
+ flagsBrowser = await deps.ensureBrowserLaunched(buildLaunchOptions(serverArgs, logFile, {
228
+ extraChromeArgs: OPERA_AUTOMATION_FLAGS,
229
+ }));
84
230
  }
85
231
  //# sourceMappingURL=browserFlags.js.map