@extension.dev/mcp 5.5.1 → 5.6.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +75 -0
- package/README.md +1 -1
- package/dist/module.js +944 -158
- package/dist/src/lib/bridge-tabs.d.ts +14 -0
- package/dist/src/lib/cdp-port.d.ts +8 -0
- package/dist/src/lib/console-summary.d.ts +5 -0
- package/dist/src/lib/rdp.d.ts +50 -0
- package/dist/src/tools/source-inspect-gecko.d.ts +8 -0
- package/package.json +5 -5
- package/server.json +2 -2
package/dist/module.js
CHANGED
|
@@ -12,6 +12,7 @@ import { execFile, execFileSync } from "node:child_process";
|
|
|
12
12
|
import { filterKeysForThisBrowser } from "browser-extension-manifest-fields";
|
|
13
13
|
import ws_0 from "ws";
|
|
14
14
|
import node_http from "node:http";
|
|
15
|
+
import node_net from "node:net";
|
|
15
16
|
import { extensionInstall, extensionUninstall, getManagedBrowsersCacheRoot } from "extension-install";
|
|
16
17
|
import { promisify } from "node:util";
|
|
17
18
|
var add_feature_namespaceObject = {};
|
|
@@ -219,7 +220,7 @@ __webpack_require__.d(whoami_namespaceObject, {
|
|
|
219
220
|
handler: ()=>whoami_handler,
|
|
220
221
|
schema: ()=>whoami_schema
|
|
221
222
|
});
|
|
222
|
-
var package_namespaceObject = JSON.parse('{"rE":"5.
|
|
223
|
+
var package_namespaceObject = JSON.parse('{"rE":"5.6.0","El":{"OP":"^4.0.15"}}');
|
|
223
224
|
function scaffoldEnginePin(projectPath) {
|
|
224
225
|
try {
|
|
225
226
|
const pkg = JSON.parse(node_fs.readFileSync(node_path.join(projectPath, "package.json"), "utf8"));
|
|
@@ -563,6 +564,42 @@ function resolveExtensionInvocation(projectDir) {
|
|
|
563
564
|
function runExtensionCli(args, options) {
|
|
564
565
|
const { command, prefixArgs } = resolveExtensionInvocation(options?.cwd);
|
|
565
566
|
return new Promise((resolve)=>{
|
|
567
|
+
let ioDir;
|
|
568
|
+
let outFd;
|
|
569
|
+
let errFd;
|
|
570
|
+
try {
|
|
571
|
+
ioDir = node_fs.mkdtempSync(node_path.join(node_os.tmpdir(), "extension-mcp-io-"));
|
|
572
|
+
outFd = node_fs.openSync(node_path.join(ioDir, "stdout"), "a");
|
|
573
|
+
errFd = node_fs.openSync(node_path.join(ioDir, "stderr"), "a");
|
|
574
|
+
} catch (err) {
|
|
575
|
+
resolve({
|
|
576
|
+
code: 1,
|
|
577
|
+
stdout: "",
|
|
578
|
+
stderr: String(err)
|
|
579
|
+
});
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
const readAll = (name)=>{
|
|
583
|
+
try {
|
|
584
|
+
return node_fs.readFileSync(node_path.join(ioDir, name), "utf8");
|
|
585
|
+
} catch {
|
|
586
|
+
return "";
|
|
587
|
+
}
|
|
588
|
+
};
|
|
589
|
+
const cleanup = ()=>{
|
|
590
|
+
for (const fd of [
|
|
591
|
+
outFd,
|
|
592
|
+
errFd
|
|
593
|
+
])try {
|
|
594
|
+
node_fs.closeSync(fd);
|
|
595
|
+
} catch {}
|
|
596
|
+
try {
|
|
597
|
+
node_fs.rmSync(ioDir, {
|
|
598
|
+
recursive: true,
|
|
599
|
+
force: true
|
|
600
|
+
});
|
|
601
|
+
} catch {}
|
|
602
|
+
};
|
|
566
603
|
const child = cross_spawn(command, [
|
|
567
604
|
...prefixArgs,
|
|
568
605
|
...args
|
|
@@ -570,8 +607,8 @@ function runExtensionCli(args, options) {
|
|
|
570
607
|
cwd: options?.cwd,
|
|
571
608
|
stdio: [
|
|
572
609
|
"ignore",
|
|
573
|
-
|
|
574
|
-
|
|
610
|
+
outFd,
|
|
611
|
+
errFd
|
|
575
612
|
],
|
|
576
613
|
env: {
|
|
577
614
|
...process.env,
|
|
@@ -580,20 +617,26 @@ function runExtensionCli(args, options) {
|
|
|
580
617
|
},
|
|
581
618
|
timeout: options?.timeoutMs ?? 30000
|
|
582
619
|
});
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
620
|
+
child.on("close", (code)=>{
|
|
621
|
+
const stdout = readAll("stdout");
|
|
622
|
+
const stderr = readAll("stderr");
|
|
623
|
+
cleanup();
|
|
624
|
+
resolve({
|
|
588
625
|
code,
|
|
589
626
|
stdout,
|
|
590
627
|
stderr
|
|
591
|
-
})
|
|
592
|
-
|
|
628
|
+
});
|
|
629
|
+
});
|
|
630
|
+
child.on("error", (err)=>{
|
|
631
|
+
const stdout = readAll("stdout");
|
|
632
|
+
const stderr = readAll("stderr");
|
|
633
|
+
cleanup();
|
|
634
|
+
resolve({
|
|
593
635
|
code: 1,
|
|
594
636
|
stdout,
|
|
595
637
|
stderr: stderr || String(err)
|
|
596
|
-
})
|
|
638
|
+
});
|
|
639
|
+
});
|
|
597
640
|
});
|
|
598
641
|
}
|
|
599
642
|
function spawnExtensionCli(args, options) {
|
|
@@ -2557,6 +2600,32 @@ async function inspect_handler(args) {
|
|
|
2557
2600
|
};
|
|
2558
2601
|
return JSON.stringify(result);
|
|
2559
2602
|
}
|
|
2603
|
+
function summarizeConsoleMessages(messages) {
|
|
2604
|
+
const counts = {};
|
|
2605
|
+
const uniqueByLevel = {};
|
|
2606
|
+
for (const msg of messages){
|
|
2607
|
+
counts[msg.level] = (counts[msg.level] ?? 0) + 1;
|
|
2608
|
+
if (!uniqueByLevel[msg.level]) uniqueByLevel[msg.level] = new Map();
|
|
2609
|
+
const key = msg.text.slice(0, 200);
|
|
2610
|
+
uniqueByLevel[msg.level].set(key, (uniqueByLevel[msg.level].get(key) ?? 0) + 1);
|
|
2611
|
+
}
|
|
2612
|
+
const topMessages = [];
|
|
2613
|
+
for (const [level, msgs] of Object.entries(uniqueByLevel)){
|
|
2614
|
+
const sorted = [
|
|
2615
|
+
...msgs.entries()
|
|
2616
|
+
].sort((a, b)=>b[1] - a[1]);
|
|
2617
|
+
for (const [text, count] of sorted.slice(0, 5))topMessages.push({
|
|
2618
|
+
level,
|
|
2619
|
+
text,
|
|
2620
|
+
count
|
|
2621
|
+
});
|
|
2622
|
+
}
|
|
2623
|
+
return {
|
|
2624
|
+
total: messages.length,
|
|
2625
|
+
counts,
|
|
2626
|
+
topMessages: topMessages.sort((a, b)=>b.count - a.count).slice(0, 10)
|
|
2627
|
+
};
|
|
2628
|
+
}
|
|
2560
2629
|
function _define_property(obj, key, value) {
|
|
2561
2630
|
if (key in obj) Object.defineProperty(obj, key, {
|
|
2562
2631
|
value: value,
|
|
@@ -2666,30 +2735,7 @@ class CDPConnection {
|
|
|
2666
2735
|
];
|
|
2667
2736
|
}
|
|
2668
2737
|
getConsoleSummary() {
|
|
2669
|
-
|
|
2670
|
-
const uniqueByLevel = {};
|
|
2671
|
-
for (const msg of this.consoleMessages){
|
|
2672
|
-
counts[msg.level] = (counts[msg.level] ?? 0) + 1;
|
|
2673
|
-
if (!uniqueByLevel[msg.level]) uniqueByLevel[msg.level] = new Map();
|
|
2674
|
-
const key = msg.text.slice(0, 200);
|
|
2675
|
-
uniqueByLevel[msg.level].set(key, (uniqueByLevel[msg.level].get(key) ?? 0) + 1);
|
|
2676
|
-
}
|
|
2677
|
-
const topMessages = [];
|
|
2678
|
-
for (const [level, msgs] of Object.entries(uniqueByLevel)){
|
|
2679
|
-
const sorted = [
|
|
2680
|
-
...msgs.entries()
|
|
2681
|
-
].sort((a, b)=>b[1] - a[1]);
|
|
2682
|
-
for (const [text, count] of sorted.slice(0, 5))topMessages.push({
|
|
2683
|
-
level,
|
|
2684
|
-
text,
|
|
2685
|
-
count
|
|
2686
|
-
});
|
|
2687
|
-
}
|
|
2688
|
-
return {
|
|
2689
|
-
total: this.consoleMessages.length,
|
|
2690
|
-
counts,
|
|
2691
|
-
topMessages: topMessages.sort((a, b)=>b.count - a.count).slice(0, 10)
|
|
2692
|
-
};
|
|
2738
|
+
return summarizeConsoleMessages(this.consoleMessages);
|
|
2693
2739
|
}
|
|
2694
2740
|
onEvent(handler) {
|
|
2695
2741
|
this.eventListeners.add(handler);
|
|
@@ -2964,7 +3010,7 @@ class CDPClient extends CDPConnection {
|
|
|
2964
3010
|
return result ?? null;
|
|
2965
3011
|
}
|
|
2966
3012
|
}
|
|
2967
|
-
async function
|
|
3013
|
+
async function resolveContractPort(projectPath, browser, field, options) {
|
|
2968
3014
|
const waitMs = options?.waitMs ?? 20000;
|
|
2969
3015
|
const graceMs = options?.graceMs ?? 2500;
|
|
2970
3016
|
const readyPath = node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json");
|
|
@@ -2976,9 +3022,9 @@ async function resolveCdpPort(projectPath, browser, options) {
|
|
|
2976
3022
|
const contract = JSON.parse(node_fs.readFileSync(readyPath, "utf8"));
|
|
2977
3023
|
contractSeen = true;
|
|
2978
3024
|
if (null == contractSeenAt) contractSeenAt = Date.now();
|
|
2979
|
-
if ("number" == typeof contract
|
|
2980
|
-
port: contract
|
|
2981
|
-
|
|
3025
|
+
if ("number" == typeof contract[field]) return {
|
|
3026
|
+
port: contract[field],
|
|
3027
|
+
contractSeen
|
|
2982
3028
|
};
|
|
2983
3029
|
} catch {
|
|
2984
3030
|
if (!contractSeen) break;
|
|
@@ -2987,13 +3033,32 @@ async function resolveCdpPort(projectPath, browser, options) {
|
|
|
2987
3033
|
if (Date.now() >= effectiveDeadline) break;
|
|
2988
3034
|
await new Promise((resolve)=>setTimeout(resolve, 500));
|
|
2989
3035
|
}
|
|
3036
|
+
return {
|
|
3037
|
+
port: null,
|
|
3038
|
+
contractSeen
|
|
3039
|
+
};
|
|
3040
|
+
}
|
|
3041
|
+
async function resolveCdpPort(projectPath, browser, options) {
|
|
3042
|
+
const { port, contractSeen } = await resolveContractPort(projectPath, browser, "cdpPort", options);
|
|
3043
|
+
if (null != port) return {
|
|
3044
|
+
port,
|
|
3045
|
+
source: "contract"
|
|
3046
|
+
};
|
|
2990
3047
|
if (!contractSeen && await isCdpEndpoint(9222)) return {
|
|
2991
3048
|
port: 9222,
|
|
2992
3049
|
source: "default-probe"
|
|
2993
3050
|
};
|
|
2994
3051
|
return null;
|
|
2995
3052
|
}
|
|
3053
|
+
async function resolveRdpPort(projectPath, browser, options) {
|
|
3054
|
+
const { port } = await resolveContractPort(projectPath, browser, "rdpPort", options);
|
|
3055
|
+
return null != port ? {
|
|
3056
|
+
port,
|
|
3057
|
+
source: "contract"
|
|
3058
|
+
} : null;
|
|
3059
|
+
}
|
|
2996
3060
|
const CDP_PORT_MISSING_HINT = "The session's ready contract has no CDP port (the browser may still be binding its debug port, or was launched without one). Confirm the session with extension_wait, give it a moment, and retry.";
|
|
3061
|
+
const RDP_PORT_MISSING_HINT = "The session's ready contract has no rdpPort. Firefox sessions publish one from extension.js 4.0.15 on; upgrade the project's extension dependency (or remove the local install so the MCP's pinned CLI drives the session) and restart the dev session.";
|
|
2997
3062
|
function isCdpEndpoint(port) {
|
|
2998
3063
|
return new Promise((resolve)=>{
|
|
2999
3064
|
const req = node_http.get({
|
|
@@ -3012,9 +3077,640 @@ function isCdpEndpoint(port) {
|
|
|
3012
3077
|
});
|
|
3013
3078
|
});
|
|
3014
3079
|
}
|
|
3080
|
+
function toMcpSpeak(text) {
|
|
3081
|
+
return text.replace(/`?extension dev(?: [^\s`]*)? --browser[= ]([\w-]+) --allow-control`?/g, 'extension_dev with { browser: "$1", allowControl: true }').replace(/--allow-control/g, "allowControl: true (extension_dev)").replace(/--allow-eval/g, "allowEval: true (extension_dev)").replace(/Use --context page --tab <id>/g, 'Use context: "page" (targets the active tab; pass url or tab to pick another)').replace(/--context[= ](background|popup|options|sidebar|devtools|newtab|history|bookmarks|content|page)\b/g, 'context: "$1"').replace(/--tab[= ](\d+|<[\w-]+>)/g, "tab: $1").replace(/--url[= ]"([^"]+)"/g, 'url: "$1"').replace(/--url[= ](<[\w-]+>|\S*(?:\/\/|\*)\S*)/g, 'url: "$1"').replace(/--browser[= ]([\w]+-based|chrome|chromium|edge|brave|opera|vivaldi|yandex|firefox|waterfox|librewolf|safari)\b/g, 'browser: "$1"').replace(/--timeout[= ](\d+)/g, "timeout: $1").replace(/`extension dev`/g, "extension_dev").replace(/\bextension dev\b/g, "extension_dev").replace(/--tab\b/g, "`tab`").replace(/--url\b/g, "`url`").replace(/--context\b/g, "`context`").replace(/--browser\b/g, "`browser`").replace(/--timeout\b/g, "`timeout`");
|
|
3082
|
+
}
|
|
3083
|
+
function withSessionContext(message, projectPath) {
|
|
3084
|
+
const isControlError = /no active control channel|control channel refused|\b1006\b|no executor connected|is the session started with allowControl/i.test(message);
|
|
3085
|
+
if (!isControlError) return message;
|
|
3086
|
+
const dead = deadReadySession(projectPath);
|
|
3087
|
+
if (dead) return `${message}\nLikely cause: the dev server has exited, ${dead.browser} ready.json still says ready but its pid ${dead.pid} is dead. Restart with extension_dev (this is not an allowControl problem); extension_doctor confirms.`;
|
|
3088
|
+
const running = knownSessionBrowsers(projectPath);
|
|
3089
|
+
if (0 === running.length) return message;
|
|
3090
|
+
return `${message} Active session browser(s) for this project: ${running.join(", ")}, pass that as \`browser\`, or restart it via extension_dev with allowControl: true if the control channel is off.`;
|
|
3091
|
+
}
|
|
3092
|
+
function translateFrame(frame, projectPath) {
|
|
3093
|
+
if (!frame || false !== frame.ok) return frame;
|
|
3094
|
+
if (frame.error && "string" == typeof frame.error.message) frame.error.message = withSessionContext(toMcpSpeak(frame.error.message), projectPath);
|
|
3095
|
+
if ("string" == typeof frame.error?.hint) frame.error.hint = toMcpSpeak(frame.error.hint);
|
|
3096
|
+
if ("string" == typeof frame.hint) frame.hint = toMcpSpeak(frame.hint);
|
|
3097
|
+
return frame;
|
|
3098
|
+
}
|
|
3099
|
+
async function runActVerb(args, projectPath, timeoutMs) {
|
|
3100
|
+
const { code, stdout, stderr } = await runExtensionCli([
|
|
3101
|
+
...args,
|
|
3102
|
+
"--output",
|
|
3103
|
+
"json"
|
|
3104
|
+
], {
|
|
3105
|
+
cwd: projectPath,
|
|
3106
|
+
timeoutMs
|
|
3107
|
+
});
|
|
3108
|
+
const out = stdout.trim();
|
|
3109
|
+
if (out) try {
|
|
3110
|
+
const frame = JSON.parse(out);
|
|
3111
|
+
if (frame && false === frame.ok) return JSON.stringify(translateFrame(frame, projectPath));
|
|
3112
|
+
return out;
|
|
3113
|
+
} catch {}
|
|
3114
|
+
const message = stderr.trim() || `extension exited with code ${code}`;
|
|
3115
|
+
return JSON.stringify({
|
|
3116
|
+
ok: false,
|
|
3117
|
+
error: {
|
|
3118
|
+
name: "CliError",
|
|
3119
|
+
message: withSessionContext(toMcpSpeak(message), projectPath)
|
|
3120
|
+
}
|
|
3121
|
+
});
|
|
3122
|
+
}
|
|
3123
|
+
function commonFlags(args) {
|
|
3124
|
+
const flags = [];
|
|
3125
|
+
if (args.context) flags.push("--context", args.context);
|
|
3126
|
+
if (args.url) flags.push("--url", args.url);
|
|
3127
|
+
if (null != args.tab) flags.push("--tab", String(args.tab));
|
|
3128
|
+
if (args.browser) flags.push("--browser", args.browser);
|
|
3129
|
+
if (null != args.timeout) flags.push("--timeout", String(args.timeout));
|
|
3130
|
+
return flags;
|
|
3131
|
+
}
|
|
3132
|
+
async function listBridgeTabs(projectPath, browser, timeout) {
|
|
3133
|
+
const raw = await runActVerb([
|
|
3134
|
+
"inspect",
|
|
3135
|
+
projectPath,
|
|
3136
|
+
"--list-tabs",
|
|
3137
|
+
"--browser",
|
|
3138
|
+
browser,
|
|
3139
|
+
...null != timeout ? [
|
|
3140
|
+
"--timeout",
|
|
3141
|
+
String(timeout)
|
|
3142
|
+
] : []
|
|
3143
|
+
], projectPath, timeout);
|
|
3144
|
+
let parsed;
|
|
3145
|
+
try {
|
|
3146
|
+
parsed = JSON.parse(raw);
|
|
3147
|
+
} catch {
|
|
3148
|
+
return {
|
|
3149
|
+
error: raw
|
|
3150
|
+
};
|
|
3151
|
+
}
|
|
3152
|
+
if (parsed?.ok === false) return {
|
|
3153
|
+
error: raw
|
|
3154
|
+
};
|
|
3155
|
+
const list = Array.isArray(parsed?.tabs) ? parsed.tabs : Array.isArray(parsed?.value) ? parsed.value : Array.isArray(parsed?.value?.tabs) ? parsed.value.tabs : null;
|
|
3156
|
+
if (!list) return {
|
|
3157
|
+
error: raw
|
|
3158
|
+
};
|
|
3159
|
+
return {
|
|
3160
|
+
tabs: list.map((t)=>({
|
|
3161
|
+
tabId: "number" == typeof t?.tabId ? t.tabId : "number" == typeof t?.id ? t.id : null,
|
|
3162
|
+
url: String(t?.url ?? ""),
|
|
3163
|
+
title: String(t?.title ?? "")
|
|
3164
|
+
}))
|
|
3165
|
+
};
|
|
3166
|
+
}
|
|
3167
|
+
function matchTabsByUrl(tabs, needle) {
|
|
3168
|
+
const wanted = needle.toLowerCase();
|
|
3169
|
+
const byUrl = tabs.filter((t)=>t.url.toLowerCase().includes(wanted));
|
|
3170
|
+
if (byUrl.length > 0) return byUrl;
|
|
3171
|
+
return tabs.filter((t)=>t.title.toLowerCase().includes(wanted));
|
|
3172
|
+
}
|
|
3173
|
+
async function pollForBridgeTab(projectPath, browser, url, budgetMs) {
|
|
3174
|
+
const deadline = Date.now() + budgetMs;
|
|
3175
|
+
const wanted = url.replace(/#.*$/, "");
|
|
3176
|
+
for(;;){
|
|
3177
|
+
const listed = await listBridgeTabs(projectPath, browser);
|
|
3178
|
+
if ("tabs" in listed) {
|
|
3179
|
+
for (const t of listed.tabs)if (t.url === wanted || t.url.startsWith(wanted)) return t;
|
|
3180
|
+
}
|
|
3181
|
+
if (Date.now() >= deadline) return null;
|
|
3182
|
+
await new Promise((r)=>setTimeout(r, 250));
|
|
3183
|
+
}
|
|
3184
|
+
}
|
|
3185
|
+
async function navigateToUrlViaBridge(projectPath, browser, url, timeout) {
|
|
3186
|
+
const expression = `(async () => { const api = typeof browser !== "undefined" ? browser : chrome; const tabs = await api.tabs.query({ active: true, currentWindow: true }); const active = tabs && tabs[0]; const tab = active && active.id != null ? await api.tabs.update(active.id, { url: ${JSON.stringify(url)} }) : await api.tabs.create({ url: ${JSON.stringify(url)} }); return { tabId: tab && tab.id != null ? tab.id : null }; })()`;
|
|
3187
|
+
const raw = await runActVerb([
|
|
3188
|
+
"eval",
|
|
3189
|
+
expression,
|
|
3190
|
+
projectPath,
|
|
3191
|
+
"--context",
|
|
3192
|
+
"background",
|
|
3193
|
+
"--browser",
|
|
3194
|
+
browser,
|
|
3195
|
+
...null != timeout ? [
|
|
3196
|
+
"--timeout",
|
|
3197
|
+
String(timeout)
|
|
3198
|
+
] : []
|
|
3199
|
+
], projectPath, timeout);
|
|
3200
|
+
try {
|
|
3201
|
+
const parsed = JSON.parse(raw);
|
|
3202
|
+
if (parsed?.ok === false) {
|
|
3203
|
+
if (!parsed.hint) parsed.hint = "On this browser family URL navigation rides the agent bridge (a background eval of tabs.update), so the dev session must be started with allowEval: true (extension_dev).";
|
|
3204
|
+
return JSON.stringify(parsed);
|
|
3205
|
+
}
|
|
3206
|
+
} catch {
|
|
3207
|
+
return raw;
|
|
3208
|
+
}
|
|
3209
|
+
const settled = await pollForBridgeTab(projectPath, browser, url, null != timeout ? Math.min(timeout, 6000) : 6000);
|
|
3210
|
+
if (!settled) return JSON.stringify({
|
|
3211
|
+
ok: false,
|
|
3212
|
+
error: {
|
|
3213
|
+
name: "NavigateFailed",
|
|
3214
|
+
message: `Navigation to ${url} did not produce a tab reporting that URL. The URL may not exist, or the browser refused the navigation (Firefox rejects privileged about:/chrome: URLs and other extensions' moz-extension: pages).`
|
|
3215
|
+
},
|
|
3216
|
+
hint: "Confirm the URL, or discover open tabs with extension_dom_inspect listTabs: true. For an extension page, the path must match the BUILT manifest."
|
|
3217
|
+
});
|
|
3218
|
+
return JSON.stringify({
|
|
3219
|
+
ok: true,
|
|
3220
|
+
navigated: url,
|
|
3221
|
+
tab: {
|
|
3222
|
+
tabId: settled.tabId,
|
|
3223
|
+
url: settled.url,
|
|
3224
|
+
title: settled.title
|
|
3225
|
+
},
|
|
3226
|
+
hint: "Inspect it with extension_dom_inspect or extension_eval using url or this numeric tab id (context: 'page'/'content')."
|
|
3227
|
+
});
|
|
3228
|
+
}
|
|
3229
|
+
async function resolveBridgeBaseUrl(projectPath, browser, timeout) {
|
|
3230
|
+
const raw = await runActVerb([
|
|
3231
|
+
"eval",
|
|
3232
|
+
'(typeof browser !== "undefined" ? browser : chrome).runtime.getURL("")',
|
|
3233
|
+
projectPath,
|
|
3234
|
+
"--context",
|
|
3235
|
+
"background",
|
|
3236
|
+
"--browser",
|
|
3237
|
+
browser,
|
|
3238
|
+
...null != timeout ? [
|
|
3239
|
+
"--timeout",
|
|
3240
|
+
String(timeout)
|
|
3241
|
+
] : []
|
|
3242
|
+
], projectPath, timeout);
|
|
3243
|
+
try {
|
|
3244
|
+
const parsed = JSON.parse(raw);
|
|
3245
|
+
if (parsed?.ok && "string" == typeof parsed.value && parsed.value) return parsed.value.endsWith("/") ? parsed.value : `${parsed.value}/`;
|
|
3246
|
+
} catch {}
|
|
3247
|
+
return null;
|
|
3248
|
+
}
|
|
3249
|
+
function rdp_define_property(obj, key, value) {
|
|
3250
|
+
if (key in obj) Object.defineProperty(obj, key, {
|
|
3251
|
+
value: value,
|
|
3252
|
+
enumerable: true,
|
|
3253
|
+
configurable: true,
|
|
3254
|
+
writable: true
|
|
3255
|
+
});
|
|
3256
|
+
else obj[key] = value;
|
|
3257
|
+
return obj;
|
|
3258
|
+
}
|
|
3259
|
+
function encodeRdpPacket(packet) {
|
|
3260
|
+
const json = Buffer.from(JSON.stringify(packet), "utf8");
|
|
3261
|
+
return Buffer.concat([
|
|
3262
|
+
Buffer.from(`${json.length}:`, "ascii"),
|
|
3263
|
+
json
|
|
3264
|
+
]);
|
|
3265
|
+
}
|
|
3266
|
+
class RdpPacketDecoder {
|
|
3267
|
+
push(chunk) {
|
|
3268
|
+
this.buffer = Buffer.concat([
|
|
3269
|
+
this.buffer,
|
|
3270
|
+
chunk
|
|
3271
|
+
]);
|
|
3272
|
+
const packets = [];
|
|
3273
|
+
for(;;){
|
|
3274
|
+
const colon = this.buffer.indexOf(0x3a);
|
|
3275
|
+
if (-1 === colon) {
|
|
3276
|
+
if (this.buffer.length > 16) throw new Error("RDP stream corrupt: no length prefix");
|
|
3277
|
+
break;
|
|
3278
|
+
}
|
|
3279
|
+
const prefix = this.buffer.subarray(0, colon).toString("ascii");
|
|
3280
|
+
if (!/^\d+$/.test(prefix)) throw new Error(`RDP stream corrupt: bad length prefix "${prefix}"`);
|
|
3281
|
+
const length = Number(prefix);
|
|
3282
|
+
if (this.buffer.length < colon + 1 + length) break;
|
|
3283
|
+
const json = this.buffer.subarray(colon + 1, colon + 1 + length);
|
|
3284
|
+
this.buffer = this.buffer.subarray(colon + 1 + length);
|
|
3285
|
+
packets.push(JSON.parse(json.toString("utf8")));
|
|
3286
|
+
}
|
|
3287
|
+
return packets;
|
|
3288
|
+
}
|
|
3289
|
+
constructor(){
|
|
3290
|
+
rdp_define_property(this, "buffer", Buffer.alloc(0));
|
|
3291
|
+
}
|
|
3292
|
+
}
|
|
3293
|
+
class RdpSession {
|
|
3294
|
+
static connect(port, timeoutMs = 10000) {
|
|
3295
|
+
return new Promise((resolve, reject)=>{
|
|
3296
|
+
const socket = node_net.connect({
|
|
3297
|
+
host: "127.0.0.1",
|
|
3298
|
+
port
|
|
3299
|
+
});
|
|
3300
|
+
const session = new RdpSession(socket);
|
|
3301
|
+
const decoder = new RdpPacketDecoder();
|
|
3302
|
+
let settledConnect = false;
|
|
3303
|
+
const timer = setTimeout(()=>{
|
|
3304
|
+
if (!settledConnect) {
|
|
3305
|
+
settledConnect = true;
|
|
3306
|
+
session.close();
|
|
3307
|
+
reject(new Error(`RDP connect timed out after ${timeoutMs}ms`));
|
|
3308
|
+
}
|
|
3309
|
+
}, timeoutMs);
|
|
3310
|
+
socket.on("data", (chunk)=>{
|
|
3311
|
+
let packets;
|
|
3312
|
+
try {
|
|
3313
|
+
packets = decoder.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
3314
|
+
} catch {
|
|
3315
|
+
session.close();
|
|
3316
|
+
return;
|
|
3317
|
+
}
|
|
3318
|
+
for (const packet of packets){
|
|
3319
|
+
if (!settledConnect && "root" === packet.from) {
|
|
3320
|
+
settledConnect = true;
|
|
3321
|
+
clearTimeout(timer);
|
|
3322
|
+
resolve(session);
|
|
3323
|
+
continue;
|
|
3324
|
+
}
|
|
3325
|
+
for (const tap of session.taps)tap(packet);
|
|
3326
|
+
for(let i = 0; i < session.waiters.length; i++)if (session.waiters[i].match(packet)) {
|
|
3327
|
+
session.waiters.splice(i, 1)[0].resolve(packet);
|
|
3328
|
+
break;
|
|
3329
|
+
}
|
|
3330
|
+
}
|
|
3331
|
+
});
|
|
3332
|
+
socket.on("error", (error)=>{
|
|
3333
|
+
if (!settledConnect) {
|
|
3334
|
+
settledConnect = true;
|
|
3335
|
+
clearTimeout(timer);
|
|
3336
|
+
reject(error);
|
|
3337
|
+
}
|
|
3338
|
+
});
|
|
3339
|
+
socket.on("close", ()=>{
|
|
3340
|
+
session.closed = true;
|
|
3341
|
+
for (const waiter of session.waiters.splice(0))waiter.reject(new Error("RDP connection closed before a reply arrived"));
|
|
3342
|
+
});
|
|
3343
|
+
});
|
|
3344
|
+
}
|
|
3345
|
+
request(actor, packet, timeoutMs = 10000) {
|
|
3346
|
+
return new Promise((resolve, reject)=>{
|
|
3347
|
+
if (this.closed) return void reject(new Error("RDP connection is closed"));
|
|
3348
|
+
const waiter = {
|
|
3349
|
+
match: (p)=>p.from === actor && void 0 === p.type,
|
|
3350
|
+
resolve: (p)=>{
|
|
3351
|
+
clearTimeout(timer);
|
|
3352
|
+
if ("string" == typeof p.error) reject(new Error(`RDP actor error: ${p.error}${"string" == typeof p.message ? ` (${p.message})` : ""}`));
|
|
3353
|
+
else resolve(p);
|
|
3354
|
+
},
|
|
3355
|
+
reject: (error)=>{
|
|
3356
|
+
clearTimeout(timer);
|
|
3357
|
+
reject(error);
|
|
3358
|
+
}
|
|
3359
|
+
};
|
|
3360
|
+
const timer = setTimeout(()=>{
|
|
3361
|
+
const at = this.waiters.indexOf(waiter);
|
|
3362
|
+
if (-1 !== at) this.waiters.splice(at, 1);
|
|
3363
|
+
reject(new Error(`RDP request ${String(packet.type)} to ${actor} timed out after ${timeoutMs}ms`));
|
|
3364
|
+
}, timeoutMs);
|
|
3365
|
+
this.waiters.push(waiter);
|
|
3366
|
+
this.socket.write(encodeRdpPacket({
|
|
3367
|
+
to: actor,
|
|
3368
|
+
...packet
|
|
3369
|
+
}));
|
|
3370
|
+
});
|
|
3371
|
+
}
|
|
3372
|
+
tap(handler) {
|
|
3373
|
+
this.taps.add(handler);
|
|
3374
|
+
return ()=>this.taps.delete(handler);
|
|
3375
|
+
}
|
|
3376
|
+
close() {
|
|
3377
|
+
this.closed = true;
|
|
3378
|
+
this.socket.destroy();
|
|
3379
|
+
}
|
|
3380
|
+
constructor(socket){
|
|
3381
|
+
rdp_define_property(this, "socket", void 0);
|
|
3382
|
+
rdp_define_property(this, "waiters", void 0);
|
|
3383
|
+
rdp_define_property(this, "taps", void 0);
|
|
3384
|
+
rdp_define_property(this, "closed", void 0);
|
|
3385
|
+
this.socket = socket;
|
|
3386
|
+
this.waiters = [];
|
|
3387
|
+
this.taps = new Set();
|
|
3388
|
+
this.closed = false;
|
|
3389
|
+
}
|
|
3390
|
+
}
|
|
3391
|
+
async function withSession(port, timeoutMs, work) {
|
|
3392
|
+
const session = await RdpSession.connect(port, timeoutMs);
|
|
3393
|
+
try {
|
|
3394
|
+
return await work(session);
|
|
3395
|
+
} finally{
|
|
3396
|
+
session.close();
|
|
3397
|
+
}
|
|
3398
|
+
}
|
|
3399
|
+
async function rdpListAddons(port, options) {
|
|
3400
|
+
const timeoutMs = options?.timeoutMs ?? 10000;
|
|
3401
|
+
return withSession(port, timeoutMs, async (session)=>{
|
|
3402
|
+
const reply = await session.request("root", {
|
|
3403
|
+
type: "listAddons"
|
|
3404
|
+
}, timeoutMs);
|
|
3405
|
+
return reply.addons ?? [];
|
|
3406
|
+
});
|
|
3407
|
+
}
|
|
3408
|
+
async function rdpListTabs(port, options) {
|
|
3409
|
+
const timeoutMs = options?.timeoutMs ?? 10000;
|
|
3410
|
+
return withSession(port, timeoutMs, async (session)=>{
|
|
3411
|
+
const reply = await session.request("root", {
|
|
3412
|
+
type: "listTabs"
|
|
3413
|
+
}, timeoutMs);
|
|
3414
|
+
return reply.tabs ?? [];
|
|
3415
|
+
});
|
|
3416
|
+
}
|
|
3417
|
+
function formatConsoleArg(arg) {
|
|
3418
|
+
if (null == arg) return String(arg);
|
|
3419
|
+
if ("object" == typeof arg) {
|
|
3420
|
+
const cls = arg.class;
|
|
3421
|
+
return "string" == typeof cls ? `[${cls}]` : "[object]";
|
|
3422
|
+
}
|
|
3423
|
+
return String(arg);
|
|
3424
|
+
}
|
|
3425
|
+
async function rdpCollectConsoleMessages(port, options) {
|
|
3426
|
+
const timeoutMs = options?.timeoutMs ?? 10000;
|
|
3427
|
+
const settleMs = options?.settleMs ?? 1000;
|
|
3428
|
+
return withSession(port, timeoutMs, async (session)=>{
|
|
3429
|
+
const tabsReply = await session.request("root", {
|
|
3430
|
+
type: "listTabs"
|
|
3431
|
+
}, timeoutMs);
|
|
3432
|
+
const tabs = tabsReply.tabs ?? [];
|
|
3433
|
+
const wanted = options?.urlFilter?.toLowerCase();
|
|
3434
|
+
const tab = wanted ? tabs.find((t)=>String(t.url ?? "").toLowerCase().includes(wanted)) : tabs.find((t)=>true === t.selected) ?? tabs[0];
|
|
3435
|
+
if (!tab?.actor) throw new Error(wanted ? `no open tab matches url: ${options?.urlFilter}` : "no open tabs");
|
|
3436
|
+
const watcherReply = await session.request(String(tab.actor), {
|
|
3437
|
+
type: "getWatcher",
|
|
3438
|
+
isServerTargetSwitchingEnabled: true
|
|
3439
|
+
}, timeoutMs);
|
|
3440
|
+
const watcherActor = String(watcherReply.actor ?? "");
|
|
3441
|
+
if (!watcherActor) throw new Error("tab descriptor returned no watcher");
|
|
3442
|
+
const messages = [];
|
|
3443
|
+
const untap = session.tap((packet)=>{
|
|
3444
|
+
if ("resources-available-array" !== packet.type) return;
|
|
3445
|
+
const array = Array.isArray(packet.array) ? packet.array : [];
|
|
3446
|
+
for (const entry of array){
|
|
3447
|
+
if (!Array.isArray(entry) || entry.length < 2) continue;
|
|
3448
|
+
const [resourceType, resources] = entry;
|
|
3449
|
+
if (Array.isArray(resources)) for (const raw of resources){
|
|
3450
|
+
const resource = raw;
|
|
3451
|
+
if ("console-message" === resourceType) {
|
|
3452
|
+
const args = Array.isArray(resource.arguments) ? resource.arguments : [];
|
|
3453
|
+
messages.push({
|
|
3454
|
+
level: String(resource.level ?? "log"),
|
|
3455
|
+
text: args.map(formatConsoleArg).join(" ")
|
|
3456
|
+
});
|
|
3457
|
+
} else if ("error-message" === resourceType) {
|
|
3458
|
+
const pageError = resource.pageError ?? {};
|
|
3459
|
+
messages.push({
|
|
3460
|
+
level: true === pageError.warning ? "warn" : "error",
|
|
3461
|
+
text: String(pageError.errorMessage ?? "")
|
|
3462
|
+
});
|
|
3463
|
+
}
|
|
3464
|
+
}
|
|
3465
|
+
}
|
|
3466
|
+
});
|
|
3467
|
+
try {
|
|
3468
|
+
await session.request(watcherActor, {
|
|
3469
|
+
type: "watchTargets",
|
|
3470
|
+
targetType: "frame"
|
|
3471
|
+
}, timeoutMs);
|
|
3472
|
+
await session.request(watcherActor, {
|
|
3473
|
+
type: "watchResources",
|
|
3474
|
+
resourceTypes: [
|
|
3475
|
+
"console-message",
|
|
3476
|
+
"error-message"
|
|
3477
|
+
]
|
|
3478
|
+
}, timeoutMs);
|
|
3479
|
+
await new Promise((resolve)=>setTimeout(resolve, settleMs));
|
|
3480
|
+
} finally{
|
|
3481
|
+
untap();
|
|
3482
|
+
}
|
|
3483
|
+
return messages;
|
|
3484
|
+
});
|
|
3485
|
+
}
|
|
3486
|
+
function buildBridgeInspectExpression(opts) {
|
|
3487
|
+
const parts = [
|
|
3488
|
+
"const out = {};"
|
|
3489
|
+
];
|
|
3490
|
+
if (opts.meta) parts.push("try { out.meta = { url: location.href, title: document.title, readyState: document.readyState }; } catch (e) {}");
|
|
3491
|
+
if (opts.summary) parts.push(`try {
|
|
3492
|
+
const roots = document.querySelectorAll('#extension-root,[data-extension-root]:not([data-extension-root="extension-js-devtools"])');
|
|
3493
|
+
out.summary = {
|
|
3494
|
+
htmlLength: document.documentElement.outerHTML.length,
|
|
3495
|
+
scriptCount: document.querySelectorAll('script').length,
|
|
3496
|
+
styleCount: document.querySelectorAll('style').length,
|
|
3497
|
+
linkCount: document.querySelectorAll('link').length,
|
|
3498
|
+
extensionRootCount: roots.length,
|
|
3499
|
+
bodyChildCount: document.body ? document.body.children.length : 0
|
|
3500
|
+
};
|
|
3501
|
+
} catch (e) { out.summary = {}; }`);
|
|
3502
|
+
if (opts.html) parts.push(`try {
|
|
3503
|
+
const html = ${PAGE_HTML_SCRIPT};
|
|
3504
|
+
const cap = ${JSON.stringify(opts.maxBytes)};
|
|
3505
|
+
out.htmlTruncated = cap > 0 && html.length > cap;
|
|
3506
|
+
out.html = out.htmlTruncated ? html.slice(0, cap) : html;
|
|
3507
|
+
} catch (e) {}`);
|
|
3508
|
+
if (opts.domSnapshot) parts.push(`try { out.domSnapshot = ${domSnapshotScript(500)}; } catch (e) {}`);
|
|
3509
|
+
if (opts.extensionRoots) parts.push(`try { out.extensionRoots = ${EXTENSION_ROOT_META_SCRIPT}; } catch (e) {}`);
|
|
3510
|
+
if (opts.probes.length) parts.push(`out.probes = {};
|
|
3511
|
+
for (const sel of ${JSON.stringify(opts.probes)}) {
|
|
3512
|
+
try {
|
|
3513
|
+
const nodes = document.querySelectorAll(sel);
|
|
3514
|
+
const first = nodes[0];
|
|
3515
|
+
out.probes[sel] = { count: nodes.length, sample: first ? String(first.outerHTML || "").slice(0, 200) : null };
|
|
3516
|
+
} catch (e) { out.probes[sel] = { error: String((e && e.message) || e) }; }
|
|
3517
|
+
}`);
|
|
3518
|
+
parts.push("return out;");
|
|
3519
|
+
return `(() => { ${parts.join("\n")} })()`;
|
|
3520
|
+
}
|
|
3521
|
+
function closedShadowWalkerCode(cap) {
|
|
3522
|
+
return `
|
|
3523
|
+
(function() {
|
|
3524
|
+
var out = { api: ("openOrClosedShadowRoot" in Element.prototype), closed: [] };
|
|
3525
|
+
function walk(node) {
|
|
3526
|
+
if (!node || node.nodeType !== 1) return;
|
|
3527
|
+
var sr = null;
|
|
3528
|
+
try { sr = node.openOrClosedShadowRoot || null; } catch (e) {}
|
|
3529
|
+
if (sr && sr.mode !== "open") out.closed.push({ host: node.tagName.toLowerCase(), html: String(sr.innerHTML).slice(0, ${cap}) });
|
|
3530
|
+
var kids = node.children;
|
|
3531
|
+
for (var i = 0; i < kids.length; i++) walk(kids[i]);
|
|
3532
|
+
if (sr) { var sk = sr.children; for (var j = 0; j < sk.length; j++) walk(sk[j]); }
|
|
3533
|
+
}
|
|
3534
|
+
walk(document.documentElement);
|
|
3535
|
+
return out;
|
|
3536
|
+
})();
|
|
3537
|
+
`;
|
|
3538
|
+
}
|
|
3539
|
+
function executeScriptExpression(urlFilter, code) {
|
|
3540
|
+
const pick = urlFilter ? `tabs.find(function (t) { return String(t.url || "").toLowerCase().indexOf(${JSON.stringify(urlFilter.toLowerCase())}) !== -1; })` : "(tabs.find(function (t) { return t.active; }) || tabs[0])";
|
|
3541
|
+
return `browser.tabs.query({}).then(function (tabs) {
|
|
3542
|
+
var tab = ${pick};
|
|
3543
|
+
if (!tab) return { error: "no matching tab" };
|
|
3544
|
+
return browser.tabs.executeScript(tab.id, { code: ${JSON.stringify(code)} }).then(
|
|
3545
|
+
function (results) { return { frames: results }; },
|
|
3546
|
+
function (err) { return { error: String((err && err.message) || err) }; }
|
|
3547
|
+
);
|
|
3548
|
+
})`;
|
|
3549
|
+
}
|
|
3550
|
+
async function collectGeckoDeepDom(args, browser, urlFilter, cap, result, notes) {
|
|
3551
|
+
const raw = await runActVerb([
|
|
3552
|
+
"eval",
|
|
3553
|
+
executeScriptExpression(urlFilter, closedShadowWalkerCode(cap)),
|
|
3554
|
+
args.projectPath,
|
|
3555
|
+
"--context",
|
|
3556
|
+
"background",
|
|
3557
|
+
"--browser",
|
|
3558
|
+
browser,
|
|
3559
|
+
...null != args.timeout ? [
|
|
3560
|
+
"--timeout",
|
|
3561
|
+
String(args.timeout)
|
|
3562
|
+
] : []
|
|
3563
|
+
], args.projectPath, args.timeout);
|
|
3564
|
+
let parsed;
|
|
3565
|
+
try {
|
|
3566
|
+
parsed = JSON.parse(raw);
|
|
3567
|
+
} catch {
|
|
3568
|
+
parsed = null;
|
|
3569
|
+
}
|
|
3570
|
+
const value = parsed?.ok === true ? parsed.value : null;
|
|
3571
|
+
const frame = Array.isArray(value?.frames) ? value.frames[0] : null;
|
|
3572
|
+
if (frame && Array.isArray(frame.closed)) {
|
|
3573
|
+
result.closedShadowRoots = frame.closed.map((c)=>({
|
|
3574
|
+
host: String(c.host ?? ""),
|
|
3575
|
+
type: "closed",
|
|
3576
|
+
html: String(c.html ?? "")
|
|
3577
|
+
}));
|
|
3578
|
+
result.deepDom = true;
|
|
3579
|
+
return;
|
|
3580
|
+
}
|
|
3581
|
+
const reason = value?.error ?? parsed?.error?.message ?? "the content-script walk returned nothing";
|
|
3582
|
+
notes.push(`deepDom failed on ${browser}: ${reason}. The walk runs via tabs.executeScript (MV2) and needs the extension to hold host permissions for the target url.`);
|
|
3583
|
+
}
|
|
3584
|
+
async function collectGeckoConsole(args, browser, urlFilter, result, notes) {
|
|
3585
|
+
const fallbackNote = `Console capture on ${browser} rides the RDP watcher replay and needs a session whose ready contract publishes rdpPort (extension.js 4.0.15+); extension_logs streams the extension's own console either way.`;
|
|
3586
|
+
const resolved = await resolveRdpPort(args.projectPath, browser, {
|
|
3587
|
+
waitMs: 5000
|
|
3588
|
+
});
|
|
3589
|
+
if (!resolved) return void notes.push(fallbackNote);
|
|
3590
|
+
try {
|
|
3591
|
+
const messages = await rdpCollectConsoleMessages(resolved.port, {
|
|
3592
|
+
urlFilter
|
|
3593
|
+
});
|
|
3594
|
+
result.console = summarizeConsoleMessages(messages);
|
|
3595
|
+
result.rdpPort = resolved.port;
|
|
3596
|
+
} catch (error) {
|
|
3597
|
+
notes.push(`Console capture over RDP failed: ${error.message}. ${fallbackNote}`);
|
|
3598
|
+
}
|
|
3599
|
+
}
|
|
3600
|
+
async function inspectViaBridge(args, browser, include, maxBytes) {
|
|
3601
|
+
const notes = [];
|
|
3602
|
+
if (args.url) {
|
|
3603
|
+
const listed = await listBridgeTabs(args.projectPath, browser, args.timeout);
|
|
3604
|
+
if ("error" in listed) return listed.error;
|
|
3605
|
+
const already = listed.tabs.some((t)=>t.url.includes(args.url));
|
|
3606
|
+
if (!already) {
|
|
3607
|
+
const nav = await navigateToUrlViaBridge(args.projectPath, browser, args.url, args.timeout);
|
|
3608
|
+
try {
|
|
3609
|
+
if (JSON.parse(nav)?.ok !== true) return nav;
|
|
3610
|
+
} catch {
|
|
3611
|
+
return nav;
|
|
3612
|
+
}
|
|
3613
|
+
}
|
|
3614
|
+
}
|
|
3615
|
+
const expression = buildBridgeInspectExpression({
|
|
3616
|
+
summary: include.has("summary"),
|
|
3617
|
+
meta: true,
|
|
3618
|
+
html: include.has("html"),
|
|
3619
|
+
domSnapshot: include.has("dom_snapshot"),
|
|
3620
|
+
extensionRoots: include.has("extension_roots"),
|
|
3621
|
+
probes: args.probe ?? [],
|
|
3622
|
+
maxBytes
|
|
3623
|
+
});
|
|
3624
|
+
let raw = await runActVerb([
|
|
3625
|
+
"eval",
|
|
3626
|
+
expression,
|
|
3627
|
+
args.projectPath,
|
|
3628
|
+
"--context",
|
|
3629
|
+
"page",
|
|
3630
|
+
...args.url ? [
|
|
3631
|
+
"--url",
|
|
3632
|
+
args.url
|
|
3633
|
+
] : [],
|
|
3634
|
+
"--browser",
|
|
3635
|
+
browser,
|
|
3636
|
+
...null != args.timeout ? [
|
|
3637
|
+
"--timeout",
|
|
3638
|
+
String(args.timeout)
|
|
3639
|
+
] : []
|
|
3640
|
+
], args.projectPath, args.timeout);
|
|
3641
|
+
let parsed;
|
|
3642
|
+
try {
|
|
3643
|
+
parsed = JSON.parse(raw);
|
|
3644
|
+
} catch {
|
|
3645
|
+
return raw;
|
|
3646
|
+
}
|
|
3647
|
+
let value = parsed?.ok === true ? parsed.value ?? {} : null;
|
|
3648
|
+
if (null === value && /scripting is not available/i.test(String(parsed?.error?.message ?? ""))) {
|
|
3649
|
+
raw = await runActVerb([
|
|
3650
|
+
"eval",
|
|
3651
|
+
executeScriptExpression(args.url, expression),
|
|
3652
|
+
args.projectPath,
|
|
3653
|
+
"--context",
|
|
3654
|
+
"background",
|
|
3655
|
+
"--browser",
|
|
3656
|
+
browser,
|
|
3657
|
+
...null != args.timeout ? [
|
|
3658
|
+
"--timeout",
|
|
3659
|
+
String(args.timeout)
|
|
3660
|
+
] : []
|
|
3661
|
+
], args.projectPath, args.timeout);
|
|
3662
|
+
try {
|
|
3663
|
+
parsed = JSON.parse(raw);
|
|
3664
|
+
} catch {
|
|
3665
|
+
return raw;
|
|
3666
|
+
}
|
|
3667
|
+
const frame = Array.isArray(parsed?.value?.frames) ? parsed.value.frames[0] : null;
|
|
3668
|
+
if (parsed?.ok === true && frame && "object" == typeof frame) value = frame;
|
|
3669
|
+
else if (parsed?.ok === true) return JSON.stringify({
|
|
3670
|
+
ok: false,
|
|
3671
|
+
error: {
|
|
3672
|
+
name: "InspectFailed",
|
|
3673
|
+
message: String(parsed?.value?.error ?? "the content-script inspect returned nothing")
|
|
3674
|
+
},
|
|
3675
|
+
hint: "The MV2 fallback inspects via tabs.executeScript, which needs the extension to hold host permissions for the target url."
|
|
3676
|
+
});
|
|
3677
|
+
}
|
|
3678
|
+
if (null === value) return raw;
|
|
3679
|
+
const result = {
|
|
3680
|
+
browser,
|
|
3681
|
+
transport: "bridge"
|
|
3682
|
+
};
|
|
3683
|
+
if (value.meta) {
|
|
3684
|
+
result.target = {
|
|
3685
|
+
url: value.meta.url,
|
|
3686
|
+
title: value.meta.title
|
|
3687
|
+
};
|
|
3688
|
+
if (include.has("meta")) result.meta = value.meta;
|
|
3689
|
+
}
|
|
3690
|
+
if (include.has("summary") && value.summary) result.summary = value.summary;
|
|
3691
|
+
if (include.has("html") && "string" == typeof value.html) {
|
|
3692
|
+
result.html = value.html;
|
|
3693
|
+
if (value.htmlTruncated) result.htmlTruncated = true;
|
|
3694
|
+
}
|
|
3695
|
+
if (include.has("dom_snapshot") && value.domSnapshot) result.domSnapshot = value.domSnapshot;
|
|
3696
|
+
if (include.has("extension_roots") && void 0 !== value.extensionRoots) result.extensionRoots = value.extensionRoots;
|
|
3697
|
+
if (value.probes) {
|
|
3698
|
+
result.probes = value.probes;
|
|
3699
|
+
const jsLooking = (args.probe ?? []).filter((p)=>/^typeof\s|^(chrome|browser|window|document)\.|\(\)|=>|===/.test(p));
|
|
3700
|
+
if (jsLooking.length) result.probeWarning = `Probes are CSS selectors run through querySelectorAll against the live page, NOT JavaScript expressions. ${jsLooking.map((s)=>`"${s}"`).join(", ")} parsed as selectors and will match nothing. To evaluate JS, use extension_eval.`;
|
|
3701
|
+
}
|
|
3702
|
+
const urlFilter = args.url ?? ("string" == typeof value.meta?.url ? value.meta.url : void 0);
|
|
3703
|
+
if (include.has("console")) await collectGeckoConsole(args, browser, urlFilter, result, notes);
|
|
3704
|
+
if (args.deepDom) {
|
|
3705
|
+
const cap = maxBytes > 0 ? maxBytes : 65536;
|
|
3706
|
+
await collectGeckoDeepDom(args, browser, urlFilter, cap, result, notes);
|
|
3707
|
+
}
|
|
3708
|
+
if (notes.length) result.notes = notes;
|
|
3709
|
+
return JSON.stringify(result);
|
|
3710
|
+
}
|
|
3015
3711
|
const source_inspect_schema = {
|
|
3016
3712
|
name: "extension_source_inspect",
|
|
3017
|
-
description: "Inspect a running extension's live state
|
|
3713
|
+
description: "Inspect a running extension's live state: full HTML (with shadow DOM), DOM structure, content script injection, console messages, and CSS selector queries. Chromium sessions ride Chrome DevTools Protocol. Firefox sessions are fully paired: summary/meta/html/dom_snapshot/extension_roots/probes ride the agent bridge (needs allowEval: true), console rides the RDP watcher replay (engine 4.0.15+), and deepDom walks closed shadow roots via a content-script eval (MV2 sessions with host permissions for the target url; Firefox MV3 background CSP blocks bridge evals entirely). Requires an active dev or start session.",
|
|
3018
3714
|
inputSchema: {
|
|
3019
3715
|
type: "object",
|
|
3020
3716
|
properties: {
|
|
@@ -3065,7 +3761,7 @@ const source_inspect_schema = {
|
|
|
3065
3761
|
deepDom: {
|
|
3066
3762
|
type: "boolean",
|
|
3067
3763
|
default: false,
|
|
3068
|
-
description: "Pierce CLOSED shadow roots
|
|
3764
|
+
description: "Pierce CLOSED shadow roots. The default path reads open shadow roots; closed ones need this escape hatch. Chromium: CDP DOM pierce. Firefox: a content-script walk via tabs.executeScript (MV2 sessions; the extension needs host permissions for the target url)."
|
|
3069
3765
|
}
|
|
3070
3766
|
},
|
|
3071
3767
|
required: [
|
|
@@ -3081,10 +3777,7 @@ async function source_inspect_handler(args) {
|
|
|
3081
3777
|
"console"
|
|
3082
3778
|
]);
|
|
3083
3779
|
const maxBytes = args.maxBytes ?? 262144;
|
|
3084
|
-
if (!isChromiumFamily(browser)) return
|
|
3085
|
-
error: `Source inspection reads the live DOM over Chrome DevTools Protocol, which ${browser} (Gecko) does not expose. This is a capability limit, not a missing session.`,
|
|
3086
|
-
hint: `For the ${browser} session, read runtime state with extension_logs, or extension_eval / extension_dom_inspect (content/page, an open surface like popup/options/sidebar, or an override page like newtab) which work against Firefox over the control channel. To get CDP DOM inspection, run a Chromium-family dev session (extension_dev with browser: "chrome") in parallel.`
|
|
3087
|
-
});
|
|
3780
|
+
if (!isChromiumFamily(browser)) return inspectViaBridge(args, browser, include, maxBytes);
|
|
3088
3781
|
const resolved = await resolveCdpPort(args.projectPath, browser);
|
|
3089
3782
|
if (!resolved) return JSON.stringify({
|
|
3090
3783
|
error: "No active dev session found. Cannot connect to Chrome DevTools Protocol.",
|
|
@@ -3188,7 +3881,7 @@ async function source_inspect_handler(args) {
|
|
|
3188
3881
|
}
|
|
3189
3882
|
const list_extensions_schema = {
|
|
3190
3883
|
name: "extension_list_extensions",
|
|
3191
|
-
description: "List the extensions
|
|
3884
|
+
description: "List the extensions in the running dev browser. Returns each extension's id, name, version, and (on Chromium) live contexts. The entry for THIS dev session's extension (the project being served) is flagged ownExtension: true, with name and version resolved from the session's ready contract even when the browser exposes no identity. On Chromium this rides the Chrome DevTools Protocol: entries are extensions with at least one live context (a dormant MV3 service worker with no open page may be absent until it wakes), and other extensions resolve via the read-only Extensions domain when available. On Firefox this rides the Remote Debugging Protocol root actor (listAddons, engine 4.0.15+): entries are INSTALLED add-ons regardless of live contexts, with temporarilyInstalled marking temporary loads, and carry no contexts. Either way other extensions' contexts are never attached to or evaluated in. Requires an active dev or start session.",
|
|
3192
3885
|
inputSchema: {
|
|
3193
3886
|
type: "object",
|
|
3194
3887
|
properties: {
|
|
@@ -3249,9 +3942,10 @@ function readOwnIdentity(projectPath, browser) {
|
|
|
3249
3942
|
const UNRESOLVED_NOTE = "Identity unresolved: the browser's Extensions CDP domain returned nothing for this id, and other extensions' contexts are never attached to or evaluated in to read a manifest.";
|
|
3250
3943
|
async function list_extensions_handler(args) {
|
|
3251
3944
|
const { browser } = resolveSessionBrowser(args.projectPath, args.browser, "chrome");
|
|
3945
|
+
if (isGeckoFamily(browser)) return listGeckoExtensions(args.projectPath, browser);
|
|
3252
3946
|
if (!isChromiumFamily(browser)) return JSON.stringify({
|
|
3253
|
-
error: `Listing extensions for ${browser}
|
|
3254
|
-
hint:
|
|
3947
|
+
error: `Listing extensions for ${browser} is not supported: no debugging-protocol pairing exists for this browser family.`,
|
|
3948
|
+
hint: "Target a Chromium-family (CDP) or Firefox-family (RDP) dev session."
|
|
3255
3949
|
});
|
|
3256
3950
|
const resolved = await resolveCdpPort(args.projectPath, browser);
|
|
3257
3951
|
if (!resolved) return JSON.stringify({
|
|
@@ -3344,6 +4038,70 @@ async function list_extensions_handler(args) {
|
|
|
3344
4038
|
cdp.disconnect();
|
|
3345
4039
|
}
|
|
3346
4040
|
}
|
|
4041
|
+
async function listGeckoExtensions(projectPath, browser) {
|
|
4042
|
+
const resolved = await resolveRdpPort(projectPath, browser);
|
|
4043
|
+
if (!resolved) return JSON.stringify({
|
|
4044
|
+
error: "No active dev session with a Firefox debugger server (RDP) found.",
|
|
4045
|
+
hint: `Start a dev session first with extension_dev, then use extension_wait to confirm it is ready. ${RDP_PORT_MISSING_HINT}`
|
|
4046
|
+
});
|
|
4047
|
+
const rdpPort = resolved.port;
|
|
4048
|
+
try {
|
|
4049
|
+
let addons = null;
|
|
4050
|
+
let lastError = null;
|
|
4051
|
+
for(let attempt = 0; attempt < 3; attempt++)try {
|
|
4052
|
+
addons = await rdpListAddons(rdpPort);
|
|
4053
|
+
break;
|
|
4054
|
+
} catch (error) {
|
|
4055
|
+
lastError = error;
|
|
4056
|
+
if (attempt < 2) await new Promise((resolve)=>setTimeout(resolve, 800));
|
|
4057
|
+
}
|
|
4058
|
+
if (!addons) throw lastError;
|
|
4059
|
+
const own = readOwnIdentity(projectPath, browser);
|
|
4060
|
+
const extensions = addons.filter((addon)=>true === addon.isWebExtension && true !== addon.isSystem && true !== addon.hidden).map((addon)=>{
|
|
4061
|
+
const entry = {
|
|
4062
|
+
id: String(addon.id ?? addon.actor ?? ""),
|
|
4063
|
+
contexts: [],
|
|
4064
|
+
source: "rdp-root"
|
|
4065
|
+
};
|
|
4066
|
+
if ("string" == typeof addon.name) entry.name = addon.name;
|
|
4067
|
+
if ("string" == typeof addon.version) entry.version = addon.version;
|
|
4068
|
+
if (true === addon.temporarilyInstalled) entry.temporarilyInstalled = true;
|
|
4069
|
+
return entry;
|
|
4070
|
+
});
|
|
4071
|
+
if (own?.name) {
|
|
4072
|
+
const byName = extensions.filter((e)=>e.name === own.name);
|
|
4073
|
+
if (1 === byName.length) byName[0].ownExtension = true;
|
|
4074
|
+
}
|
|
4075
|
+
if (!extensions.some((e)=>e.ownExtension)) {
|
|
4076
|
+
const temporary = extensions.filter((e)=>e.temporarilyInstalled);
|
|
4077
|
+
if (1 === temporary.length) {
|
|
4078
|
+
temporary[0].ownExtension = true;
|
|
4079
|
+
if (void 0 === temporary[0].name && own?.name !== void 0) {
|
|
4080
|
+
temporary[0].name = own.name;
|
|
4081
|
+
if (void 0 !== own.version) temporary[0].version = own.version;
|
|
4082
|
+
temporary[0].source = "session-contract";
|
|
4083
|
+
}
|
|
4084
|
+
}
|
|
4085
|
+
}
|
|
4086
|
+
extensions.sort((a, b)=>{
|
|
4087
|
+
if ((a.ownExtension ?? false) !== (b.ownExtension ?? false)) return a.ownExtension ? -1 : 1;
|
|
4088
|
+
return (a.name ?? a.id).localeCompare(b.name ?? b.id);
|
|
4089
|
+
});
|
|
4090
|
+
const ownEntry = extensions.find((e)=>e.ownExtension);
|
|
4091
|
+
return JSON.stringify({
|
|
4092
|
+
rdpPort,
|
|
4093
|
+
browser,
|
|
4094
|
+
count: extensions.length,
|
|
4095
|
+
ownExtensionId: ownEntry?.id ?? null,
|
|
4096
|
+
extensions,
|
|
4097
|
+
note: "Lists INSTALLED add-ons via the RDP root actor (listAddons), regardless of whether a context is currently live, so entries carry no contexts. temporarilyInstalled marks temporary loads; ownExtension marks the extension this dev session serves, matched from the session's ready contract. Add-ons are never attached to or evaluated in."
|
|
4098
|
+
});
|
|
4099
|
+
} catch (error) {
|
|
4100
|
+
return JSON.stringify({
|
|
4101
|
+
error: `Failed to list extensions over RDP: ${error.message}`
|
|
4102
|
+
});
|
|
4103
|
+
}
|
|
4104
|
+
}
|
|
3347
4105
|
const CONTROL_WS_PATH = "/extjs-control";
|
|
3348
4106
|
const LEVEL_ORDER = [
|
|
3349
4107
|
"error",
|
|
@@ -3665,58 +4423,6 @@ async function logs_handler(args) {
|
|
|
3665
4423
|
if (args.follow) return readFromStream(args, browser, limit);
|
|
3666
4424
|
return readFromFile(args, browser, limit);
|
|
3667
4425
|
}
|
|
3668
|
-
function toMcpSpeak(text) {
|
|
3669
|
-
return text.replace(/`?extension dev(?: [^\s`]*)? --browser[= ]([\w-]+) --allow-control`?/g, 'extension_dev with { browser: "$1", allowControl: true }').replace(/--allow-control/g, "allowControl: true (extension_dev)").replace(/--allow-eval/g, "allowEval: true (extension_dev)").replace(/Use --context page --tab <id>/g, 'Use context: "page" (targets the active tab; pass url or tab to pick another)').replace(/--context[= ](background|popup|options|sidebar|devtools|newtab|history|bookmarks|content|page)\b/g, 'context: "$1"').replace(/--tab[= ](\d+|<[\w-]+>)/g, "tab: $1").replace(/--url[= ]"([^"]+)"/g, 'url: "$1"').replace(/--url[= ](<[\w-]+>|\S*(?:\/\/|\*)\S*)/g, 'url: "$1"').replace(/--browser[= ]([\w]+-based|chrome|chromium|edge|brave|opera|vivaldi|yandex|firefox|waterfox|librewolf|safari)\b/g, 'browser: "$1"').replace(/--timeout[= ](\d+)/g, "timeout: $1").replace(/`extension dev`/g, "extension_dev").replace(/\bextension dev\b/g, "extension_dev").replace(/--tab\b/g, "`tab`").replace(/--url\b/g, "`url`").replace(/--context\b/g, "`context`").replace(/--browser\b/g, "`browser`").replace(/--timeout\b/g, "`timeout`");
|
|
3670
|
-
}
|
|
3671
|
-
function withSessionContext(message, projectPath) {
|
|
3672
|
-
const isControlError = /no active control channel|control channel refused|\b1006\b|no executor connected|is the session started with allowControl/i.test(message);
|
|
3673
|
-
if (!isControlError) return message;
|
|
3674
|
-
const dead = deadReadySession(projectPath);
|
|
3675
|
-
if (dead) return `${message}\nLikely cause: the dev server has exited, ${dead.browser} ready.json still says ready but its pid ${dead.pid} is dead. Restart with extension_dev (this is not an allowControl problem); extension_doctor confirms.`;
|
|
3676
|
-
const running = knownSessionBrowsers(projectPath);
|
|
3677
|
-
if (0 === running.length) return message;
|
|
3678
|
-
return `${message} Active session browser(s) for this project: ${running.join(", ")}, pass that as \`browser\`, or restart it via extension_dev with allowControl: true if the control channel is off.`;
|
|
3679
|
-
}
|
|
3680
|
-
function translateFrame(frame, projectPath) {
|
|
3681
|
-
if (!frame || false !== frame.ok) return frame;
|
|
3682
|
-
if (frame.error && "string" == typeof frame.error.message) frame.error.message = withSessionContext(toMcpSpeak(frame.error.message), projectPath);
|
|
3683
|
-
if ("string" == typeof frame.error?.hint) frame.error.hint = toMcpSpeak(frame.error.hint);
|
|
3684
|
-
if ("string" == typeof frame.hint) frame.hint = toMcpSpeak(frame.hint);
|
|
3685
|
-
return frame;
|
|
3686
|
-
}
|
|
3687
|
-
async function runActVerb(args, projectPath, timeoutMs) {
|
|
3688
|
-
const { code, stdout, stderr } = await runExtensionCli([
|
|
3689
|
-
...args,
|
|
3690
|
-
"--output",
|
|
3691
|
-
"json"
|
|
3692
|
-
], {
|
|
3693
|
-
cwd: projectPath,
|
|
3694
|
-
timeoutMs
|
|
3695
|
-
});
|
|
3696
|
-
const out = stdout.trim();
|
|
3697
|
-
if (out) try {
|
|
3698
|
-
const frame = JSON.parse(out);
|
|
3699
|
-
if (frame && false === frame.ok) return JSON.stringify(translateFrame(frame, projectPath));
|
|
3700
|
-
return out;
|
|
3701
|
-
} catch {}
|
|
3702
|
-
const message = stderr.trim() || `extension exited with code ${code}`;
|
|
3703
|
-
return JSON.stringify({
|
|
3704
|
-
ok: false,
|
|
3705
|
-
error: {
|
|
3706
|
-
name: "CliError",
|
|
3707
|
-
message: withSessionContext(toMcpSpeak(message), projectPath)
|
|
3708
|
-
}
|
|
3709
|
-
});
|
|
3710
|
-
}
|
|
3711
|
-
function commonFlags(args) {
|
|
3712
|
-
const flags = [];
|
|
3713
|
-
if (args.context) flags.push("--context", args.context);
|
|
3714
|
-
if (args.url) flags.push("--url", args.url);
|
|
3715
|
-
if (null != args.tab) flags.push("--tab", String(args.tab));
|
|
3716
|
-
if (args.browser) flags.push("--browser", args.browser);
|
|
3717
|
-
if (null != args.timeout) flags.push("--timeout", String(args.timeout));
|
|
3718
|
-
return flags;
|
|
3719
|
-
}
|
|
3720
4426
|
const eval_schema = {
|
|
3721
4427
|
name: "extension_eval",
|
|
3722
4428
|
description: "Evaluate an expression in a running extension context. Requires the dev session to be started with allowEval: true (extension_dev; writes a 0600 session token the CLI reads). Context default: on a Chromium session whose manifest is MV3 (the default template) the default is `page` (the active tab), because the MV3 background is a service worker whose CSP blocks eval, so a background default would fail on the most common path; on Firefox/MV2 sessions the default stays `background`. Pass `context: \"background\"` explicitly to target the worker anyway (on Chromium MV3 that returns the CSP explanation). Targeting for context content/page: pass `url` to pick the matching tab, or omit both `url` and `tab` to use the ACTIVE tab; a numeric `tab` id is only needed to disambiguate. Extension surfaces (popup/options/sidebar/devtools) and override pages (newtab/history/bookmarks) evaluate over the in-bundle relay and need NO tab id; the surface must be OPEN (extension_open first; a closed surface returns an explicit error). Use extension_dom_inspect with listTabs: true to enumerate {tabId,url,title}. Wraps `extension eval`.",
|
|
@@ -3984,14 +4690,8 @@ async function pollForTarget(port, url, budgetMs) {
|
|
|
3984
4690
|
await new Promise((r)=>setTimeout(r, 250));
|
|
3985
4691
|
}
|
|
3986
4692
|
}
|
|
3987
|
-
async function navigateToUrl(projectPath, browser, url) {
|
|
3988
|
-
if (!isChromiumFamily(browser)) return
|
|
3989
|
-
ok: false,
|
|
3990
|
-
error: {
|
|
3991
|
-
name: "Unsupported",
|
|
3992
|
-
message: `URL navigation drives a tab over Chrome DevTools Protocol, which ${browser} (Gecko) does not expose. On Firefox, drive the page via extension_eval (context: "page"/"content") or read extension_logs.`
|
|
3993
|
-
}
|
|
3994
|
-
});
|
|
4693
|
+
async function navigateToUrl(projectPath, browser, url, timeout) {
|
|
4694
|
+
if (!isChromiumFamily(browser)) return navigateToUrlViaBridge(projectPath, browser, url, timeout);
|
|
3995
4695
|
const resolved = await resolveCdpPort(projectPath, browser);
|
|
3996
4696
|
if (!resolved) return JSON.stringify({
|
|
3997
4697
|
ok: false,
|
|
@@ -4234,16 +4934,32 @@ async function applyPopupBounds(projectPath, browser, targetId) {
|
|
|
4234
4934
|
async function openSurfaceAsTab(projectPath, browser, surface) {
|
|
4235
4935
|
const doc = surfaceDocument(projectPath, browser, surface);
|
|
4236
4936
|
if (!doc) return missingSurfaceError(projectPath, browser, surface, "so there is no page to render as a tab");
|
|
4237
|
-
|
|
4238
|
-
|
|
4239
|
-
|
|
4240
|
-
|
|
4241
|
-
|
|
4242
|
-
|
|
4243
|
-
|
|
4244
|
-
|
|
4245
|
-
|
|
4246
|
-
|
|
4937
|
+
let url;
|
|
4938
|
+
let extensionId = null;
|
|
4939
|
+
if (isChromiumFamily(browser)) {
|
|
4940
|
+
extensionId = await resolveExtensionId(projectPath, browser);
|
|
4941
|
+
if (!extensionId) return JSON.stringify({
|
|
4942
|
+
ok: false,
|
|
4943
|
+
error: {
|
|
4944
|
+
name: "NoExtensionId",
|
|
4945
|
+
message: "Could not resolve the extension id from the live session's CDP targets."
|
|
4946
|
+
},
|
|
4947
|
+
hint: `Confirm the session is ready (extension_wait). ${CDP_PORT_MISSING_HINT}`
|
|
4948
|
+
});
|
|
4949
|
+
url = `chrome-extension://${extensionId}/${doc}`;
|
|
4950
|
+
} else {
|
|
4951
|
+
const base = await resolveBridgeBaseUrl(projectPath, browser);
|
|
4952
|
+
if (!base) return JSON.stringify({
|
|
4953
|
+
ok: false,
|
|
4954
|
+
error: {
|
|
4955
|
+
name: "NoExtensionId",
|
|
4956
|
+
message: "Could not resolve the extension's moz-extension:// base URL from the live session (a background eval of runtime.getURL)."
|
|
4957
|
+
},
|
|
4958
|
+
hint: "Confirm the session is ready (extension_wait) and was started with allowEval: true (extension_dev)."
|
|
4959
|
+
});
|
|
4960
|
+
url = `${base}${doc}`;
|
|
4961
|
+
extensionId = base.replace(/^.*:\/\//, "").replace(/\/$/, "");
|
|
4962
|
+
}
|
|
4247
4963
|
const raw = await navigateToUrl(projectPath, browser, url);
|
|
4248
4964
|
try {
|
|
4249
4965
|
const parsed = JSON.parse(raw);
|
|
@@ -4251,14 +4967,14 @@ async function openSurfaceAsTab(projectPath, browser, surface) {
|
|
|
4251
4967
|
parsed.renderedAsTab = {
|
|
4252
4968
|
surface,
|
|
4253
4969
|
document: doc,
|
|
4254
|
-
extensionId
|
|
4970
|
+
extensionId
|
|
4255
4971
|
};
|
|
4256
4972
|
let popupBounds = null;
|
|
4257
4973
|
if (("popup" === surface || "action" === surface) && "string" == typeof parsed.target?.targetId) {
|
|
4258
4974
|
popupBounds = await applyPopupBounds(projectPath, browser, parsed.target.targetId);
|
|
4259
4975
|
if (popupBounds) parsed.renderedAsTab.popupBounds = popupBounds;
|
|
4260
4976
|
}
|
|
4261
|
-
parsed.hint = `Rendered the ${surface} document in a real tab, which is how you inspect a surface headlessly. ` + (popupBounds ? `The window was resized to the popup's content size (${popupBounds.width}x${popupBounds.height}${popupBounds.clamped ? ", clamped to Chrome's 25x25-800x600 popup bounds" : ""}), approximating real popup rendering. This resizes the WHOLE browser window for the session. It is the same page with the same extension APIs, but window.close() closes the tab. ` : "It is the same page with the same extension APIs, but it is NOT hosted in a popup window: no popup sizing, and window.close() closes the tab. ") + `Inspect it with extension_dom_inspect context: '${surface}' (include: ['html']), or extension_source_inspect with this url. ` + "Do NOT pass this
|
|
4977
|
+
parsed.hint = `Rendered the ${surface} document in a real tab, which is how you inspect a surface headlessly. ` + (popupBounds ? `The window was resized to the popup's content size (${popupBounds.width}x${popupBounds.height}${popupBounds.clamped ? ", clamped to Chrome's 25x25-800x600 popup bounds" : ""}), approximating real popup rendering. This resizes the WHOLE browser window for the session. It is the same page with the same extension APIs, but window.close() closes the tab. ` : "It is the same page with the same extension APIs, but it is NOT hosted in a popup window: no popup sizing, and window.close() closes the tab. ") + `Inspect it with extension_dom_inspect context: '${surface}' (include: ['html']), or extension_source_inspect with this url. ` + "Do NOT pass this extension-page url to extension_dom_inspect or extension_eval as a tab target: script injection cannot reach extension pages, only the surface context or CDP can.";
|
|
4262
4978
|
return JSON.stringify(parsed);
|
|
4263
4979
|
}
|
|
4264
4980
|
} catch {}
|
|
@@ -4294,7 +5010,7 @@ const open_schema = {
|
|
|
4294
5010
|
},
|
|
4295
5011
|
url: {
|
|
4296
5012
|
type: "string",
|
|
4297
|
-
description: "Navigate a real tab to this URL (Chromium
|
|
5013
|
+
description: "Navigate a real tab to this URL instead of opening a surface (Chromium via CDP; Firefox via the agent bridge, which needs allowEval: true). Use for content-script/webNavigation test pages, or the popup as a page: chrome-extension://<id>/popup.html. Alternative to `surface`."
|
|
4298
5014
|
},
|
|
4299
5015
|
asTab: {
|
|
4300
5016
|
type: "boolean",
|
|
@@ -4317,7 +5033,7 @@ const open_schema = {
|
|
|
4317
5033
|
};
|
|
4318
5034
|
async function open_handler(args) {
|
|
4319
5035
|
const { browser } = resolveSessionBrowser(args.projectPath, args.browser);
|
|
4320
|
-
if (args.url) return navigateToUrl(args.projectPath, browser, args.url);
|
|
5036
|
+
if (args.url) return navigateToUrl(args.projectPath, browser, args.url, args.timeout);
|
|
4321
5037
|
const AS_TAB_SURFACES = [
|
|
4322
5038
|
"popup",
|
|
4323
5039
|
"options",
|
|
@@ -4368,7 +5084,7 @@ async function open_handler(args) {
|
|
|
4368
5084
|
const parsed = JSON.parse(raw);
|
|
4369
5085
|
const msg = String(parsed?.error?.message ?? "");
|
|
4370
5086
|
if (parsed?.ok === false && /active browser window|no active|headless|user gesture/i.test(msg)) {
|
|
4371
|
-
if (AS_TAB_SURFACES.includes(args.surface)
|
|
5087
|
+
if (AS_TAB_SURFACES.includes(args.surface)) {
|
|
4372
5088
|
const fallback = await openSurfaceAsTab(args.projectPath, browser, args.surface);
|
|
4373
5089
|
try {
|
|
4374
5090
|
const parsedFallback = JSON.parse(fallback);
|
|
@@ -4402,9 +5118,10 @@ function matchTargetsByUrl(targets, needle) {
|
|
|
4402
5118
|
if (byUrl.length > 0) return byUrl;
|
|
4403
5119
|
return targets.filter((t)=>t.title.toLowerCase().includes(wanted));
|
|
4404
5120
|
}
|
|
5121
|
+
const RDP_ACTOR_NOTE = "actor is an RDP tab descriptor actor id, NOT a chrome.tabs id: do not pass it as `tab`. Target a tab with `tabUrl` (URL substring) or `url`; if you need a numeric tab id, call extension_dom_inspect with listTabs: true.";
|
|
4405
5122
|
const dom_inspect_schema = {
|
|
4406
5123
|
name: "extension_dom_inspect",
|
|
4407
|
-
description: "Inspect a page/content-script DOM via the agent bridge (CDP-free, localhost). Returns a structured snapshot (counts, extension roots, open shadow roots, optional capped HTML). Target a tab by `tabUrl` (case-insensitive URL substring resolved against the browser's live page targets; zero or several matches return the candidates instead of guessing), by `url`, or by numeric `tab`. Discover what is open with listTargets: true (CDP targetIds) or listTabs: true (numeric chrome.tabs ids). Requires the dev session to be started with allowControl: true (extension_dev). For closed shadow roots or deep CDP inspection use extension_source_inspect. Wraps `extension inspect`.",
|
|
5124
|
+
description: "Inspect a page/content-script DOM via the agent bridge (CDP-free, localhost). Returns a structured snapshot (counts, extension roots, open shadow roots, optional capped HTML). Target a tab by `tabUrl` (case-insensitive URL substring resolved against the browser's live page targets; zero or several matches return the candidates instead of guessing), by `url`, or by numeric `tab`. Discover what is open with listTargets: true (Chromium CDP targetIds; Firefox RDP tab actors) or listTabs: true (numeric chrome.tabs ids). Requires the dev session to be started with allowControl: true (extension_dev). For closed shadow roots or deep CDP inspection use extension_source_inspect. Wraps `extension inspect`.",
|
|
4408
5125
|
inputSchema: {
|
|
4409
5126
|
type: "object",
|
|
4410
5127
|
properties: {
|
|
@@ -4422,12 +5139,12 @@ const dom_inspect_schema = {
|
|
|
4422
5139
|
},
|
|
4423
5140
|
tabUrl: {
|
|
4424
5141
|
type: "string",
|
|
4425
|
-
description: "Target the tab whose URL contains this substring (case-insensitive; titles are checked only when no url matches). Resolved against the live browser
|
|
5142
|
+
description: "Target the tab whose URL contains this substring (case-insensitive; titles are checked only when no url matches). Resolved against the live browser BEFORE inspecting (Chromium: CDP page targets; Firefox: the agent bridge tab list): exactly one match proceeds; zero or several matches return the candidates so you can narrow, never a guess. Alternative to `url`."
|
|
4426
5143
|
},
|
|
4427
5144
|
listTargets: {
|
|
4428
5145
|
type: "boolean",
|
|
4429
5146
|
default: false,
|
|
4430
|
-
description: "Enumerate the browser's
|
|
5147
|
+
description: "Enumerate the browser's live page targets and return, ignoring the other args. The discovery path for `tabUrl`. Chromium: CDP page targets as {targetId,url,title,type}. Firefox: RDP tab descriptors as {actor,url,title,type} (engine 4.0.15+). Neither id is a numeric chrome.tabs id (for those use listTabs)."
|
|
4431
5148
|
},
|
|
4432
5149
|
listTabs: {
|
|
4433
5150
|
type: "boolean",
|
|
@@ -4517,6 +5234,43 @@ async function dom_inspect_handler(args) {
|
|
|
4517
5234
|
const withConsole = true === args.withConsole ? 50 : false === args.withConsole ? void 0 : args.withConsole;
|
|
4518
5235
|
if (args.listTargets) {
|
|
4519
5236
|
const { browser } = resolveSessionBrowser(args.projectPath, args.browser);
|
|
5237
|
+
if (isGeckoFamily(browser)) {
|
|
5238
|
+
const resolved = await resolveRdpPort(args.projectPath, browser);
|
|
5239
|
+
if (!resolved) return JSON.stringify({
|
|
5240
|
+
ok: false,
|
|
5241
|
+
error: {
|
|
5242
|
+
name: "NoSession",
|
|
5243
|
+
message: `No active dev session with a Firefox debugger server (RDP) for ${browser}, so listTargets has no browser to ask. Start extension_dev and extension_wait for ready. ${RDP_PORT_MISSING_HINT}`
|
|
5244
|
+
}
|
|
5245
|
+
});
|
|
5246
|
+
try {
|
|
5247
|
+
const tabs = await rdpListTabs(resolved.port);
|
|
5248
|
+
return JSON.stringify({
|
|
5249
|
+
ok: true,
|
|
5250
|
+
browser,
|
|
5251
|
+
transport: "rdp",
|
|
5252
|
+
targets: tabs.map((t)=>({
|
|
5253
|
+
actor: String(t.actor ?? ""),
|
|
5254
|
+
type: "tab",
|
|
5255
|
+
url: String(t.url ?? ""),
|
|
5256
|
+
title: String(t.title ?? ""),
|
|
5257
|
+
...true === t.selected ? {
|
|
5258
|
+
selected: true
|
|
5259
|
+
} : {}
|
|
5260
|
+
})),
|
|
5261
|
+
note: RDP_ACTOR_NOTE
|
|
5262
|
+
});
|
|
5263
|
+
} catch (e) {
|
|
5264
|
+
return JSON.stringify({
|
|
5265
|
+
ok: false,
|
|
5266
|
+
error: {
|
|
5267
|
+
name: "RdpError",
|
|
5268
|
+
message: `Could not list tab targets over RDP: ${e instanceof Error ? e.message : String(e)}`
|
|
5269
|
+
},
|
|
5270
|
+
hint: "Confirm the session is ready (extension_wait), then retry. listTabs: true is the bridge alternative (needs allowControl)."
|
|
5271
|
+
});
|
|
5272
|
+
}
|
|
5273
|
+
}
|
|
4520
5274
|
const cdp = await cdpPortOrError(args.projectPath, browser, "listTargets");
|
|
4521
5275
|
if ("error" in cdp) return cdp.error;
|
|
4522
5276
|
try {
|
|
@@ -4550,6 +5304,7 @@ async function dom_inspect_handler(args) {
|
|
|
4550
5304
|
] : []
|
|
4551
5305
|
], args.projectPath, args.timeout);
|
|
4552
5306
|
let targetUrl = args.url;
|
|
5307
|
+
let targetTab = args.tab;
|
|
4553
5308
|
let resolvedTarget = null;
|
|
4554
5309
|
if (args.tabUrl) {
|
|
4555
5310
|
if (null != args.tab || args.url) return JSON.stringify({
|
|
@@ -4560,48 +5315,79 @@ async function dom_inspect_handler(args) {
|
|
|
4560
5315
|
}
|
|
4561
5316
|
});
|
|
4562
5317
|
const { browser } = resolveSessionBrowser(args.projectPath, args.browser);
|
|
4563
|
-
|
|
4564
|
-
|
|
4565
|
-
|
|
4566
|
-
|
|
4567
|
-
|
|
4568
|
-
|
|
4569
|
-
|
|
5318
|
+
if (isChromiumFamily(browser)) {
|
|
5319
|
+
const cdp = await cdpPortOrError(args.projectPath, browser, "tabUrl");
|
|
5320
|
+
if ("error" in cdp) return cdp.error;
|
|
5321
|
+
let targets;
|
|
5322
|
+
try {
|
|
5323
|
+
targets = await listPageTargets(cdp.port);
|
|
5324
|
+
} catch (e) {
|
|
5325
|
+
return JSON.stringify({
|
|
5326
|
+
ok: false,
|
|
5327
|
+
error: {
|
|
5328
|
+
name: "CdpError",
|
|
5329
|
+
message: `Could not list page targets to resolve tabUrl: ${e instanceof Error ? e.message : String(e)}`
|
|
5330
|
+
},
|
|
5331
|
+
hint: "Confirm the session is ready (extension_wait), then retry, or target with `url`/`tab` instead."
|
|
5332
|
+
});
|
|
5333
|
+
}
|
|
5334
|
+
const matches = matchTargetsByUrl(targets, args.tabUrl);
|
|
5335
|
+
if (0 === matches.length) return JSON.stringify({
|
|
4570
5336
|
ok: false,
|
|
4571
5337
|
error: {
|
|
4572
|
-
name: "
|
|
4573
|
-
message: `
|
|
5338
|
+
name: "NoMatchingTarget",
|
|
5339
|
+
message: `No open page target's url (or title) contains "${args.tabUrl}" (case-insensitive).`
|
|
5340
|
+
},
|
|
5341
|
+
availableTargets: targets,
|
|
5342
|
+
hint: `Pick one from availableTargets and retry with a \`tabUrl\` substring of its url, or open the page first (extension_open with \`url\`). ${TARGET_ID_NOTE}`
|
|
5343
|
+
});
|
|
5344
|
+
if (matches.length > 1) return JSON.stringify({
|
|
5345
|
+
ok: false,
|
|
5346
|
+
error: {
|
|
5347
|
+
name: "AmbiguousTabUrl",
|
|
5348
|
+
message: `${matches.length} page targets match "${args.tabUrl}"; refusing to guess which tab you mean.`
|
|
5349
|
+
},
|
|
5350
|
+
matchingTargets: matches,
|
|
5351
|
+
hint: `Narrow \`tabUrl\` to a longer substring that matches exactly one url in matchingTargets. ${TARGET_ID_NOTE}`
|
|
5352
|
+
});
|
|
5353
|
+
resolvedTarget = {
|
|
5354
|
+
...matches[0]
|
|
5355
|
+
};
|
|
5356
|
+
targetUrl = matches[0].url;
|
|
5357
|
+
} else {
|
|
5358
|
+
const listed = await listBridgeTabs(args.projectPath, browser, args.timeout);
|
|
5359
|
+
if ("error" in listed) return listed.error;
|
|
5360
|
+
const matches = matchTabsByUrl(listed.tabs, args.tabUrl);
|
|
5361
|
+
if (0 === matches.length) return JSON.stringify({
|
|
5362
|
+
ok: false,
|
|
5363
|
+
error: {
|
|
5364
|
+
name: "NoMatchingTarget",
|
|
5365
|
+
message: `No open tab's url (or title) contains "${args.tabUrl}" (case-insensitive).`
|
|
4574
5366
|
},
|
|
4575
|
-
|
|
5367
|
+
availableTabs: listed.tabs,
|
|
5368
|
+
hint: "Pick one from availableTabs and retry with a `tabUrl` substring of its url, or open the page first (extension_open with `url`)."
|
|
4576
5369
|
});
|
|
5370
|
+
if (matches.length > 1) return JSON.stringify({
|
|
5371
|
+
ok: false,
|
|
5372
|
+
error: {
|
|
5373
|
+
name: "AmbiguousTabUrl",
|
|
5374
|
+
message: `${matches.length} tabs match "${args.tabUrl}"; refusing to guess which tab you mean.`
|
|
5375
|
+
},
|
|
5376
|
+
matchingTabs: matches,
|
|
5377
|
+
hint: "Narrow `tabUrl` to a longer substring that matches exactly one url in matchingTabs, or pass its numeric tabId as `tab`."
|
|
5378
|
+
});
|
|
5379
|
+
resolvedTarget = {
|
|
5380
|
+
...matches[0]
|
|
5381
|
+
};
|
|
5382
|
+
if (null != matches[0].tabId) targetTab = matches[0].tabId;
|
|
5383
|
+
else targetUrl = matches[0].url;
|
|
4577
5384
|
}
|
|
4578
|
-
const matches = matchTargetsByUrl(targets, args.tabUrl);
|
|
4579
|
-
if (0 === matches.length) return JSON.stringify({
|
|
4580
|
-
ok: false,
|
|
4581
|
-
error: {
|
|
4582
|
-
name: "NoMatchingTarget",
|
|
4583
|
-
message: `No open page target's url (or title) contains "${args.tabUrl}" (case-insensitive).`
|
|
4584
|
-
},
|
|
4585
|
-
availableTargets: targets,
|
|
4586
|
-
hint: `Pick one from availableTargets and retry with a \`tabUrl\` substring of its url, or open the page first (extension_open with \`url\`). ${TARGET_ID_NOTE}`
|
|
4587
|
-
});
|
|
4588
|
-
if (matches.length > 1) return JSON.stringify({
|
|
4589
|
-
ok: false,
|
|
4590
|
-
error: {
|
|
4591
|
-
name: "AmbiguousTabUrl",
|
|
4592
|
-
message: `${matches.length} page targets match "${args.tabUrl}"; refusing to guess which tab you mean.`
|
|
4593
|
-
},
|
|
4594
|
-
matchingTargets: matches,
|
|
4595
|
-
hint: `Narrow \`tabUrl\` to a longer substring that matches exactly one url in matchingTargets. ${TARGET_ID_NOTE}`
|
|
4596
|
-
});
|
|
4597
|
-
resolvedTarget = matches[0];
|
|
4598
|
-
targetUrl = resolvedTarget.url;
|
|
4599
5385
|
}
|
|
4600
5386
|
const cli = [
|
|
4601
5387
|
"inspect",
|
|
4602
5388
|
args.projectPath
|
|
4603
5389
|
];
|
|
4604
|
-
if (null !=
|
|
5390
|
+
if (null != targetTab) cli.push("--tab", String(targetTab));
|
|
4605
5391
|
if (targetUrl) cli.push("--url", targetUrl);
|
|
4606
5392
|
if (args.context) cli.push("--context", args.context);
|
|
4607
5393
|
if (args.include?.length) cli.push("--include", args.include.join(","));
|