@extension.dev/mcp 5.6.0 → 5.7.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/dist/module.js CHANGED
@@ -7,7 +7,8 @@ import node_path, { join } from "node:path";
7
7
  import { extensionCreate } from "extension-create";
8
8
  import node_os from "node:os";
9
9
  import cross_spawn from "cross-spawn";
10
- import node_crypto from "node:crypto";
10
+ import node_crypto, { createHash } from "node:crypto";
11
+ import { fileURLToPath } from "node:url";
11
12
  import { execFile, execFileSync } from "node:child_process";
12
13
  import { filterKeysForThisBrowser } from "browser-extension-manifest-fields";
13
14
  import ws_0 from "ws";
@@ -220,7 +221,7 @@ __webpack_require__.d(whoami_namespaceObject, {
220
221
  handler: ()=>whoami_handler,
221
222
  schema: ()=>whoami_schema
222
223
  });
223
- var package_namespaceObject = JSON.parse('{"rE":"5.6.0","El":{"OP":"^4.0.15"}}');
224
+ var package_namespaceObject = JSON.parse('{"rE":"5.7.0","El":{"OP":"4.0.16-canary.1784889479.74e12044"}}');
224
225
  function scaffoldEnginePin(projectPath) {
225
226
  try {
226
227
  const pkg = JSON.parse(node_fs.readFileSync(node_path.join(projectPath, "package.json"), "utf8"));
@@ -854,6 +855,178 @@ function resolveSessionBrowser(projectPath, explicit, fallback = "chrome") {
854
855
  source: "fallback"
855
856
  };
856
857
  }
858
+ const CHROMIUM_FAMILY = new Set([
859
+ "chrome",
860
+ "chromium",
861
+ "edge",
862
+ "brave",
863
+ "opera",
864
+ "vivaldi",
865
+ "yandex",
866
+ "chromium-based"
867
+ ]);
868
+ const GECKO_FAMILY = new Set([
869
+ "firefox",
870
+ "waterfox",
871
+ "librewolf",
872
+ "gecko-based",
873
+ "firefox-based"
874
+ ]);
875
+ const WEBKIT_FAMILY = new Set([
876
+ "safari",
877
+ "webkit-based"
878
+ ]);
879
+ function isChromiumFamily(browser) {
880
+ return CHROMIUM_FAMILY.has(browser);
881
+ }
882
+ function isGeckoFamily(browser) {
883
+ return GECKO_FAMILY.has(browser);
884
+ }
885
+ const CARRIER_DIR_NAME = "extension-dev-live-preview";
886
+ const MARKER_FILE = "managed-by-extension-dev-mcp.json";
887
+ function deriveCarrierId(source) {
888
+ try {
889
+ const manifest = JSON.parse(node_fs.readFileSync(node_path.join(source, "manifest.json"), "utf-8"));
890
+ if (!manifest.key) return null;
891
+ const hash = createHash("sha256").update(Buffer.from(manifest.key, "base64")).digest();
892
+ return [
893
+ ...hash.subarray(0, 16)
894
+ ].map((byte)=>String.fromCharCode(97 + (byte >> 4)) + String.fromCharCode(97 + (15 & byte))).join("");
895
+ } catch {
896
+ return null;
897
+ }
898
+ }
899
+ function carrierPath(projectPath) {
900
+ return node_path.join(projectPath, "extensions", CARRIER_DIR_NAME);
901
+ }
902
+ function removeCarrier(projectPath) {
903
+ const target = carrierPath(projectPath);
904
+ if (!node_fs.existsSync(target)) return {
905
+ removed: false,
906
+ path: target
907
+ };
908
+ if (!node_fs.existsSync(node_path.join(target, MARKER_FILE))) return {
909
+ removed: false,
910
+ path: target,
911
+ note: `extensions/${CARRIER_DIR_NAME} has no ${MARKER_FILE} marker, so it is not the carrier this tool placed and was left untouched.`
912
+ };
913
+ try {
914
+ node_fs.rmSync(target, {
915
+ recursive: true,
916
+ force: true
917
+ });
918
+ } catch (error) {
919
+ return {
920
+ removed: false,
921
+ path: target,
922
+ note: `Could not remove the carrier: ${error instanceof Error ? error.message : String(error)}`
923
+ };
924
+ }
925
+ const parent = node_path.join(projectPath, "extensions");
926
+ try {
927
+ if (0 === node_fs.readdirSync(parent).length) node_fs.rmdirSync(parent);
928
+ } catch {}
929
+ return {
930
+ removed: true,
931
+ path: target
932
+ };
933
+ }
934
+ function ensureCarrierIgnored(projectPath) {
935
+ if (!node_fs.existsSync(node_path.join(projectPath, ".git"))) return null;
936
+ const entry = `extensions/${CARRIER_DIR_NAME}/`;
937
+ const file = node_path.join(projectPath, ".gitignore");
938
+ let current = "";
939
+ try {
940
+ current = node_fs.readFileSync(file, "utf-8");
941
+ } catch {}
942
+ const ignored = current.split("\n").map((line)=>line.trim()).some((line)=>line === entry || line === `extensions/${CARRIER_DIR_NAME}` || "extensions/" === line || "extensions" === line);
943
+ if (ignored) return null;
944
+ try {
945
+ const prefix = "" === current || current.endsWith("\n") ? "" : "\n";
946
+ node_fs.appendFileSync(file, `${prefix}\n# Extension.dev live-preview carrier: a local debug companion, not part of your extension.\n${entry}\n`);
947
+ return entry;
948
+ } catch {
949
+ return null;
950
+ }
951
+ }
952
+ function findBundledCarrier(engine) {
953
+ let dir = node_path.dirname(fileURLToPath(import.meta.url));
954
+ for(let depth = 0; depth < 6; depth++){
955
+ const candidate = node_path.join(dir, "extensions", "live-preview", engine);
956
+ if (node_fs.existsSync(node_path.join(candidate, "manifest.json"))) return candidate;
957
+ const parent = node_path.dirname(dir);
958
+ if (parent === dir) break;
959
+ dir = parent;
960
+ }
961
+ return null;
962
+ }
963
+ function materializeCarrier(projectPath, browser) {
964
+ if (!isChromiumFamily(browser)) return {
965
+ loaded: false,
966
+ note: `The live-preview carrier is Chromium-family only for now (requested: ${browser}). Firefox has no externally_connectable channel for web pages, so the carrier pairing cannot work there.`
967
+ };
968
+ const source = findBundledCarrier("chromium");
969
+ if (!source) return {
970
+ loaded: false,
971
+ note: "This install ships no bundled carrier payload (extensions/live-preview/chromium missing from the package)."
972
+ };
973
+ const target = carrierPath(projectPath);
974
+ const marker = node_path.join(target, MARKER_FILE);
975
+ if (node_fs.existsSync(target) && !node_fs.existsSync(marker)) return {
976
+ loaded: false,
977
+ path: target,
978
+ note: `A directory already exists at extensions/${CARRIER_DIR_NAME} without the ${MARKER_FILE} marker, so it is not managed by this tool and was left untouched. Remove or rename it to let extension_dev place the carrier there.`
979
+ };
980
+ const carrierId = deriveCarrierId(source);
981
+ try {
982
+ node_fs.rmSync(target, {
983
+ recursive: true,
984
+ force: true
985
+ });
986
+ node_fs.cpSync(source, target, {
987
+ recursive: true
988
+ });
989
+ const manifest = JSON.parse(node_fs.readFileSync(node_path.join(source, "manifest.json"), "utf-8"));
990
+ node_fs.writeFileSync(marker, `${JSON.stringify({
991
+ managedBy: "@extension.dev/mcp",
992
+ carrierVersion: manifest.version ?? "unknown",
993
+ note: "Safe to delete; extension_dev recreates it when carrier: true. extension_stop and extension_build remove it for you."
994
+ }, null, 2)}\n`);
995
+ const ignored = ensureCarrierIgnored(projectPath);
996
+ return {
997
+ loaded: true,
998
+ path: target,
999
+ ...ignored ? {
1000
+ gitignored: ignored
1001
+ } : {},
1002
+ note: "Live-preview carrier placed in ./extensions; Extension.js loads it as a companion beside your extension. Open https://inspect.extension.dev/?session=live in the dev browser (any http://localhost origin works too) to watch the session's real-lane chrome.* trace on the Trace tab. It is a debug companion, never part of a release: extension_stop and extension_build remove it again" + (ignored ? `, and ${ignored} was added to .gitignore.` : "."),
1003
+ limitations: [
1004
+ "The trace shows calls a PAGE bridges to the carrier. Your extension's own chrome.* calls run directly in its contexts and never cross the carrier, so they do not appear.",
1005
+ "Bridged calls run under the CARRIER's identity, not your extension's. The preview assumes a single active guest and does not namespace per-extension state, so storage, action/badge state, messaging delivery, offscreen documents and relative script paths belong to the carrier. Rows affected are badged carrier-scoped in the Trace tab.",
1006
+ "Chromium-family only: Firefox has no externally_connectable channel for web pages."
1007
+ ],
1008
+ ...carrierId ? {
1009
+ bridgeProtocol: {
1010
+ carrierExtensionId: carrierId,
1011
+ allowedOrigins: "https://inspect.extension.dev, https://intelligence.extension.dev, https://themes.extension.dev, http://localhost/*, http://127.0.0.1/*",
1012
+ howTo: "From a page on an allowed origin, register your guest once with a 'session' message (it declares the permissions the carrier enforces), then send 'bridge' messages to run chrome.* for real; each one streams into the Trace tab. Use the EXACT dotted wire names the bridge dispatcher accepts: storage is storage.get/set/remove/clear with the AREA AS AN ARGUMENT, NOT storage.local.get.",
1013
+ example: [
1014
+ `const id = '${carrierId}'`,
1015
+ "const send = (msg) => new Promise((r) => chrome.runtime.sendMessage(id, msg, r))",
1016
+ "await send({ type: 'extensiondev:session', extensionId: 'my-guest', permissions: ['storage'] })",
1017
+ "await send({ type: 'extensiondev:bridge', request: { type: 'EXTENSION_BRIDGE_REQUEST', extensionId: 'my-guest', requestId: 'r1', api: 'storage.get', args: [null, 'local'] } })"
1018
+ ].join("\n")
1019
+ }
1020
+ } : {}
1021
+ };
1022
+ } catch (error) {
1023
+ return {
1024
+ loaded: false,
1025
+ path: target,
1026
+ note: `Could not place the carrier: ${error instanceof Error ? error.message : String(error)}`
1027
+ };
1028
+ }
1029
+ }
857
1030
  function readBuildSummary(projectPath, browser, since) {
858
1031
  const file = node_path.resolve(projectPath, "dist", "extension-js", browser, "build-summary.json");
859
1032
  try {
@@ -1052,6 +1225,27 @@ function manifestDivergence(projectPath, browser) {
1052
1225
  if (Array.isArray(sourceWar) && sourceWar.length && !Array.isArray(builtWar)) notes.push("The built manifest has no web_accessible_resources although the source declares them. Anything injected into a page (scripting.insertCSS targets, injected scripts, images) will be blocked at runtime.");
1053
1226
  return notes;
1054
1227
  }
1228
+ const MARKER_FILE_NAME = "managed-by-extension-dev-mcp.json";
1229
+ function carrierEntriesInDist(distDir, depth = 0) {
1230
+ if (depth > 3) return [];
1231
+ let entries;
1232
+ try {
1233
+ entries = node_fs.readdirSync(distDir, {
1234
+ withFileTypes: true
1235
+ });
1236
+ } catch {
1237
+ return [];
1238
+ }
1239
+ const found = [];
1240
+ for (const entry of entries){
1241
+ if (entry.name === CARRIER_DIR_NAME || entry.name === MARKER_FILE_NAME) {
1242
+ found.push(node_path.join(distDir, entry.name));
1243
+ continue;
1244
+ }
1245
+ if (entry.isDirectory()) found.push(...carrierEntriesInDist(node_path.join(distDir, entry.name), depth + 1));
1246
+ }
1247
+ return found;
1248
+ }
1055
1249
  async function validationPreflight(projectPath, browser) {
1056
1250
  try {
1057
1251
  const manifestValidate = await Promise.resolve().then(()=>({
@@ -1076,6 +1270,10 @@ async function validationPreflight(projectPath, browser) {
1076
1270
  async function build_handler(args) {
1077
1271
  const start = Date.now();
1078
1272
  const browser = args.browser ?? "chrome";
1273
+ const carrierCleanup = removeCarrier(args.projectPath);
1274
+ const carrierNotes = [];
1275
+ if (carrierCleanup.removed) carrierNotes.push("Removed the Extension.dev live-preview carrier from ./extensions before building. It is a debug companion, not part of your extension; run extension_dev with carrier: true to get it back.");
1276
+ else if (carrierCleanup.note) carrierNotes.push(carrierCleanup.note);
1079
1277
  const preflight = args.skipValidation ? null : await validationPreflight(args.projectPath, browser);
1080
1278
  if (preflight?.buildBlocking) return JSON.stringify({
1081
1279
  success: false,
@@ -1083,12 +1281,15 @@ async function build_handler(args) {
1083
1281
  browser,
1084
1282
  error: "Build refused: the manifest has errors that produce a broken extension even when the bundler succeeds.",
1085
1283
  errors: preflight.errors,
1086
- warnings: preflight.warnings,
1284
+ warnings: [
1285
+ ...carrierNotes,
1286
+ ...preflight.warnings
1287
+ ],
1087
1288
  duration: Date.now() - start,
1088
1289
  hint: "Fix the errors above, then build again. Run extension_manifest_validate for the full report. To build anyway (for example to inspect the broken output), pass skipValidation: true."
1089
1290
  });
1090
1291
  const clobberedSessions = liveProjectSessions(args.projectPath).filter((session)=>session.browser === browser);
1091
- const warnings = clobberedSessions.map((session)=>`A live dev session (pid ${session.pid}) is running on this project for ${browser}, and this build wrote over its dist/${browser} output. The dev browser may now serve the production artifact instead of the dev build until the next recompile. Run extension_stop, or let dev recompile on the next source change, to resolve it.`);
1292
+ const warnings = carrierNotes.concat(clobberedSessions.map((session)=>`A live dev session (pid ${session.pid}) is running on this project for ${browser}, and this build wrote over its dist/${browser} output. The dev browser may now serve the production artifact instead of the dev build until the next recompile. Run extension_stop, or let dev recompile on the next source change, to resolve it.`));
1092
1293
  const cliArgs = [
1093
1294
  "build",
1094
1295
  args.projectPath,
@@ -1118,7 +1319,21 @@ async function build_handler(args) {
1118
1319
  buildWarningsTruncated: engineSummary.warnings_count
1119
1320
  } : {}
1120
1321
  } : {};
1121
- const entrypoints = builtEntrypoints(node_path.resolve(args.projectPath, "dist", browser));
1322
+ const distDir = node_path.resolve(args.projectPath, "dist", browser);
1323
+ const entrypoints = builtEntrypoints(distDir);
1324
+ const contamination = carrierEntriesInDist(distDir);
1325
+ if (contamination.length) return JSON.stringify({
1326
+ success: false,
1327
+ status: "contaminated",
1328
+ browser,
1329
+ buildExitCode: 0,
1330
+ error: `The build output contains the Extension.dev live-preview carrier: ${contamination.join(", ")}. That is a local debug companion and must never ship. This artifact is not safe to submit.`,
1331
+ ...warnings.length ? {
1332
+ warnings
1333
+ } : {},
1334
+ duration,
1335
+ hint: "Delete the listed paths from dist and build again. The carrier normally lives in ./extensions and is removed before every build."
1336
+ });
1122
1337
  const missing = entrypoints.filter((e)=>!e.present);
1123
1338
  if (missing.length) return JSON.stringify({
1124
1339
  success: false,
@@ -1198,7 +1413,7 @@ async function build_handler(args) {
1198
1413
  }
1199
1414
  const stop_schema = {
1200
1415
  name: "extension_stop",
1201
- description: "Stop a running dev, start, or preview session: terminates the dev server and the browser it launched. Counterpart to extension_dev/extension_start. Call it when you are done verifying so sessions do not accumulate.",
1416
+ description: "Stop a running dev, start, or preview session: terminates the dev server and the browser it launched, and removes the live-preview carrier if extension_dev placed one. Counterpart to extension_dev/extension_start. Call it when you are done verifying so sessions do not accumulate.",
1202
1417
  inputSchema: {
1203
1418
  type: "object",
1204
1419
  properties: {
@@ -1219,6 +1434,12 @@ const stop_schema = {
1219
1434
  required: []
1220
1435
  }
1221
1436
  };
1437
+ function cleanCarrier(projectPath) {
1438
+ const removal = removeCarrier(projectPath);
1439
+ return removal.removed ? {
1440
+ carrierRemoved: removal.path
1441
+ } : {};
1442
+ }
1222
1443
  function pgrepPids(pattern) {
1223
1444
  try {
1224
1445
  const out = execFileSync("pgrep", [
@@ -1295,6 +1516,7 @@ async function stopOne(projectPath, browser) {
1295
1516
  pid: null,
1296
1517
  stopped: 0 !== reaped.length,
1297
1518
  reaped,
1519
+ ...cleanCarrier(projectPath),
1298
1520
  detail: 0 === reaped.length ? "No known session for this project/browser (nothing registered in this server and no ready.json contract found)." : `No dev pid on record, but reaped ${reaped.length} orphaned browser process(es) from the profile dir.`
1299
1521
  };
1300
1522
  }
@@ -1326,7 +1548,8 @@ async function stopOne(projectPath, browser) {
1326
1548
  pid,
1327
1549
  stopped,
1328
1550
  reaped,
1329
- detail
1551
+ detail,
1552
+ ...cleanCarrier(projectPath)
1330
1553
  };
1331
1554
  }
1332
1555
  async function stop_handler(args) {
@@ -1458,6 +1681,11 @@ const dev_schema = {
1458
1681
  type: "boolean",
1459
1682
  default: false,
1460
1683
  description: "Enable extension_eval (runs code in a context; writes a 0600 session token). Implies allowControl, so a single allowEval: true also unlocks storage/reload/open/dom_inspect. You do not need to pass both."
1684
+ },
1685
+ carrier: {
1686
+ type: "boolean",
1687
+ default: false,
1688
+ description: "Load the bundled Extension.dev Live Preview carrier beside your extension (Chromium-family browsers only). It is placed in the project's ./extensions folder, which Extension.js auto-loads; allowlisted pages (inspect.extension.dev, localhost) can then pair with the session and stream its real-lane chrome.* trace. Writes extensions/extension-dev-live-preview/ into the project, gitignores it, and takes it back out on extension_stop or extension_build: it is a debug companion, never part of a release."
1461
1689
  }
1462
1690
  },
1463
1691
  required: [
@@ -1492,6 +1720,7 @@ async function dev_handler(args) {
1492
1720
  });
1493
1721
  }
1494
1722
  }
1723
+ const carrier = args.carrier ? materializeCarrier(args.projectPath, browser) : null;
1495
1724
  const allowControl = Boolean(args.allowControl || args.allowEval);
1496
1725
  const cliArgs = [
1497
1726
  "dev",
@@ -1600,6 +1829,9 @@ async function dev_handler(args) {
1600
1829
  ...portReport,
1601
1830
  projectPath: args.projectPath,
1602
1831
  status: "started",
1832
+ ...carrier ? {
1833
+ carrier
1834
+ } : {},
1603
1835
  ...replaced.length > 0 ? {
1604
1836
  replacedSession: replaced[0],
1605
1837
  ...replaced.length > 1 ? {
@@ -1925,33 +2157,6 @@ async function get_template_source_handler(args) {
1925
2157
  } : {}
1926
2158
  });
1927
2159
  }
1928
- const CHROMIUM_FAMILY = new Set([
1929
- "chrome",
1930
- "chromium",
1931
- "edge",
1932
- "brave",
1933
- "opera",
1934
- "vivaldi",
1935
- "yandex",
1936
- "chromium-based"
1937
- ]);
1938
- const GECKO_FAMILY = new Set([
1939
- "firefox",
1940
- "waterfox",
1941
- "librewolf",
1942
- "gecko-based",
1943
- "firefox-based"
1944
- ]);
1945
- const WEBKIT_FAMILY = new Set([
1946
- "safari",
1947
- "webkit-based"
1948
- ]);
1949
- function isChromiumFamily(browser) {
1950
- return CHROMIUM_FAMILY.has(browser);
1951
- }
1952
- function isGeckoFamily(browser) {
1953
- return GECKO_FAMILY.has(browser);
1954
- }
1955
2160
  const CHROME_DESKTOP_ONLY_KEYS = [
1956
2161
  "file_browser_handlers",
1957
2162
  "file_system_provider_capabilities",
@@ -2186,11 +2391,13 @@ const HARD_APIS = new Set([
2186
2391
  "pageCapture",
2187
2392
  "desktopCapture"
2188
2393
  ]);
2189
- function scanApiUsage(roots) {
2394
+ function scanApiUsage(roots, excluded = []) {
2190
2395
  const used = new Set();
2396
+ const skip = new Set(excluded.map((d)=>node_path.resolve(d)));
2191
2397
  let filesRead = 0;
2192
2398
  const walk = (dir, depth)=>{
2193
2399
  if (depth > 6 || filesRead > 300) return;
2400
+ if (skip.has(node_path.resolve(dir))) return;
2194
2401
  let entries;
2195
2402
  try {
2196
2403
  entries = node_fs.readdirSync(dir, {
@@ -2296,7 +2503,7 @@ async function manifest_validate_handler(args) {
2296
2503
  ...view.permissions ?? [],
2297
2504
  ...view.optional_permissions ?? []
2298
2505
  ])if ("string" == typeof p) declaredPermSet.add(p);
2299
- const usedApis = scanApiUsage(roots);
2506
+ const usedApis = scanApiUsage(roots, roots.map((r)=>node_path.join(r, "extensions")));
2300
2507
  for (const api of usedApis){
2301
2508
  const perm = API_PERMISSION[api];
2302
2509
  if (declaredPermSet.has(perm)) continue;
@@ -4669,27 +4876,40 @@ async function reload_handler(args) {
4669
4876
  })
4670
4877
  ], args.projectPath, args.timeout);
4671
4878
  }
4672
- async function pollForTarget(port, url, budgetMs) {
4879
+ async function pollForTarget(port, url, budgetMs, navigatedTargetId) {
4673
4880
  const deadline = Date.now() + budgetMs;
4674
4881
  const wanted = url.replace(/#.*$/, "");
4882
+ let redirected = null;
4675
4883
  for(;;){
4676
4884
  try {
4677
4885
  const targets = await CDPClient.discoverTargets(port);
4678
4886
  for (const t of targets){
4679
4887
  const tUrl = String(t.url ?? "");
4680
- if ("page" === t.type) {
4681
- if (tUrl === wanted || tUrl.startsWith(wanted)) return {
4682
- id: String(t.id),
4683
- url: tUrl,
4684
- title: "string" == typeof t.title ? t.title : void 0
4685
- };
4686
- }
4888
+ if ("page" !== t.type) continue;
4889
+ const title = "string" == typeof t.title ? t.title : void 0;
4890
+ if (tUrl === wanted || tUrl.startsWith(wanted)) return {
4891
+ id: String(t.id),
4892
+ url: tUrl,
4893
+ title
4894
+ };
4895
+ if (navigatedTargetId && String(t.id) === navigatedTargetId && tUrl && "about:blank" !== tUrl && !tUrl.startsWith("chrome-error://")) redirected = {
4896
+ id: String(t.id),
4897
+ url: tUrl,
4898
+ title,
4899
+ redirectedFrom: url
4900
+ };
4687
4901
  }
4688
4902
  } catch {}
4689
- if (Date.now() >= deadline) return null;
4903
+ if (Date.now() >= deadline) return redirected;
4690
4904
  await new Promise((r)=>setTimeout(r, 250));
4691
4905
  }
4692
4906
  }
4907
+ function isDisposableTab(tabUrl, destination) {
4908
+ if (!tabUrl || "about:blank" === tabUrl) return true;
4909
+ if (/^chrome:\/\/(newtab|new-tab-page)/.test(tabUrl)) return true;
4910
+ const origin = destination.match(/^chrome-extension:\/\/[a-p]{32}\//)?.[0];
4911
+ return Boolean(origin && tabUrl.startsWith(origin));
4912
+ }
4693
4913
  async function navigateToUrl(projectPath, browser, url, timeout) {
4694
4914
  if (!isChromiumFamily(browser)) return navigateToUrlViaBridge(projectPath, browser, url, timeout);
4695
4915
  const resolved = await resolveCdpPort(projectPath, browser);
@@ -4704,30 +4924,49 @@ async function navigateToUrl(projectPath, browser, url, timeout) {
4704
4924
  try {
4705
4925
  const targets = await CDPClient.discoverTargets(resolved.port);
4706
4926
  const pageTargets = targets.filter((t)=>"page" === t.type && !String(t.url || "").startsWith("devtools://"));
4707
- if (0 === pageTargets.length) return JSON.stringify({
4708
- ok: false,
4709
- error: {
4710
- name: "NoTab",
4711
- message: "The dev browser has no open page tab to navigate. Trigger one (e.g. extension_open surface, or open the extension) first."
4712
- }
4713
- });
4714
- const target = pageTargets[0];
4715
4927
  const browserWsUrl = await CDPClient.discoverBrowserWsUrl(resolved.port);
4716
4928
  await cdp.connect(browserWsUrl);
4717
- const sessionId = await cdp.attachToTarget(String(target.id));
4718
- await cdp.navigate(sessionId, url);
4719
- const settled = await pollForTarget(resolved.port, url, 6000);
4720
- if (!settled) return JSON.stringify({
4721
- ok: false,
4722
- error: {
4723
- name: "NavigateFailed",
4724
- message: `Navigation to ${url} did not produce a live page target. The URL may not exist in the extension bundle, or Chrome refused the navigation.`
4725
- },
4726
- hint: "Confirm the path exists in the built dist (extension_build / extension_inspect list entrypoints). For an extension page, the path must match the BUILT manifest, which may differ from your source layout."
4727
- });
4929
+ const reusable = pageTargets.find((t)=>isDisposableTab(String(t.url ?? ""), url));
4930
+ let navigatedTargetId;
4931
+ let openedNewTab = false;
4932
+ if (reusable) {
4933
+ navigatedTargetId = String(reusable.id);
4934
+ const sessionId = await cdp.attachToTarget(navigatedTargetId);
4935
+ await cdp.navigate(sessionId, url);
4936
+ } else {
4937
+ const created = await cdp.sendCommand("Target.createTarget", {
4938
+ url,
4939
+ background: true
4940
+ }).catch(()=>cdp.sendCommand("Target.createTarget", {
4941
+ url
4942
+ }));
4943
+ navigatedTargetId = "string" == typeof created?.targetId ? created.targetId : void 0;
4944
+ openedNewTab = true;
4945
+ }
4946
+ const settled = await pollForTarget(resolved.port, url, 6000, navigatedTargetId);
4947
+ if (!settled) {
4948
+ const isExtensionPage = url.startsWith("chrome-extension://");
4949
+ return JSON.stringify({
4950
+ ok: false,
4951
+ error: {
4952
+ name: "NavigateFailed",
4953
+ message: `Navigation to ${url} did not produce a live page target. ${isExtensionPage ? "The URL may not exist in the extension bundle, or Chrome refused the navigation." : "The page may have failed to load, or the browser refused the navigation."}`
4954
+ },
4955
+ hint: isExtensionPage ? "Confirm the path exists in the built dist (extension_build / extension_inspect list entrypoints). For an extension page, the path must match the BUILT manifest, which may differ from your source layout." : "Confirm the URL loads in a normal browser and that the dev session's browser has network access. Nothing about your extension bundle is implicated in a failed http(s) navigation."
4956
+ });
4957
+ }
4728
4958
  return JSON.stringify({
4729
4959
  ok: true,
4730
4960
  navigated: url,
4961
+ ...openedNewTab ? {
4962
+ openedNewTab: true
4963
+ } : {},
4964
+ ...settled.redirectedFrom ? {
4965
+ redirected: {
4966
+ from: settled.redirectedFrom,
4967
+ to: settled.url
4968
+ }
4969
+ } : {},
4731
4970
  target: {
4732
4971
  targetId: settled.id,
4733
4972
  title: settled.title,
@@ -4980,6 +5219,39 @@ async function openSurfaceAsTab(projectPath, browser, surface) {
4980
5219
  } catch {}
4981
5220
  return raw;
4982
5221
  }
5222
+ async function confirmSurfaceTarget(projectPath, browser, surface, raw) {
5223
+ if (!isChromiumFamily(browser)) return raw;
5224
+ const doc = surfaceDocument(projectPath, browser, surface);
5225
+ if (!doc) return raw;
5226
+ let parsed;
5227
+ try {
5228
+ parsed = JSON.parse(raw);
5229
+ } catch {
5230
+ return raw;
5231
+ }
5232
+ if (parsed?.ok === false) return raw;
5233
+ const resolved = await resolveCdpPort(projectPath, browser);
5234
+ const extensionId = resolved ? await resolveExtensionId(projectPath, browser) : null;
5235
+ if (!resolved || !extensionId) return raw;
5236
+ const wanted = `chrome-extension://${extensionId}/${doc}`;
5237
+ const settled = await pollForTarget(resolved.port, wanted, 3000);
5238
+ if (settled) {
5239
+ parsed.surfaceTarget = {
5240
+ targetId: settled.id,
5241
+ url: settled.url
5242
+ };
5243
+ return JSON.stringify(parsed);
5244
+ }
5245
+ return JSON.stringify({
5246
+ ok: false,
5247
+ error: {
5248
+ name: "SurfaceDidNotOpen",
5249
+ message: `The engine reported the ${surface} as opened, but no page target for ${wanted} appeared within 3s, so nothing is there to inspect.`
5250
+ },
5251
+ engineResult: parsed,
5252
+ hint: `Retry with asTab: true to render ${doc} in a real tab, which works headed or headless. If you expected a window, check that the session is headed and that the surface is declared in the BUILT manifest.`
5253
+ });
5254
+ }
4983
5255
  const open_schema = {
4984
5256
  name: "extension_open",
4985
5257
  description: "Open an extension surface or replay an event in a running session. 'popup'/'options'/'sidebar' open UI surfaces; 'newtab'/'history'/'bookmarks' open the extension's chrome_url_overrides page in a tab. 'action' triggers the toolbar action: opens the action's popup, or (no popup) replays chrome.action.onClicked. 'command' replays a chrome.commands.onCommand keyboard shortcut (pass `name`). NOTE: action/command replay invokes your listener WITHOUT a user gesture, so the gesture-derived activeTab grant does not apply (the result includes gesture:false and a warning when activeTab is declared). Requires the dev session to be started with allowControl: true (extension_dev). Wraps `extension open`.",
@@ -5098,7 +5370,7 @@ async function open_handler(args) {
5098
5370
  return JSON.stringify(parsed);
5099
5371
  }
5100
5372
  } catch {}
5101
- return raw;
5373
+ return AS_TAB_SURFACES.includes(args.surface) ? confirmSurfaceTarget(args.projectPath, browser, args.surface, raw) : raw;
5102
5374
  }
5103
5375
  const TARGET_ID_NOTE = "targetId is a CDP target 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.";
5104
5376
  function filterPageTargets(raw) {
@@ -6056,7 +6328,7 @@ function storeMdWarnings(browsers, cwd) {
6056
6328
  }
6057
6329
  const deploy_schema = {
6058
6330
  name: "extension_deploy",
6059
- description: "Submit a built extension to the Chrome Web Store, Firefox AMO, and/or Edge Add-ons THROUGH extension.dev, which holds your store credentials and dispatches the release from your project's mirror CI. DEFAULTS TO A DRY RUN (preflight - dispatches nothing): the platform side verifies auth, the project, that the build exists, and the store workflow, and this tool then adds the per-store verdict from each store's public credential-health record; trust the per-store rows in the result over the platform's bare preflight line, which does not check store health. Pass dryRun:false to actually submit, which is irreversible and enters store review. The target project is identified by your token (extension_login or a release token in EXTENSION_DEV_TOKEN; tokens live at most 7 days, so CI must re-mint from the console's Access tokens page). Store credentials are never tool arguments and local files are not uploaded. Pass browsers + buildSha (extension_release_list lists valid shas); after a real submission, extension_store_status reads the recorded outcome and review state. Posts to the platform's CLI store-submission endpoint.",
6331
+ description: "Submit a built extension to the Chrome Web Store, Firefox AMO, Edge Add-ons, and/or the App Store (Safari) THROUGH extension.dev, which holds your store credentials and dispatches the release from your project's mirror CI. DEFAULTS TO A DRY RUN (preflight - dispatches nothing): the platform side verifies auth, the project, that the build exists, and the store workflow, and this tool then adds the per-store verdict from each store's public credential-health record; trust the per-store rows in the result over the platform's bare preflight line, which does not check store health. Pass dryRun:false to actually submit, which is irreversible and enters store review. The target project is identified by your token (extension_login or a release token in EXTENSION_DEV_TOKEN; tokens live at most 7 days, so CI must re-mint from the console's Access tokens page). Store credentials are never tool arguments and local files are not uploaded. Pass browsers + buildSha (extension_release_list lists valid shas); after a real submission, extension_store_status reads the recorded outcome and review state. Posts to the platform's CLI store-submission endpoint.",
6060
6332
  inputSchema: {
6061
6333
  type: "object",
6062
6334
  properties: {
@@ -6114,7 +6386,7 @@ async function deploy_handler(args) {
6114
6386
  const token = resolveToken();
6115
6387
  if (!token) return deploy_fail("DeployAuthError", "No token. Run extension_login, or set EXTENSION_DEV_TOKEN (create one in the extension.dev dashboard under project settings -> Access tokens; tokens live at most 7 days, so CI must re-mint before expiry).");
6116
6388
  const browsers = (Array.isArray(args.browsers) ? args.browsers : []).map((b)=>String(b).trim().toLowerCase()).filter(Boolean);
6117
- if (0 === browsers.length) return deploy_fail("DeployInputError", 'browsers is required (e.g. ["chrome","firefox","edge"]).');
6389
+ if (0 === browsers.length) return deploy_fail("DeployInputError", 'browsers is required (e.g. ["chrome","firefox","edge","safari"]).');
6118
6390
  const buildSha = String(args.buildSha || "").trim();
6119
6391
  if (!buildSha) return deploy_fail("DeployInputError", "buildSha is required (the built commit to submit).");
6120
6392
  const apiCheck = safeApiBase(String(args.api || process.env.EXTENSION_DEV_API_URL || deploy_DEFAULT_API));
@@ -0,0 +1,71 @@
1
+ /**
2
+ * The Extension.dev Live Preview carrier, bundled prebuilt in this package
3
+ * under extensions/live-preview/<engine>. extension_dev materializes it into
4
+ * the project's ./extensions folder (which Extension.js auto-scans and loads
5
+ * as a companion next to the user's extension), so the dev browser comes up
6
+ * carrier-equipped: web pages the carrier allowlists (inspect.extension.dev,
7
+ * localhost) can then watch the session's real-lane trace and pair with it.
8
+ */
9
+ export declare const CARRIER_DIR_NAME = "extension-dev-live-preview";
10
+ /** Where the carrier lands inside a project. */
11
+ export declare function carrierPath(projectPath: string): string;
12
+ /** True only for a directory carrying our marker, i.e. ours to delete. */
13
+ export declare function isManagedCarrier(projectPath: string): boolean;
14
+ export type CarrierRemoval = {
15
+ removed: boolean;
16
+ path: string;
17
+ /** Present when something was there but was NOT ours to touch. */
18
+ note?: string;
19
+ };
20
+ /**
21
+ * Remove the carrier from a project, marker-guarded.
22
+ *
23
+ * The trace swarm's most release-dangerous finding was that a debugging flag
24
+ * leaves a permanent companion extension in the project: it survived
25
+ * extension_stop, Extension.js auto-scans ./extensions, and the first
26
+ * `git add -A` vendors it. Nothing removed it, ever. Stop and build both call
27
+ * this now, and `extension_dev carrier: true` puts it back on demand.
28
+ */
29
+ export declare function removeCarrier(projectPath: string): CarrierRemoval;
30
+ /**
31
+ * Keep the carrier out of the user's commits. The scaffolder runs `git init`
32
+ * with no initial commit, so the first `git add -A` in a project that ever ran
33
+ * with carrier: true vendors 460K of somebody else's extension.
34
+ * Returns the line added, or null when nothing needed doing.
35
+ */
36
+ export declare function ensureCarrierIgnored(projectPath: string): string | null;
37
+ export type CarrierMaterialization = {
38
+ loaded: boolean;
39
+ path?: string;
40
+ note: string;
41
+ /** The .gitignore entry this run added, when it added one. */
42
+ gitignored?: string;
43
+ /**
44
+ * What the carrier lane CANNOT do, stated at the moment it is handed over.
45
+ * The trace swarm's top finding was that the real lane's boundaries were
46
+ * discoverable only by experiment: 14 personas independently filed the
47
+ * carrier-identity scoping as a severe bug, and 10 more expected the trace
48
+ * to show their own extension's calls. Both are honest constraints; neither
49
+ * was written down anywhere the caller would look.
50
+ */
51
+ limitations?: string[];
52
+ /**
53
+ * How to actually DRIVE the real lane. Seven personas reached a
54
+ * permanently empty trace and concluded the feature was broken, because
55
+ * nothing in the 33 tool schemas, this note, or the rendered page named the
56
+ * carrier's extension id or its message envelopes. Every persona that did
57
+ * reach the real lane used out-of-band knowledge of the emulator source.
58
+ */
59
+ bridgeProtocol?: {
60
+ carrierExtensionId: string;
61
+ allowedOrigins: string;
62
+ howTo: string;
63
+ example: string;
64
+ };
65
+ };
66
+ /**
67
+ * Copy the bundled carrier into <projectPath>/extensions/<CARRIER_DIR_NAME>.
68
+ * Refuses to touch an existing directory that lacks our marker file (it is
69
+ * the user's, not ours); otherwise replaces it so version updates propagate.
70
+ */
71
+ export declare function materializeCarrier(projectPath: string, browser: string): CarrierMaterialization;