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
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Opera Norway AS. All rights reserved.
|
|
4
|
+
*
|
|
5
|
+
* This file is an original work developed by Opera.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Who is using the shared browser at this moment.
|
|
9
|
+
*
|
|
10
|
+
* One daemon serves every terminal through one MCP server and one browser
|
|
11
|
+
* (`../browser.ts` holds a module-level singleton), so invocations that know
|
|
12
|
+
* nothing about each other overlap freely: an Opera AI action streams for
|
|
13
|
+
* minutes while another terminal's `take_snapshot` arrives, and a second
|
|
14
|
+
* `opera_do` joins the first. That is fine for reading the page and fatal for
|
|
15
|
+
* *replacing the browser*, which closes every page in it — including the pages
|
|
16
|
+
* of whatever is still running.
|
|
17
|
+
*
|
|
18
|
+
* `opera/browserFlags.ts` is the one thing that ever replaces the browser, and
|
|
19
|
+
* it asks here first. A count per tool name rather than a boolean because the
|
|
20
|
+
* refusal names the tools holding the browser, and because two invocations of
|
|
21
|
+
* the same tool are two claims rather than one.
|
|
22
|
+
*
|
|
23
|
+
* `ToolHandler` pairs `noteToolStarted` with `noteToolFinished` around every
|
|
24
|
+
* invocation, the second from the same `finally` that releases the tool mutex —
|
|
25
|
+
* so an invocation that fails is not left counted, and neither is one that
|
|
26
|
+
* `beforeInvoke` itself refused (`opera/toolHandlerHooks.ts`).
|
|
27
|
+
*/
|
|
28
|
+
const inFlight = new Map();
|
|
29
|
+
/** Claim the browser for the invocation of `toolName` that is starting. */
|
|
30
|
+
export function noteToolStarted(toolName) {
|
|
31
|
+
inFlight.set(toolName, (inFlight.get(toolName) ?? 0) + 1);
|
|
32
|
+
}
|
|
33
|
+
/** Release one claim on the browser; unknown names are ignored. */
|
|
34
|
+
export function noteToolFinished(toolName) {
|
|
35
|
+
const count = inFlight.get(toolName);
|
|
36
|
+
if (count === undefined) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (count > 1) {
|
|
40
|
+
inFlight.set(toolName, count - 1);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
inFlight.delete(toolName);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The tools using the browser besides the invocation of `self` asking, split by
|
|
47
|
+
* name so a refusal can name them, sorted so its wording is stable.
|
|
48
|
+
*/
|
|
49
|
+
export function otherBrowserUsers(self) {
|
|
50
|
+
const names = new Set();
|
|
51
|
+
for (const [name, count] of inFlight) {
|
|
52
|
+
if (name === self ? count > 1 : count > 0) {
|
|
53
|
+
names.add(name);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return [...names].sort();
|
|
57
|
+
}
|
|
58
|
+
/** Test seam: forget every in-flight invocation. */
|
|
59
|
+
export function resetBrowserActivity() {
|
|
60
|
+
inFlight.clear();
|
|
61
|
+
}
|
|
62
|
+
//# sourceMappingURL=browserActivity.js.map
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Opera Norway AS. All rights reserved.
|
|
4
|
+
*
|
|
5
|
+
* This file is an original work developed by Opera.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Browser process-tree teardown, owned by Opera.
|
|
9
|
+
*
|
|
10
|
+
* Chrome calls `setsid()` while it initializes, so the browser is a process
|
|
11
|
+
* group leader and its helpers (GPU, renderer, utility) join *its* group rather
|
|
12
|
+
* than sitting below it in the process tree. That is why a helper survives
|
|
13
|
+
* SIGKILL to the main process: it is a sibling, not a child, so killing the
|
|
14
|
+
* parent orphans it instead of ending it.
|
|
15
|
+
*
|
|
16
|
+
* Killing the group is therefore the only teardown that reaches the helpers. A
|
|
17
|
+
* descendant walk from the browser pid cannot: by the time `disconnected`
|
|
18
|
+
* fires the pid is gone, the helpers are re-parented to init, and the walk
|
|
19
|
+
* returns nothing.
|
|
20
|
+
*
|
|
21
|
+
* On Linux the group id persists as long as any member is alive, even after the
|
|
22
|
+
* leader exits, so `kill(-pgid)` still names the whole group at that point.
|
|
23
|
+
*
|
|
24
|
+
* `watchBrowserForOrphans` is the seam `src/browser.ts` calls: it owns the pid
|
|
25
|
+
* capture and the `disconnected` listener, so the upstream file gets one line
|
|
26
|
+
* after `launch()` rather than a handler body.
|
|
27
|
+
*/
|
|
28
|
+
import process from 'node:process';
|
|
29
|
+
/**
|
|
30
|
+
* SIGKILL every process in the browser's process group.
|
|
31
|
+
*
|
|
32
|
+
* Synchronous by design: `process.kill` is a syscall, and this runs from the
|
|
33
|
+
* `disconnected` handler, where the SIGKILL has to reach the group before the
|
|
34
|
+
* event loop yields to whatever teardown is already in flight.
|
|
35
|
+
*
|
|
36
|
+
* Takes the group id rather than the `Browser`: `browser.process()` is not
|
|
37
|
+
* reliable once `disconnected` has fired — the main process is gone, and a pid
|
|
38
|
+
* read at that point could already name an unrelated process that the kernel
|
|
39
|
+
* reused. The caller captures the pid at launch and holds it; the group outlives
|
|
40
|
+
* its leader, so that value still addresses every helper.
|
|
41
|
+
*
|
|
42
|
+
* No-op on Windows, which has no POSIX process groups: there the browser's
|
|
43
|
+
* helpers are its children and Puppeteer's own close path reaps them.
|
|
44
|
+
*/
|
|
45
|
+
export function killBrowserProcessGroup(pid) {
|
|
46
|
+
if (process.platform === 'win32' || !pid) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
process.kill(-pid, 'SIGKILL');
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// ESRCH — the group is already gone, which is what a graceful `close()`
|
|
54
|
+
// leaves behind, so the signal is a no-op either way. EPERM — the browser
|
|
55
|
+
// is not a group leader, which `setsid()` makes impossible for a browser
|
|
56
|
+
// Puppeteer launched.
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Whether the teardown above must stay its hand — set while *we* close the
|
|
61
|
+
* browser.
|
|
62
|
+
*
|
|
63
|
+
* `disconnected` is not an abnormal-exit signal: Puppeteer's own `close()` ends
|
|
64
|
+
* with `disconnect()`, so a deliberate shutdown fires the same event a crash
|
|
65
|
+
* does. Killing the process group at that moment SIGKILLs a browser that is
|
|
66
|
+
* still flushing its profile — IndexedDB and LevelDB writes are done by its
|
|
67
|
+
* child storage utility, not by the browser process — and a store that loses
|
|
68
|
+
* those writes keeps records pointing at files that never landed. Blink reports
|
|
69
|
+
* the read of such a record as `NotReadableError: Data lost due to missing
|
|
70
|
+
* file. Affected record should be considered irrecoverable` (`indexeddb/`
|
|
71
|
+
* `idb_request_queue_item.cc`), and Opera AI's chat path then fails on every
|
|
72
|
+
* later run in that profile.
|
|
73
|
+
*
|
|
74
|
+
* So the group kill is armed for a browser that goes away without us closing
|
|
75
|
+
* it, and disarmed for the close we asked for.
|
|
76
|
+
*
|
|
77
|
+
* The flag lives in the watching handler's closure, not in module state: a
|
|
78
|
+
* handler for a browser we already closed can fire *after* the next launch has
|
|
79
|
+
* installed its own, and module state would then be read through the new cycle
|
|
80
|
+
* — an arm resets it to false, so the late handler for the deliberately closed
|
|
81
|
+
* browser sees "not ours" and SIGKILLs a group whose helpers may still be
|
|
82
|
+
* flushing that profile. One flag per armed handler means each browser's
|
|
83
|
+
* teardown answers only for its own close.
|
|
84
|
+
*/
|
|
85
|
+
let disarmActiveWatcher;
|
|
86
|
+
/**
|
|
87
|
+
* Mark the teardown that follows as deliberate, so the `disconnected` it emits
|
|
88
|
+
* is not answered with a group kill. Called by both browser-closing paths in
|
|
89
|
+
* `src/browser.ts`, which are the only places that close a browser we launched.
|
|
90
|
+
*/
|
|
91
|
+
export function disarmBrowserOrphanCleanup() {
|
|
92
|
+
disarmActiveWatcher?.();
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Arm the browser's teardown: when it disconnects without closing, kill the
|
|
96
|
+
* process group it left behind.
|
|
97
|
+
*
|
|
98
|
+
* The pid is read here, while the browser is alive, because by the time
|
|
99
|
+
* `disconnected` fires the main process is gone and a pid read then could
|
|
100
|
+
* already name a process the kernel reused. `.once` because Puppeteer emits
|
|
101
|
+
* `disconnected` on an abnormal exit and again on `close()`, and one group kill
|
|
102
|
+
* is the whole job.
|
|
103
|
+
*
|
|
104
|
+
* Called from `ensureBrowserLaunched` right after `launch()`, which is the one
|
|
105
|
+
* place in `src/browser.ts` that owns a launched browser. Each launch installs
|
|
106
|
+
* a fresh watcher with its own disarm flag, so a browser launched after a
|
|
107
|
+
* deliberate close is protected again — and the previous launch's watcher,
|
|
108
|
+
* which may fire afterwards, is still answered by its own flag.
|
|
109
|
+
*/
|
|
110
|
+
export function watchBrowserForOrphans(browser) {
|
|
111
|
+
const pgid = browser.process()?.pid;
|
|
112
|
+
let disarmed = false;
|
|
113
|
+
disarmActiveWatcher = () => {
|
|
114
|
+
disarmed = true;
|
|
115
|
+
};
|
|
116
|
+
browser.once('disconnected', () => {
|
|
117
|
+
if (disarmed) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
killBrowserProcessGroup(pgid);
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
//# sourceMappingURL=browserCleanup.js.map
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Opera Norway AS. All rights reserved.
|
|
4
|
+
*
|
|
5
|
+
* This file is an original work developed by Opera.
|
|
6
|
+
*/
|
|
7
|
+
function attachTarget(target) {
|
|
8
|
+
return target.browserURL ?? target.wsEndpoint ?? target.userDataDir;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* The opening of {@link profileInUse}, exported because a second caller has to
|
|
12
|
+
* recognise the failure without matching the prose again: the CLI settles a
|
|
13
|
+
* conflict it finds this way (see `cliCommands.ts`), and the two spellings of
|
|
14
|
+
* "the profile is in use" must not drift apart.
|
|
15
|
+
*/
|
|
16
|
+
export const PROFILE_IN_USE_PREFIX = 'A browser is already running with the profile';
|
|
17
|
+
/** A launch failed because a browser already holds the profile. */
|
|
18
|
+
export function profileInUse(userDataDir) {
|
|
19
|
+
return (`${PROFILE_IN_USE_PREFIX} ${userDataDir}, so a second one cannot be launched on it. ` +
|
|
20
|
+
`Either drive that browser — start it with --remote-debugging-port=<port> (9222 is the usual one) and run with --browser-url=http://127.0.0.1:<port> ` +
|
|
21
|
+
`(or --autoConnect on Chrome 144+) — or launch on a separate profile with --isolated.`);
|
|
22
|
+
}
|
|
23
|
+
/** Whether a failure message is one {@link profileInUse} produced. */
|
|
24
|
+
export function isProfileInUseMessage(message) {
|
|
25
|
+
return message.includes(PROFILE_IN_USE_PREFIX);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The opening of {@link attachFailed} and {@link noDevToolsEndpoint}, which
|
|
29
|
+
* both say the daemon was told to drive a browser that is not there.
|
|
30
|
+
*/
|
|
31
|
+
export const ATTACH_FAILED_PREFIX = 'Could not attach to the browser';
|
|
32
|
+
/**
|
|
33
|
+
* The browser failure a tool reply describes, or undefined when it describes
|
|
34
|
+
* none.
|
|
35
|
+
*
|
|
36
|
+
* Both of these mean the daemon has no browser to drive — one because a launch
|
|
37
|
+
* was refused, the other because the browser it attached to is gone — and both
|
|
38
|
+
* leave it that way until something re-reads the environment, because a daemon
|
|
39
|
+
* fixes its browser at startup. `cliCommands.ts` settles them for that reason.
|
|
40
|
+
*/
|
|
41
|
+
export function classifyBrowserFailure(message) {
|
|
42
|
+
if (isProfileInUseMessage(message)) {
|
|
43
|
+
return 'profile-in-use';
|
|
44
|
+
}
|
|
45
|
+
if (message.includes(ATTACH_FAILED_PREFIX)) {
|
|
46
|
+
return 'unreachable';
|
|
47
|
+
}
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* An attach through a profile found no DevTools endpoint in it, i.e. the browser
|
|
52
|
+
* is running but was not started with remote debugging.
|
|
53
|
+
*/
|
|
54
|
+
export function noDevToolsEndpoint(userDataDir) {
|
|
55
|
+
return (`Could not attach to the browser running with the profile ${userDataDir}: no DevTools endpoint was found there. ` +
|
|
56
|
+
`Check that it is running and that remote debugging is enabled (chrome://inspect/#remote-debugging).`);
|
|
57
|
+
}
|
|
58
|
+
/** An attach failed outright: the browser is gone, or not debuggable. */
|
|
59
|
+
export function attachFailed(target, autoConnect) {
|
|
60
|
+
const at = attachTarget(target);
|
|
61
|
+
const hint = autoConnect ? ' (chrome://inspect/#remote-debugging)' : '';
|
|
62
|
+
return (`Could not attach to the browser${at ? ` at ${at}` : ''}. ` +
|
|
63
|
+
`Check that it is running with remote debugging enabled${hint}. ` +
|
|
64
|
+
`An attached browser is not managed by this daemon, so it is not restarted for you — start it again and re-attach.`);
|
|
65
|
+
}
|
|
66
|
+
//# sourceMappingURL=browserErrors.js.map
|
|
@@ -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
|
-
*
|
|
31
|
-
*
|
|
32
|
-
* the
|
|
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
|
|
35
|
-
|
|
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
|
-
|
|
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
|
|
61
|
+
export function isLaunchMode(serverArgs) {
|
|
62
|
+
return !ATTACH_OPTIONS.some(option => Boolean(serverArgs[option]));
|
|
48
63
|
}
|
|
49
64
|
/**
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
215
|
+
const current = deps.getCurrentBrowser();
|
|
216
|
+
if (current?.connected && current === flagsBrowser) {
|
|
67
217
|
return;
|
|
68
218
|
}
|
|
69
|
-
|
|
219
|
+
await waitForBrowserIdle(toolName, deps.sleep);
|
|
220
|
+
logger?.(`Relaunching browser with Opera's automation flags for ${toolName}`);
|
|
70
221
|
control.resetContext();
|
|
71
|
-
|
|
222
|
+
flagsBrowser = undefined;
|
|
72
223
|
await deps.closeBrowserIfOpen();
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|