@extension.dev/mcp 5.6.1 → 6.0.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";
@@ -137,7 +138,8 @@ __webpack_require__.r(open_namespaceObject);
137
138
  __webpack_require__.d(open_namespaceObject, {
138
139
  clampPopupBounds: ()=>clampPopupBounds,
139
140
  handler: ()=>open_handler,
140
- schema: ()=>open_schema
141
+ schema: ()=>open_schema,
142
+ sessionIsHeadless: ()=>sessionIsHeadless
141
143
  });
142
144
  var preview_namespaceObject = {};
143
145
  __webpack_require__.r(preview_namespaceObject);
@@ -220,7 +222,7 @@ __webpack_require__.d(whoami_namespaceObject, {
220
222
  handler: ()=>whoami_handler,
221
223
  schema: ()=>whoami_schema
222
224
  });
223
- var package_namespaceObject = JSON.parse('{"rE":"5.6.1","El":{"OP":"^4.0.15"}}');
225
+ var package_namespaceObject = JSON.parse('{"rE":"5.7.0","El":{"OP":"4.0.16-canary.1784889479.74e12044"}}');
224
226
  function scaffoldEnginePin(projectPath) {
225
227
  try {
226
228
  const pkg = JSON.parse(node_fs.readFileSync(node_path.join(projectPath, "package.json"), "utf8"));
@@ -387,10 +389,85 @@ async function create_handler(args) {
387
389
  }
388
390
  });
389
391
  }
392
+ const DEFAULT_MEDIA_ORIGIN = "https://media.extension.land";
393
+ const DEFAULT_CHANNEL = "latest";
394
+ const PINNED_COMMIT = "2d2ed9668cca002148d9eecd953a08b54d0bad9d";
395
+ const CHANNEL_CACHE_TTL_MS = 300000;
396
+ function mediaOrigin() {
397
+ return (process.env.EXTENSION_MEDIA_ORIGIN || "").trim() || DEFAULT_MEDIA_ORIGIN;
398
+ }
399
+ function channelName() {
400
+ return (process.env.EXTENSION_TEMPLATES_CHANNEL || "").trim() || DEFAULT_CHANNEL;
401
+ }
402
+ function pinnedCommitOverride() {
403
+ return (process.env.EXTENSION_TEMPLATES_COMMIT || "").trim();
404
+ }
405
+ function rawBaseForCommit(commit) {
406
+ return `https://raw.githubusercontent.com/extension-js/examples/${commit}`;
407
+ }
408
+ let releaseCache = null;
409
+ let releaseCacheExpiresAt = 0;
410
+ let releaseRequest = null;
411
+ function releaseForCommit(origin, commit) {
412
+ return {
413
+ commit,
414
+ metaUrl: `${origin}/templates/${commit}/templates-meta.json`,
415
+ filesBaseUrl: `${origin}/templates/${commit}/files`
416
+ };
417
+ }
418
+ async function resolveRelease() {
419
+ const now = Date.now();
420
+ if (releaseCache && now < releaseCacheExpiresAt) return releaseCache;
421
+ if (releaseRequest) return releaseRequest;
422
+ const origin = mediaOrigin();
423
+ const override = pinnedCommitOverride();
424
+ if (override) {
425
+ releaseCache = releaseForCommit(origin, override);
426
+ releaseCacheExpiresAt = now + CHANNEL_CACHE_TTL_MS;
427
+ return releaseCache;
428
+ }
429
+ releaseRequest = (async ()=>{
430
+ try {
431
+ const pointerUrl = `${origin}/templates/${channelName()}.json`;
432
+ const response = await fetch(pointerUrl, {
433
+ headers: {
434
+ Accept: "application/json"
435
+ }
436
+ });
437
+ if (!response.ok) return null;
438
+ const pointer = await response.json();
439
+ const commit = String(pointer?.commit || "").trim();
440
+ if (!commit) return null;
441
+ releaseCache = releaseForCommit(origin, commit);
442
+ releaseCacheExpiresAt = Date.now() + CHANNEL_CACHE_TTL_MS;
443
+ return releaseCache;
444
+ } catch {
445
+ return null;
446
+ }
447
+ })();
448
+ try {
449
+ return await releaseRequest;
450
+ } finally{
451
+ releaseRequest = null;
452
+ }
453
+ }
454
+ async function templateMetaUrls() {
455
+ const urls = [];
456
+ const release = await resolveRelease();
457
+ if (release) urls.push(release.metaUrl);
458
+ urls.push(`${rawBaseForCommit(PINNED_COMMIT)}/templates-meta.json`);
459
+ return urls;
460
+ }
461
+ async function templateFileUrls(slug, relativePath) {
462
+ const urls = [];
463
+ const release = await resolveRelease();
464
+ if (release) urls.push(`${release.filesBaseUrl}/${slug}/${relativePath}`);
465
+ urls.push(`${rawBaseForCommit(PINNED_COMMIT)}/examples/${slug}/${relativePath}`);
466
+ return urls;
467
+ }
390
468
  const CACHE_DIR = node_path.join(node_os.homedir(), ".cache", "extension-js");
391
469
  const CACHE_FILE = node_path.join(CACHE_DIR, "templates-meta.json");
392
470
  const CACHE_TTL_MS = 3600000;
393
- const TEMPLATES_META_URL = "https://github.com/extension-js/examples/releases/download/nightly/templates-meta.json";
394
471
  function isCacheValid() {
395
472
  try {
396
473
  const stat = node_fs.statSync(CACHE_FILE);
@@ -404,17 +481,22 @@ async function fetchTemplatesMeta() {
404
481
  const cached = JSON.parse(node_fs.readFileSync(CACHE_FILE, "utf8"));
405
482
  return cached;
406
483
  }
407
- const response = await fetch(TEMPLATES_META_URL);
408
- if (!response.ok) {
409
- if (node_fs.existsSync(CACHE_FILE)) return JSON.parse(node_fs.readFileSync(CACHE_FILE, "utf8"));
410
- throw new Error(`Failed to fetch templates-meta.json: ${response.status}`);
411
- }
412
- const data = await response.json();
413
- node_fs.mkdirSync(CACHE_DIR, {
414
- recursive: true
415
- });
416
- node_fs.writeFileSync(CACHE_FILE, JSON.stringify(data, null, 2));
417
- return data;
484
+ let lastStatus = 0;
485
+ for (const url of (await templateMetaUrls()))try {
486
+ const response = await fetch(url);
487
+ if (!response.ok) {
488
+ lastStatus = response.status;
489
+ continue;
490
+ }
491
+ const data = await response.json();
492
+ node_fs.mkdirSync(CACHE_DIR, {
493
+ recursive: true
494
+ });
495
+ node_fs.writeFileSync(CACHE_FILE, JSON.stringify(data, null, 2));
496
+ return data;
497
+ } catch {}
498
+ if (node_fs.existsSync(CACHE_FILE)) return JSON.parse(node_fs.readFileSync(CACHE_FILE, "utf8"));
499
+ throw new Error(`Failed to fetch templates-meta.json (${lastStatus || "network error"}).`);
418
500
  }
419
501
  async function listTemplates(filters) {
420
502
  const meta = await fetchTemplatesMeta();
@@ -854,6 +936,180 @@ function resolveSessionBrowser(projectPath, explicit, fallback = "chrome") {
854
936
  source: "fallback"
855
937
  };
856
938
  }
939
+ const CHROMIUM_FAMILY = new Set([
940
+ "chrome",
941
+ "chromium",
942
+ "edge",
943
+ "brave",
944
+ "opera",
945
+ "vivaldi",
946
+ "yandex",
947
+ "chromium-based"
948
+ ]);
949
+ const GECKO_FAMILY = new Set([
950
+ "firefox",
951
+ "waterfox",
952
+ "librewolf",
953
+ "gecko-based",
954
+ "firefox-based"
955
+ ]);
956
+ const WEBKIT_FAMILY = new Set([
957
+ "safari",
958
+ "webkit-based"
959
+ ]);
960
+ function isChromiumFamily(browser) {
961
+ return CHROMIUM_FAMILY.has(browser);
962
+ }
963
+ function isGeckoFamily(browser) {
964
+ return GECKO_FAMILY.has(browser);
965
+ }
966
+ const CARRIER_DIR_NAME = "extension-dev-live-preview";
967
+ const CARRIER_EXTENSION_ID = "ibppeifnekhjjjmpjfiobccjlicbmgcb";
968
+ const MARKER_FILE = "managed-by-extension-dev-mcp.json";
969
+ function deriveCarrierId(source) {
970
+ try {
971
+ const manifest = JSON.parse(node_fs.readFileSync(node_path.join(source, "manifest.json"), "utf-8"));
972
+ if (!manifest.key) return null;
973
+ const hash = createHash("sha256").update(Buffer.from(manifest.key, "base64")).digest();
974
+ return [
975
+ ...hash.subarray(0, 16)
976
+ ].map((byte)=>String.fromCharCode(97 + (byte >> 4)) + String.fromCharCode(97 + (15 & byte))).join("");
977
+ } catch {
978
+ return null;
979
+ }
980
+ }
981
+ function carrierPath(projectPath) {
982
+ return node_path.join(projectPath, "extensions", CARRIER_DIR_NAME);
983
+ }
984
+ function removeCarrier(projectPath) {
985
+ const target = carrierPath(projectPath);
986
+ if (!node_fs.existsSync(target)) return {
987
+ removed: false,
988
+ path: target
989
+ };
990
+ if (!node_fs.existsSync(node_path.join(target, MARKER_FILE))) return {
991
+ removed: false,
992
+ path: target,
993
+ note: `extensions/${CARRIER_DIR_NAME} has no ${MARKER_FILE} marker, so it is not the carrier this tool placed and was left untouched.`
994
+ };
995
+ try {
996
+ node_fs.rmSync(target, {
997
+ recursive: true,
998
+ force: true
999
+ });
1000
+ } catch (error) {
1001
+ return {
1002
+ removed: false,
1003
+ path: target,
1004
+ note: `Could not remove the carrier: ${error instanceof Error ? error.message : String(error)}`
1005
+ };
1006
+ }
1007
+ const parent = node_path.join(projectPath, "extensions");
1008
+ try {
1009
+ if (0 === node_fs.readdirSync(parent).length) node_fs.rmdirSync(parent);
1010
+ } catch {}
1011
+ return {
1012
+ removed: true,
1013
+ path: target
1014
+ };
1015
+ }
1016
+ function ensureCarrierIgnored(projectPath) {
1017
+ if (!node_fs.existsSync(node_path.join(projectPath, ".git"))) return null;
1018
+ const entry = `extensions/${CARRIER_DIR_NAME}/`;
1019
+ const file = node_path.join(projectPath, ".gitignore");
1020
+ let current = "";
1021
+ try {
1022
+ current = node_fs.readFileSync(file, "utf-8");
1023
+ } catch {}
1024
+ const ignored = current.split("\n").map((line)=>line.trim()).some((line)=>line === entry || line === `extensions/${CARRIER_DIR_NAME}` || "extensions/" === line || "extensions" === line);
1025
+ if (ignored) return null;
1026
+ try {
1027
+ const prefix = "" === current || current.endsWith("\n") ? "" : "\n";
1028
+ node_fs.appendFileSync(file, `${prefix}\n# Extension.dev live-preview carrier: a local debug companion, not part of your extension.\n${entry}\n`);
1029
+ return entry;
1030
+ } catch {
1031
+ return null;
1032
+ }
1033
+ }
1034
+ function findBundledCarrier(engine) {
1035
+ let dir = node_path.dirname(fileURLToPath(import.meta.url));
1036
+ for(let depth = 0; depth < 6; depth++){
1037
+ const candidate = node_path.join(dir, "extensions", "live-preview", engine);
1038
+ if (node_fs.existsSync(node_path.join(candidate, "manifest.json"))) return candidate;
1039
+ const parent = node_path.dirname(dir);
1040
+ if (parent === dir) break;
1041
+ dir = parent;
1042
+ }
1043
+ return null;
1044
+ }
1045
+ function materializeCarrier(projectPath, browser) {
1046
+ if (!isChromiumFamily(browser)) return {
1047
+ loaded: false,
1048
+ 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.`
1049
+ };
1050
+ const source = findBundledCarrier("chromium");
1051
+ if (!source) return {
1052
+ loaded: false,
1053
+ note: "This install ships no bundled carrier payload (extensions/live-preview/chromium missing from the package)."
1054
+ };
1055
+ const target = carrierPath(projectPath);
1056
+ const marker = node_path.join(target, MARKER_FILE);
1057
+ if (node_fs.existsSync(target) && !node_fs.existsSync(marker)) return {
1058
+ loaded: false,
1059
+ path: target,
1060
+ 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.`
1061
+ };
1062
+ const carrierId = deriveCarrierId(source);
1063
+ try {
1064
+ node_fs.rmSync(target, {
1065
+ recursive: true,
1066
+ force: true
1067
+ });
1068
+ node_fs.cpSync(source, target, {
1069
+ recursive: true
1070
+ });
1071
+ const manifest = JSON.parse(node_fs.readFileSync(node_path.join(source, "manifest.json"), "utf-8"));
1072
+ node_fs.writeFileSync(marker, `${JSON.stringify({
1073
+ managedBy: "@extension.dev/mcp",
1074
+ carrierVersion: manifest.version ?? "unknown",
1075
+ note: "Safe to delete; extension_dev recreates it when carrier: true. extension_stop and extension_build remove it for you."
1076
+ }, null, 2)}\n`);
1077
+ const ignored = ensureCarrierIgnored(projectPath);
1078
+ return {
1079
+ loaded: true,
1080
+ path: target,
1081
+ ...ignored ? {
1082
+ gitignored: ignored
1083
+ } : {},
1084
+ 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.` : "."),
1085
+ limitations: [
1086
+ "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.",
1087
+ "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.",
1088
+ "Chromium-family only: Firefox has no externally_connectable channel for web pages."
1089
+ ],
1090
+ graduation: "The carrier lane is the SHARED real lane: bridged calls run as the carrier, by design (see limitations). Your guest is already loaded as ITSELF in this same session, so for its own storage, identity, badge and messaging (the isolated real thing), drive the guest directly instead of the carrier bridge: extension_storage, extension_eval and extension_dom_inspect against this projectPath all operate on the guest as itself. Start (or replace) this session with allowControl: true (or allowEval: true) to unlock them. Use the carrier bridge for the shared real-lane TRACE; use the control verbs for the guest's OWN state.",
1091
+ ...carrierId ? {
1092
+ bridgeProtocol: {
1093
+ carrierExtensionId: carrierId,
1094
+ allowedOrigins: "https://inspect.extension.dev, https://intelligence.extension.dev, https://themes.extension.dev, http://localhost/*, http://127.0.0.1/*",
1095
+ 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.",
1096
+ example: [
1097
+ `const id = '${carrierId}'`,
1098
+ "const send = (msg) => new Promise((r) => chrome.runtime.sendMessage(id, msg, r))",
1099
+ "await send({ type: 'extensiondev:session', extensionId: 'my-guest', permissions: ['storage'] })",
1100
+ "await send({ type: 'extensiondev:bridge', request: { type: 'EXTENSION_BRIDGE_REQUEST', extensionId: 'my-guest', requestId: 'r1', api: 'storage.get', args: [null, 'local'] } })"
1101
+ ].join("\n")
1102
+ }
1103
+ } : {}
1104
+ };
1105
+ } catch (error) {
1106
+ return {
1107
+ loaded: false,
1108
+ path: target,
1109
+ note: `Could not place the carrier: ${error instanceof Error ? error.message : String(error)}`
1110
+ };
1111
+ }
1112
+ }
857
1113
  function readBuildSummary(projectPath, browser, since) {
858
1114
  const file = node_path.resolve(projectPath, "dist", "extension-js", browser, "build-summary.json");
859
1115
  try {
@@ -1052,6 +1308,27 @@ function manifestDivergence(projectPath, browser) {
1052
1308
  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
1309
  return notes;
1054
1310
  }
1311
+ const MARKER_FILE_NAME = "managed-by-extension-dev-mcp.json";
1312
+ function carrierEntriesInDist(distDir, depth = 0) {
1313
+ if (depth > 3) return [];
1314
+ let entries;
1315
+ try {
1316
+ entries = node_fs.readdirSync(distDir, {
1317
+ withFileTypes: true
1318
+ });
1319
+ } catch {
1320
+ return [];
1321
+ }
1322
+ const found = [];
1323
+ for (const entry of entries){
1324
+ if (entry.name === CARRIER_DIR_NAME || entry.name === MARKER_FILE_NAME) {
1325
+ found.push(node_path.join(distDir, entry.name));
1326
+ continue;
1327
+ }
1328
+ if (entry.isDirectory()) found.push(...carrierEntriesInDist(node_path.join(distDir, entry.name), depth + 1));
1329
+ }
1330
+ return found;
1331
+ }
1055
1332
  async function validationPreflight(projectPath, browser) {
1056
1333
  try {
1057
1334
  const manifestValidate = await Promise.resolve().then(()=>({
@@ -1076,6 +1353,10 @@ async function validationPreflight(projectPath, browser) {
1076
1353
  async function build_handler(args) {
1077
1354
  const start = Date.now();
1078
1355
  const browser = args.browser ?? "chrome";
1356
+ const carrierCleanup = removeCarrier(args.projectPath);
1357
+ const carrierNotes = [];
1358
+ 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.");
1359
+ else if (carrierCleanup.note) carrierNotes.push(carrierCleanup.note);
1079
1360
  const preflight = args.skipValidation ? null : await validationPreflight(args.projectPath, browser);
1080
1361
  if (preflight?.buildBlocking) return JSON.stringify({
1081
1362
  success: false,
@@ -1083,12 +1364,15 @@ async function build_handler(args) {
1083
1364
  browser,
1084
1365
  error: "Build refused: the manifest has errors that produce a broken extension even when the bundler succeeds.",
1085
1366
  errors: preflight.errors,
1086
- warnings: preflight.warnings,
1367
+ warnings: [
1368
+ ...carrierNotes,
1369
+ ...preflight.warnings
1370
+ ],
1087
1371
  duration: Date.now() - start,
1088
1372
  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
1373
  });
1090
1374
  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.`);
1375
+ 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
1376
  const cliArgs = [
1093
1377
  "build",
1094
1378
  args.projectPath,
@@ -1118,7 +1402,21 @@ async function build_handler(args) {
1118
1402
  buildWarningsTruncated: engineSummary.warnings_count
1119
1403
  } : {}
1120
1404
  } : {};
1121
- const entrypoints = builtEntrypoints(node_path.resolve(args.projectPath, "dist", browser));
1405
+ const distDir = node_path.resolve(args.projectPath, "dist", browser);
1406
+ const entrypoints = builtEntrypoints(distDir);
1407
+ const contamination = carrierEntriesInDist(distDir);
1408
+ if (contamination.length) return JSON.stringify({
1409
+ success: false,
1410
+ status: "contaminated",
1411
+ browser,
1412
+ buildExitCode: 0,
1413
+ 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.`,
1414
+ ...warnings.length ? {
1415
+ warnings
1416
+ } : {},
1417
+ duration,
1418
+ hint: "Delete the listed paths from dist and build again. The carrier normally lives in ./extensions and is removed before every build."
1419
+ });
1122
1420
  const missing = entrypoints.filter((e)=>!e.present);
1123
1421
  if (missing.length) return JSON.stringify({
1124
1422
  success: false,
@@ -1198,7 +1496,7 @@ async function build_handler(args) {
1198
1496
  }
1199
1497
  const stop_schema = {
1200
1498
  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.",
1499
+ 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
1500
  inputSchema: {
1203
1501
  type: "object",
1204
1502
  properties: {
@@ -1219,6 +1517,12 @@ const stop_schema = {
1219
1517
  required: []
1220
1518
  }
1221
1519
  };
1520
+ function cleanCarrier(projectPath) {
1521
+ const removal = removeCarrier(projectPath);
1522
+ return removal.removed ? {
1523
+ carrierRemoved: removal.path
1524
+ } : {};
1525
+ }
1222
1526
  function pgrepPids(pattern) {
1223
1527
  try {
1224
1528
  const out = execFileSync("pgrep", [
@@ -1295,6 +1599,7 @@ async function stopOne(projectPath, browser) {
1295
1599
  pid: null,
1296
1600
  stopped: 0 !== reaped.length,
1297
1601
  reaped,
1602
+ ...cleanCarrier(projectPath),
1298
1603
  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
1604
  };
1300
1605
  }
@@ -1326,7 +1631,8 @@ async function stopOne(projectPath, browser) {
1326
1631
  pid,
1327
1632
  stopped,
1328
1633
  reaped,
1329
- detail
1634
+ detail,
1635
+ ...cleanCarrier(projectPath)
1330
1636
  };
1331
1637
  }
1332
1638
  async function stop_handler(args) {
@@ -1458,6 +1764,11 @@ const dev_schema = {
1458
1764
  type: "boolean",
1459
1765
  default: false,
1460
1766
  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."
1767
+ },
1768
+ carrier: {
1769
+ type: "boolean",
1770
+ default: false,
1771
+ 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
1772
  }
1462
1773
  },
1463
1774
  required: [
@@ -1492,6 +1803,7 @@ async function dev_handler(args) {
1492
1803
  });
1493
1804
  }
1494
1805
  }
1806
+ const carrier = args.carrier ? materializeCarrier(args.projectPath, browser) : null;
1495
1807
  const allowControl = Boolean(args.allowControl || args.allowEval);
1496
1808
  const cliArgs = [
1497
1809
  "dev",
@@ -1522,6 +1834,7 @@ async function dev_handler(args) {
1522
1834
  child.on("exit", ()=>removeSession(args.projectPath, browser));
1523
1835
  await new Promise((resolve)=>setTimeout(resolve, 3000));
1524
1836
  const earlyOutput = spawned.readOutput();
1837
+ const cleanOutput = denoiseEarlyOutput(earlyOutput);
1525
1838
  if (null !== child.exitCode || null !== child.signalCode) {
1526
1839
  const code = child.exitCode;
1527
1840
  const signal = child.signalCode;
@@ -1534,12 +1847,12 @@ async function dev_handler(args) {
1534
1847
  exitCode: code,
1535
1848
  signal,
1536
1849
  error: `The dev server exited during startup (${signal ? `signal ${signal}` : `exit code ${code}`}). No session is running, so extension_logs/wait/eval and the control verbs have nothing to attach to.`,
1537
- output: denoiseEarlyOutput(earlyOutput).slice(0, 2000),
1850
+ output: cleanOutput.slice(0, 2000),
1538
1851
  logPath,
1539
1852
  hint: "Read `output` above for the cause: a port already in use, a manifest the build rejects, or a missing browser binary are the common ones. Fix it and call extension_dev again; extension_doctor with this projectPath will also report what the last session recorded."
1540
1853
  });
1541
1854
  }
1542
- const compileFailed = /compiled with errors|✖✖✖|ERROR in |Module not found|NOT FOUND/i.test(earlyOutput);
1855
+ const compileFailed = /compiled with errors|✖✖✖|ERROR in |Module not found|NOT FOUND/i.test(cleanOutput);
1543
1856
  if (compileFailed) return JSON.stringify({
1544
1857
  ok: false,
1545
1858
  status: "compile-failed",
@@ -1547,12 +1860,12 @@ async function dev_handler(args) {
1547
1860
  browser,
1548
1861
  pid,
1549
1862
  error: "The dev server started but the FIRST COMPILE FAILED, so the browser has nothing usable to load. The session is running; the extension is not.",
1550
- output: denoiseEarlyOutput(earlyOutput).slice(0, 2000),
1863
+ output: cleanOutput.slice(0, 2000),
1551
1864
  logPath,
1552
1865
  hint: "Fix the compile error in `output` above and save: the dev server is still running and will recompile. Do not call extension_wait yet, it will report ready for a build that failed."
1553
1866
  });
1554
1867
  const exitStamp = args.noBrowser ? null : browserExitStamp(args.projectPath, browser, spawnedAt);
1555
- const profileLockHit = !args.noBrowser && /SingletonLock|ProcessSingleton|profile[^\n]*(in use|locked)|already (open|running)/i.test(earlyOutput);
1868
+ const profileLockHit = !args.noBrowser && /SingletonLock|ProcessSingleton|profile[^\n]*(in use|locked)|already (open|running)/i.test(cleanOutput);
1556
1869
  if (exitStamp || profileLockHit) {
1557
1870
  const profileDir = node_path.join(args.projectPath, "dist", `extension-profile-${browser}`);
1558
1871
  return JSON.stringify({
@@ -1563,7 +1876,7 @@ async function dev_handler(args) {
1563
1876
  pid,
1564
1877
  ...exitStamp ?? {},
1565
1878
  error: `The dev server is running but the ${browser} browser it launched died during startup` + (profileLockHit ? " because its profile is locked by another browser instance." : "."),
1566
- output: denoiseEarlyOutput(earlyOutput).slice(0, 2000),
1879
+ output: cleanOutput.slice(0, 2000),
1567
1880
  logPath,
1568
1881
  hint: `A locked profile means another session's browser still holds it: call extension_stop with this projectPath to kill that session, then start extension_dev again. If the lock survives a crash, remove ${profileDir} manually before retrying.`
1569
1882
  });
@@ -1600,6 +1913,9 @@ async function dev_handler(args) {
1600
1913
  ...portReport,
1601
1914
  projectPath: args.projectPath,
1602
1915
  status: "started",
1916
+ ...carrier ? {
1917
+ carrier
1918
+ } : {},
1603
1919
  ...replaced.length > 0 ? {
1604
1920
  replacedSession: replaced[0],
1605
1921
  ...replaced.length > 1 ? {
@@ -1608,7 +1924,7 @@ async function dev_handler(args) {
1608
1924
  } : {},
1609
1925
  capabilities,
1610
1926
  hint: args.noBrowser ? "Build-only session (noBrowser: true): no browser will launch, so no runtime will ever attach. extension_wait returns as soon as the first compile lands (compiled: true, browserAttached: false) instead of waiting out its budget; do not wait for a browser. The control verbs (storage/reload/open/dom_inspect/eval) need a live browser and will not work against this session. When you are done, call extension_stop to shut down the dev server." : "Use extension_wait to check when the extension is fully loaded, then extension_source_inspect to inspect the live state. " + (allowControl ? `Control channel is ON: extension_${controlVerbs.split(", ").join("/extension_")}${args.allowEval ? "/extension_eval" : ""} will work against this session.` : "Control channel is OFF: extension_storage/reload/open/dom_inspect need allowControl: true, and extension_eval needs allowEval: true (which also implies allowControl). To unlock them, call extension_dev again with the flag you need plus replace: true (it stops this session first); a plain second call is refused so the session does not fork.") + " When you are done, call extension_stop to shut down the dev server and browser.",
1611
- earlyOutput: denoiseEarlyOutput(earlyOutput).slice(0, 500),
1927
+ earlyOutput: cleanOutput.slice(0, 500),
1612
1928
  logPath
1613
1929
  });
1614
1930
  }
@@ -1617,6 +1933,8 @@ function denoiseEarlyOutput(raw) {
1617
1933
  /^npm warn Unknown project config/i,
1618
1934
  /This will stop working in the next major version of npm/i,
1619
1935
  /^npm warn config/i,
1936
+ /^npm warn exec/i,
1937
+ /The following package(s)? (was|were) not found and will be installed/i,
1620
1938
  /V8: .*Invalid asm\.js/i,
1621
1939
  /^\(node:\d+\) V8:/i,
1622
1940
  /Use `node --trace-warnings/i,
@@ -1860,7 +2178,6 @@ async function preview_handler(args) {
1860
2178
  logPath
1861
2179
  });
1862
2180
  }
1863
- const RAW_BASE = "https://raw.githubusercontent.com/extension-js/examples/main/examples";
1864
2181
  const get_template_source_schema = {
1865
2182
  name: "extension_get_template_source",
1866
2183
  description: "Read source files from a template in the extension.dev template catalog. Use this to learn implementation patterns before building something similar.",
@@ -1908,14 +2225,17 @@ async function get_template_source_handler(args) {
1908
2225
  const fileContents = {};
1909
2226
  const errors = [];
1910
2227
  await Promise.all(args.files.map(async (filePath)=>{
1911
- const url = `${RAW_BASE}/${args.slug}/${filePath}`;
1912
- try {
2228
+ const urls = await templateFileUrls(args.slug, filePath);
2229
+ let lastStatus = 0;
2230
+ for (const url of urls)try {
1913
2231
  const response = await fetch(url);
1914
- if (response.ok) fileContents[filePath] = await response.text();
1915
- else errors.push(`${filePath}: ${response.status}`);
1916
- } catch (err) {
1917
- errors.push(`${filePath}: ${err instanceof Error ? err.message : "fetch failed"}`);
1918
- }
2232
+ if (response.ok) {
2233
+ fileContents[filePath] = await response.text();
2234
+ return;
2235
+ }
2236
+ lastStatus = response.status;
2237
+ } catch {}
2238
+ errors.push(`${filePath}: ${lastStatus || "fetch failed"}`);
1919
2239
  }));
1920
2240
  return JSON.stringify({
1921
2241
  ...meta,
@@ -1925,33 +2245,6 @@ async function get_template_source_handler(args) {
1925
2245
  } : {}
1926
2246
  });
1927
2247
  }
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
2248
  const CHROME_DESKTOP_ONLY_KEYS = [
1956
2249
  "file_browser_handlers",
1957
2250
  "file_system_provider_capabilities",
@@ -2186,11 +2479,13 @@ const HARD_APIS = new Set([
2186
2479
  "pageCapture",
2187
2480
  "desktopCapture"
2188
2481
  ]);
2189
- function scanApiUsage(roots) {
2482
+ function scanApiUsage(roots, excluded = []) {
2190
2483
  const used = new Set();
2484
+ const skip = new Set(excluded.map((d)=>node_path.resolve(d)));
2191
2485
  let filesRead = 0;
2192
2486
  const walk = (dir, depth)=>{
2193
2487
  if (depth > 6 || filesRead > 300) return;
2488
+ if (skip.has(node_path.resolve(dir))) return;
2194
2489
  let entries;
2195
2490
  try {
2196
2491
  entries = node_fs.readdirSync(dir, {
@@ -2296,7 +2591,7 @@ async function manifest_validate_handler(args) {
2296
2591
  ...view.permissions ?? [],
2297
2592
  ...view.optional_permissions ?? []
2298
2593
  ])if ("string" == typeof p) declaredPermSet.add(p);
2299
- const usedApis = scanApiUsage(roots);
2594
+ const usedApis = scanApiUsage(roots, roots.map((r)=>node_path.join(r, "extensions")));
2300
2595
  for (const api of usedApis){
2301
2596
  const perm = API_PERMISSION[api];
2302
2597
  if (declaredPermSet.has(perm)) continue;
@@ -4669,27 +4964,40 @@ async function reload_handler(args) {
4669
4964
  })
4670
4965
  ], args.projectPath, args.timeout);
4671
4966
  }
4672
- async function pollForTarget(port, url, budgetMs) {
4967
+ async function pollForTarget(port, url, budgetMs, navigatedTargetId) {
4673
4968
  const deadline = Date.now() + budgetMs;
4674
4969
  const wanted = url.replace(/#.*$/, "");
4970
+ let redirected = null;
4675
4971
  for(;;){
4676
4972
  try {
4677
4973
  const targets = await CDPClient.discoverTargets(port);
4678
4974
  for (const t of targets){
4679
4975
  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
- }
4976
+ if ("page" !== t.type) continue;
4977
+ const title = "string" == typeof t.title ? t.title : void 0;
4978
+ if (tUrl === wanted || tUrl.startsWith(wanted)) return {
4979
+ id: String(t.id),
4980
+ url: tUrl,
4981
+ title
4982
+ };
4983
+ if (navigatedTargetId && String(t.id) === navigatedTargetId && tUrl && "about:blank" !== tUrl && !tUrl.startsWith("chrome-error://")) redirected = {
4984
+ id: String(t.id),
4985
+ url: tUrl,
4986
+ title,
4987
+ redirectedFrom: url
4988
+ };
4687
4989
  }
4688
4990
  } catch {}
4689
- if (Date.now() >= deadline) return null;
4991
+ if (Date.now() >= deadline) return redirected;
4690
4992
  await new Promise((r)=>setTimeout(r, 250));
4691
4993
  }
4692
4994
  }
4995
+ function isDisposableTab(tabUrl, destination) {
4996
+ if (!tabUrl || "about:blank" === tabUrl) return true;
4997
+ if (/^chrome:\/\/(newtab|new-tab-page)/.test(tabUrl)) return true;
4998
+ const origin = destination.match(/^chrome-extension:\/\/[a-p]{32}\//)?.[0];
4999
+ return Boolean(origin && tabUrl.startsWith(origin));
5000
+ }
4693
5001
  async function navigateToUrl(projectPath, browser, url, timeout) {
4694
5002
  if (!isChromiumFamily(browser)) return navigateToUrlViaBridge(projectPath, browser, url, timeout);
4695
5003
  const resolved = await resolveCdpPort(projectPath, browser);
@@ -4704,30 +5012,49 @@ async function navigateToUrl(projectPath, browser, url, timeout) {
4704
5012
  try {
4705
5013
  const targets = await CDPClient.discoverTargets(resolved.port);
4706
5014
  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
5015
  const browserWsUrl = await CDPClient.discoverBrowserWsUrl(resolved.port);
4716
5016
  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
- });
5017
+ const reusable = pageTargets.find((t)=>isDisposableTab(String(t.url ?? ""), url));
5018
+ let navigatedTargetId;
5019
+ let openedNewTab = false;
5020
+ if (reusable) {
5021
+ navigatedTargetId = String(reusable.id);
5022
+ const sessionId = await cdp.attachToTarget(navigatedTargetId);
5023
+ await cdp.navigate(sessionId, url);
5024
+ } else {
5025
+ const created = await cdp.sendCommand("Target.createTarget", {
5026
+ url,
5027
+ background: true
5028
+ }).catch(()=>cdp.sendCommand("Target.createTarget", {
5029
+ url
5030
+ }));
5031
+ navigatedTargetId = "string" == typeof created?.targetId ? created.targetId : void 0;
5032
+ openedNewTab = true;
5033
+ }
5034
+ const settled = await pollForTarget(resolved.port, url, 6000, navigatedTargetId);
5035
+ if (!settled) {
5036
+ const isExtensionPage = url.startsWith("chrome-extension://");
5037
+ return JSON.stringify({
5038
+ ok: false,
5039
+ error: {
5040
+ name: "NavigateFailed",
5041
+ 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."}`
5042
+ },
5043
+ 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."
5044
+ });
5045
+ }
4728
5046
  return JSON.stringify({
4729
5047
  ok: true,
4730
5048
  navigated: url,
5049
+ ...openedNewTab ? {
5050
+ openedNewTab: true
5051
+ } : {},
5052
+ ...settled.redirectedFrom ? {
5053
+ redirected: {
5054
+ from: settled.redirectedFrom,
5055
+ to: settled.url
5056
+ }
5057
+ } : {},
4731
5058
  target: {
4732
5059
  targetId: settled.id,
4733
5060
  title: settled.title,
@@ -4980,6 +5307,39 @@ async function openSurfaceAsTab(projectPath, browser, surface) {
4980
5307
  } catch {}
4981
5308
  return raw;
4982
5309
  }
5310
+ async function confirmSurfaceTarget(projectPath, browser, surface, raw) {
5311
+ if (!isChromiumFamily(browser)) return raw;
5312
+ const doc = surfaceDocument(projectPath, browser, surface);
5313
+ if (!doc) return raw;
5314
+ let parsed;
5315
+ try {
5316
+ parsed = JSON.parse(raw);
5317
+ } catch {
5318
+ return raw;
5319
+ }
5320
+ if (parsed?.ok === false) return raw;
5321
+ const resolved = await resolveCdpPort(projectPath, browser);
5322
+ const extensionId = resolved ? await resolveExtensionId(projectPath, browser) : null;
5323
+ if (!resolved || !extensionId) return raw;
5324
+ const wanted = `chrome-extension://${extensionId}/${doc}`;
5325
+ const settled = await pollForTarget(resolved.port, wanted, 3000);
5326
+ if (settled) {
5327
+ parsed.surfaceTarget = {
5328
+ targetId: settled.id,
5329
+ url: settled.url
5330
+ };
5331
+ return JSON.stringify(parsed);
5332
+ }
5333
+ return JSON.stringify({
5334
+ ok: false,
5335
+ error: {
5336
+ name: "SurfaceDidNotOpen",
5337
+ message: `The engine reported the ${surface} as opened, but no page target for ${wanted} appeared within 3s, so nothing is there to inspect.`
5338
+ },
5339
+ engineResult: parsed,
5340
+ 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.`
5341
+ });
5342
+ }
4983
5343
  const open_schema = {
4984
5344
  name: "extension_open",
4985
5345
  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`.",
@@ -5031,6 +5391,11 @@ const open_schema = {
5031
5391
  ]
5032
5392
  }
5033
5393
  };
5394
+ function sessionIsHeadless() {
5395
+ if (/^(1|true)$/i.test(process.env.EXTENSION_HEADLESS ?? "")) return true;
5396
+ return /(^|\s|=)-{1,2}headless\b/i.test(process.env.EXTENSION_BROWSER_FLAGS ?? "");
5397
+ }
5398
+ const HEADED_RELAUNCH = "start a headed session: extension_dev with replace: true, and in the environment set EXTENSION_HEADLESS=0 AND clear EXTENSION_BROWSER_FLAGS (it may carry --headless=new, which keeps the window hidden even with EXTENSION_HEADLESS=0)";
5034
5399
  async function open_handler(args) {
5035
5400
  const { browser } = resolveSessionBrowser(args.projectPath, args.browser);
5036
5401
  if (args.url) return navigateToUrl(args.projectPath, browser, args.url, args.timeout);
@@ -5075,7 +5440,7 @@ async function open_handler(args) {
5075
5440
  cli.push("--browser", browser);
5076
5441
  if (null != args.timeout) cli.push("--timeout", String(args.timeout));
5077
5442
  const raw = await runActVerb(cli, args.projectPath, args.timeout);
5078
- const headless = /^(1|true)$/i.test(process.env.EXTENSION_HEADLESS ?? "");
5443
+ const headless = sessionIsHeadless();
5079
5444
  if (headless && [
5080
5445
  "popup",
5081
5446
  "action",
@@ -5089,16 +5454,16 @@ async function open_handler(args) {
5089
5454
  try {
5090
5455
  const parsedFallback = JSON.parse(fallback);
5091
5456
  if (parsedFallback?.ok) {
5092
- parsedFallback.note = "The dev browser is headless (EXTENSION_HEADLESS=1), and a real popup/sidebar window can only open in a headed session, so the surface was rendered as a tab instead. For the real window, start a headed session: extension_dev with replace: true, with EXTENSION_HEADLESS=0 (or unset) in the environment, then open the surface again without asTab.";
5457
+ parsedFallback.note = `The dev browser is headless, and a real popup/sidebar window can only open in a headed session, so the surface was rendered as a tab instead. For the real window, ${HEADED_RELAUNCH}, then open the surface again without asTab.`;
5093
5458
  return JSON.stringify(parsedFallback);
5094
5459
  }
5095
5460
  } catch {}
5096
5461
  }
5097
- if (!parsed.hint) parsed.hint = /user gesture/i.test(msg) ? "This surface can only open from a real user gesture, which headless automation cannot produce. Retry with asTab: true to render the surface document in a tab instead." : "The dev browser is running headless (EXTENSION_HEADLESS=1), and a popup/sidebar window needs a headed session. Retry with asTab: true to render the surface document in a tab, or start a headed session for the real window: extension_dev with replace: true, with EXTENSION_HEADLESS=0 (or unset) in the environment.";
5462
+ if (!parsed.hint) parsed.hint = /user gesture/i.test(msg) ? "This surface can only open from a real user gesture, which headless automation cannot produce. Retry with asTab: true to render the surface document in a tab instead." : `The dev browser is running headless, and a popup/sidebar window needs a headed session. Retry with asTab: true to render the surface document in a tab, or for the real window, ${HEADED_RELAUNCH}.`;
5098
5463
  return JSON.stringify(parsed);
5099
5464
  }
5100
5465
  } catch {}
5101
- return raw;
5466
+ return AS_TAB_SURFACES.includes(args.surface) ? confirmSurfaceTarget(args.projectPath, browser, args.surface, raw) : raw;
5102
5467
  }
5103
5468
  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
5469
  function filterPageTargets(raw) {
@@ -5480,11 +5845,78 @@ function readValidCredentials(nowSeconds = Math.floor(Date.now() / 1000)) {
5480
5845
  if (creds.expiresAt && creds.expiresAt <= nowSeconds) return null;
5481
5846
  return creds;
5482
5847
  }
5483
- const REGISTRY_BASE_DEFAULT = "https://registry.extension.land";
5484
- const CONSOLE_BASE = "https://console.extension.dev";
5848
+ const PROD_ORIGINS = {
5849
+ www: "https://www.extension.dev",
5850
+ console: "https://console.extension.dev",
5851
+ inspect: "https://inspect.extension.dev",
5852
+ templates: "https://templates.extension.dev",
5853
+ intelligence: "https://intelligence.extension.dev",
5854
+ registry: "https://registry.extension.land",
5855
+ media: "https://media.extension.land"
5856
+ };
5857
+ const DEV_LOCALHOST_ORIGINS = {
5858
+ www: "http://localhost:3100",
5859
+ console: "http://console.extension.localhost",
5860
+ inspect: "http://inspect.extension.localhost",
5861
+ templates: "http://templates.extension.localhost",
5862
+ intelligence: "http://intelligence.extension.localhost",
5863
+ registry: "https://registry.extension.land",
5864
+ media: "https://media.extension.land"
5865
+ };
5866
+ function strip(value) {
5867
+ return String(value ?? "").trim().replace(/\/+$/, "");
5868
+ }
5869
+ function isLocalOrigin(url) {
5870
+ const raw = strip(url);
5871
+ if (!raw) return false;
5872
+ let host;
5873
+ try {
5874
+ host = new URL(raw).hostname;
5875
+ } catch {
5876
+ return false;
5877
+ }
5878
+ return "localhost" === host || "127.0.0.1" === host || "::1" === host || "[::1]" === host || "extension.localhost" === host || host.endsWith(".extension.localhost");
5879
+ }
5880
+ function resolveOrigins(overrides = {}, opts = {}) {
5881
+ const devLike = isLocalOrigin(overrides.www) || isLocalOrigin(overrides.console) || isLocalOrigin(opts.hint);
5882
+ const base = devLike ? DEV_LOCALHOST_ORIGINS : PROD_ORIGINS;
5883
+ const pick = (key)=>strip(overrides[key]) || base[key];
5884
+ return {
5885
+ www: pick("www"),
5886
+ console: pick("console"),
5887
+ inspect: pick("inspect"),
5888
+ templates: pick("templates"),
5889
+ intelligence: pick("intelligence"),
5890
+ registry: pick("registry"),
5891
+ media: pick("media")
5892
+ };
5893
+ }
5894
+ const seg = (value)=>encodeURIComponent(String(value));
5895
+ function urls_paths_join(base, sub) {
5896
+ if (!sub) return base;
5897
+ return `${base}/${sub.replace(/^\/+/, "")}`;
5898
+ }
5899
+ function consoleProjectPath(ref, page = "") {
5900
+ return urls_paths_join(`/${seg(ref.workspace)}/${seg(ref.project)}`, page);
5901
+ }
5902
+ PROD_ORIGINS.registry;
5903
+ function mcpOrigins(apiHint) {
5904
+ const www = String(apiHint || process.env.EXTENSION_DEV_API_URL || "").trim() || void 0;
5905
+ return resolveOrigins({
5906
+ www,
5907
+ console: process.env.EXTENSION_DEV_CONSOLE_URL,
5908
+ inspect: process.env.EXTENSION_DEV_INSPECT_URL,
5909
+ registry: process.env.EXTENSION_DEV_REGISTRY_URL,
5910
+ media: process.env.EXTENSION_MEDIA_ORIGIN
5911
+ }, {
5912
+ hint: www
5913
+ });
5914
+ }
5915
+ function consoleBase(apiHint) {
5916
+ return mcpOrigins(apiHint).console;
5917
+ }
5485
5918
  function registryBase() {
5486
- const fromEnv = String(process.env.EXTENSION_DEV_REGISTRY_URL || "").trim();
5487
- return (fromEnv || REGISTRY_BASE_DEFAULT).replace(/\/+$/, "");
5919
+ return mcpOrigins().registry;
5488
5920
  }
5489
5921
  function resolveProjectRef(overrides) {
5490
5922
  const workspace = String(overrides?.workspace || "").trim();
@@ -5505,9 +5937,10 @@ function resolveProjectRef(overrides) {
5505
5937
  function registryFileUrl(ref, file) {
5506
5938
  return `${registryBase()}/${encodeURIComponent(ref.workspace)}/${encodeURIComponent(ref.project)}/_extension-dev/${file}`;
5507
5939
  }
5508
- function consoleProjectUrl(ref, page) {
5509
- if (!ref) return `${CONSOLE_BASE}`;
5510
- return `${CONSOLE_BASE}/${encodeURIComponent(ref.workspace)}/${encodeURIComponent(ref.project)}/${page}`;
5940
+ function consoleProjectUrl(ref, page, apiHint) {
5941
+ const base = consoleBase(apiHint);
5942
+ if (!ref) return base;
5943
+ return `${base}${consoleProjectPath(ref, page)}`;
5511
5944
  }
5512
5945
  async function fetchRegistryJson(url, fetchImpl = fetch) {
5513
5946
  let res;
@@ -5583,12 +6016,12 @@ function parseBuildIndex(json) {
5583
6016
  }
5584
6017
  return out;
5585
6018
  }
5586
- const DEFAULT_API = "https://www.extension.dev";
6019
+ const DEFAULT_API = PROD_ORIGINS.www;
5587
6020
  function tokenTtlNote(workspaceSlug, projectSlug) {
5588
6021
  const tokensUrl = workspaceSlug && projectSlug ? consoleProjectUrl({
5589
6022
  workspace: workspaceSlug,
5590
6023
  project: projectSlug
5591
- }, "settings/access-tokens") : CONSOLE_BASE;
6024
+ }, "settings/access-tokens") : consoleBase();
5592
6025
  return `extension.dev CLI tokens live at most 7 days (server-enforced). CI pipelines must re-mint before expiry on the console's Access tokens page: ${tokensUrl}`;
5593
6026
  }
5594
6027
  function resolveApiBase(api) {
@@ -5829,7 +6262,6 @@ async function publish_handler(args) {
5829
6262
  }
5830
6263
  return JSON.stringify(data);
5831
6264
  }
5832
- const release_promote_DEFAULT_API = "https://www.extension.dev";
5833
6265
  const release_promote_schema = {
5834
6266
  name: "extension_release_promote",
5835
6267
  description: "Promote a built extension to a release channel (e.g. stable, preview, beta) on extension.dev, headless. Auth-gated: uses your stored login (extension_login) or a release token in EXTENSION_DEV_TOKEN (mint and revoke it in the dashboard under project settings -> Access tokens; tokens live at most 7 days, so CI must re-mint before expiry). Posts to the platform's CLI release endpoint; the project is identified by the token. Cutting a version-bump PR is not available headlessly (it writes to your source repo and needs an interactive login).",
@@ -5889,7 +6321,7 @@ async function release_promote_handler(args) {
5889
6321
  const buildId = String(args.buildId || "").trim();
5890
6322
  const channel = String(args.channel || "").trim();
5891
6323
  if (!buildId || !channel) return release_promote_fail("ReleaseInputError", "buildId and channel are required.");
5892
- const apiCheck = safeApiBase(String(args.api || process.env.EXTENSION_DEV_API_URL || release_promote_DEFAULT_API));
6324
+ const apiCheck = safeApiBase(resolveApiBase(args.api));
5893
6325
  if (!apiCheck.ok) return release_promote_fail("ReleaseConfigError", apiCheck.message);
5894
6326
  const url = `${apiCheck.base}/api/cli/release/promote`;
5895
6327
  const body = {
@@ -5927,7 +6359,7 @@ async function release_promote_handler(args) {
5927
6359
  const enrich = {};
5928
6360
  const ref = resolveProjectRef();
5929
6361
  if (404 === res.status || "UNKNOWN_BUILD" === code) {
5930
- enrich.buildsPageUrl = consoleProjectUrl(ref, "builds");
6362
+ enrich.buildsPageUrl = consoleProjectUrl(ref, "builds", args.api);
5931
6363
  enrich.hint = "Run extension_release_list to see this project's channels, their promoted shas, and recent builds.";
5932
6364
  if (ref) {
5933
6365
  const channelsUrl = registryFileUrl(ref, "channels.json");
@@ -6027,7 +6459,6 @@ async function release_list_handler(args) {
6027
6459
  if (!buildsRes.ok) result.buildsUnavailable = `builds/index.json unreadable: ${buildsRes.message}`;
6028
6460
  return JSON.stringify(result);
6029
6461
  }
6030
- const deploy_DEFAULT_API = "https://www.extension.dev";
6031
6462
  function storeMdWarnings(browsers, cwd) {
6032
6463
  const wantsFirefox = browsers.includes("firefox");
6033
6464
  const wantsEdge = browsers.includes("edge");
@@ -6056,7 +6487,7 @@ function storeMdWarnings(browsers, cwd) {
6056
6487
  }
6057
6488
  const deploy_schema = {
6058
6489
  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.",
6490
+ 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
6491
  inputSchema: {
6061
6492
  type: "object",
6062
6493
  properties: {
@@ -6067,7 +6498,8 @@ const deploy_schema = {
6067
6498
  enum: [
6068
6499
  "chrome",
6069
6500
  "firefox",
6070
- "edge"
6501
+ "edge",
6502
+ "safari"
6071
6503
  ]
6072
6504
  },
6073
6505
  description: "Stores to submit to."
@@ -6113,10 +6545,10 @@ async function deploy_handler(args) {
6113
6545
  const token = resolveToken();
6114
6546
  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).");
6115
6547
  const browsers = (Array.isArray(args.browsers) ? args.browsers : []).map((b)=>String(b).trim().toLowerCase()).filter(Boolean);
6116
- if (0 === browsers.length) return deploy_fail("DeployInputError", 'browsers is required (e.g. ["chrome","firefox","edge"]).');
6548
+ if (0 === browsers.length) return deploy_fail("DeployInputError", 'browsers is required (e.g. ["chrome","firefox","edge","safari"]).');
6117
6549
  const buildSha = String(args.buildSha || "").trim();
6118
6550
  if (!buildSha) return deploy_fail("DeployInputError", "buildSha is required (the built commit to submit).");
6119
- const apiCheck = safeApiBase(String(args.api || process.env.EXTENSION_DEV_API_URL || deploy_DEFAULT_API));
6551
+ const apiCheck = safeApiBase(resolveApiBase(args.api));
6120
6552
  if (!apiCheck.ok) return deploy_fail("DeployConfigError", apiCheck.message);
6121
6553
  const url = `${apiCheck.base}/api/cli/stores/submit`;
6122
6554
  const dryRun = false !== args.dryRun;
@@ -6161,7 +6593,7 @@ async function deploy_handler(args) {
6161
6593
  };
6162
6594
  if (dryRun) {
6163
6595
  const ref = resolveProjectRef();
6164
- const consoleStoresUrl = consoleProjectUrl(ref, "stores");
6596
+ const consoleStoresUrl = consoleProjectUrl(ref, "stores", args.api);
6165
6597
  const storeModeNote = `Store publish mode (draft / skip-publish / live) is not readable with the CLI token, so it cannot be verified from here; check per-store settings at ${consoleStoresUrl}.`;
6166
6598
  let health = null;
6167
6599
  let healthUnreadable = null;
@@ -6236,11 +6668,12 @@ async function deploy_handler(args) {
6236
6668
  const KNOWN_STORES = [
6237
6669
  "chrome",
6238
6670
  "firefox",
6239
- "edge"
6671
+ "edge",
6672
+ "safari"
6240
6673
  ];
6241
6674
  const store_status_schema = {
6242
6675
  name: "extension_store_status",
6243
- description: "Report the project's browser-store state after an extension_deploy submission: per store (chrome, firefox, edge) whether it is configured, its latest credential health check, the last recorded submission (version, status, store URL, submitted-at), and the latest review status. Reads the project's public registry (registry.extension.land: stores/health.json, stores/status.json, stores/submissions.json) - read-only, dispatches nothing, no auth needed for public projects. Defaults to the logged-in project (extension_login); pass workspace + project to inspect another public project. Registry state can lag the store dashboards by up to a polling interval.",
6676
+ description: "Report the project's browser-store state after an extension_deploy submission: per store (chrome, firefox, edge, safari) whether it is configured, its latest credential health check, the last recorded submission (version, status, store URL, submitted-at), and the latest review status. Reads the project's public registry (registry.extension.land: stores/health.json, stores/status.json, stores/submissions.json) - read-only, dispatches nothing, no auth needed for public projects. Defaults to the logged-in project (extension_login); pass workspace + project to inspect another public project. Registry state can lag the store dashboards by up to a polling interval.",
6244
6677
  inputSchema: {
6245
6678
  type: "object",
6246
6679
  properties: {
@@ -6436,6 +6869,63 @@ async function store_status_handler(args) {
6436
6869
  if (!submissionsRes.ok && 404 !== submissionsRes.status) result.submissionsUnavailable = `stores/submissions.json unreadable: ${submissionsRes.message}`;
6437
6870
  return JSON.stringify(result);
6438
6871
  }
6872
+ const ENGINE_COMPANION_IDS = new Set([
6873
+ "kgdaecdpfkikjncaalnmmnjjfpofkcbl",
6874
+ CARRIER_EXTENSION_ID
6875
+ ]);
6876
+ const EXTENSION_URL = /^chrome-extension:\/\/([a-p]{32})\//i;
6877
+ async function verifyGuestLoaded(projectPath, browser, options) {
6878
+ let cdpPort;
6879
+ try {
6880
+ const resolved = await resolveCdpPort(projectPath, browser, {
6881
+ waitMs: options?.waitMs ?? 0
6882
+ });
6883
+ if (!resolved) return {
6884
+ checked: false,
6885
+ loaded: false,
6886
+ guestTargets: [],
6887
+ guestIds: [],
6888
+ reason: "No CDP port in the session's ready contract, so the browser's target list could not be queried (a headless Chromium session still exposes one; a gecko/Firefox session does not)."
6889
+ };
6890
+ cdpPort = resolved.port;
6891
+ const timeoutMs = options?.timeoutMs ?? 3000;
6892
+ const targets = await Promise.race([
6893
+ CDPClient.discoverTargets(cdpPort),
6894
+ new Promise((_, reject)=>setTimeout(()=>reject(new Error(`CDP /json timed out after ${timeoutMs}ms`)), timeoutMs))
6895
+ ]);
6896
+ const guestTargets = [];
6897
+ for (const t of targets){
6898
+ const match = EXTENSION_URL.exec(String(t.url ?? ""));
6899
+ if (!match) continue;
6900
+ const id = match[1].toLowerCase();
6901
+ if (!ENGINE_COMPANION_IDS.has(id)) guestTargets.push({
6902
+ id,
6903
+ type: String(t.type),
6904
+ url: String(t.url)
6905
+ });
6906
+ }
6907
+ const guestIds = [
6908
+ ...new Set(guestTargets.map((t)=>t.id))
6909
+ ];
6910
+ return {
6911
+ checked: true,
6912
+ loaded: guestTargets.length > 0,
6913
+ guestTargets,
6914
+ guestIds,
6915
+ cdpPort,
6916
+ reason: guestTargets.length > 0 ? `The browser lists ${guestIds.length} extension target${1 === guestIds.length ? "" : "s"} that are not the engine companion (${guestIds.join(", ")}), so the guest is loaded.` : "The browser's target list has no chrome-extension:// target other than the engine's devtools companion. On Chrome this is the signature of a silently rejected --load-extension (extension.js BUGS_TO_FIX §83): the CLI and ready.json cannot see it."
6917
+ };
6918
+ } catch (err) {
6919
+ return {
6920
+ checked: false,
6921
+ loaded: false,
6922
+ guestTargets: [],
6923
+ guestIds: [],
6924
+ cdpPort,
6925
+ reason: `Could not query the browser's target list${cdpPort ? ` on CDP port ${cdpPort}` : ""}: ${err?.message ?? String(err)}.`
6926
+ };
6927
+ }
6928
+ }
6439
6929
  function doctor_readReadyContract(projectPath, browser) {
6440
6930
  try {
6441
6931
  const raw = node_fs.readFileSync(node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json"), "utf8");
@@ -6631,7 +7121,7 @@ function wait_isAlive(pid) {
6631
7121
  }
6632
7122
  const wait_schema = {
6633
7123
  name: "extension_wait",
6634
- description: "Wait for a running dev or start session to be ready. Polls the ready.json contract file and returns structured status with two separate facts: compiled (the compiler finished) and browserAttached (the extension's runtime connected from a live browser). Every result reports budgetMs (this call's wait budget) and elapsedMs; on status:'timeout' call again to keep waiting (polling resumes on the same contract). In a noBrowser (build-only) session it returns as soon as the compile lands instead of waiting for a browser that will never attach. Ports in the result come from the ready contract, so they always match what the dev server actually bound.",
7124
+ description: "Wait for a running dev or start session to be ready. Polls the ready.json contract file and returns structured status with these facts: compiled (the compiler finished), browserAttached (the engine's runtime executor connected), and for a browser session guestLoaded (the browser's OWN target list actually shows your extension). guestLoaded is the trustworthy load signal: it catches a silently rejected --load-extension that leaves ready.json stamped attached with empty logs (extension.js BUGS_TO_FIX §83); it is null when it could not be checked (no CDP port, e.g. a gecko session). Every result reports budgetMs (this call's wait budget) and elapsedMs; on status:'timeout' call again to keep waiting (polling resumes on the same contract). In a noBrowser (build-only) session it returns as soon as the compile lands instead of waiting for a browser that will never attach. Ports in the result come from the ready contract, so they always match what the dev server actually bound.",
6635
7125
  inputSchema: {
6636
7126
  type: "object",
6637
7127
  properties: {
@@ -6707,10 +7197,20 @@ async function wait_handler(args) {
6707
7197
  continue;
6708
7198
  }
6709
7199
  const runtimeErrors = recentErrorLogs(args.projectPath, browser, 3);
7200
+ const guestCheck = await verifyGuestLoaded(args.projectPath, browser);
7201
+ const warnings = [];
7202
+ if (runtimeErrors.length) warnings.push(`Compiled and attached, but the extension is throwing at runtime (${runtimeErrors.length} recent error event${1 === runtimeErrors.length ? "" : "s"} above). Check extension_logs (level: error) or extension_doctor before trusting this session.`);
7203
+ if (guestCheck.checked && !guestCheck.loaded) warnings.push("The engine reports the runtime attached, but the browser's own target list shows no chrome-extension:// target for your extension, only the engine companion. This is the signature of a silently rejected --load-extension (extension.js BUGS_TO_FIX §83): the CLI and ready.json cannot see it, and the control verbs will fail against a guest that is not there. Check the manifest and extension_logs.");
6710
7204
  return JSON.stringify({
6711
7205
  status: "ready",
6712
7206
  compiled: true,
6713
7207
  browserAttached: true,
7208
+ guestLoaded: guestCheck.checked ? guestCheck.loaded : null,
7209
+ ...guestCheck.checked ? {
7210
+ guestIds: guestCheck.guestIds
7211
+ } : {
7212
+ guestLoadNote: guestCheck.reason
7213
+ },
6714
7214
  command: contract.command,
6715
7215
  browser: contract.browser,
6716
7216
  port: contract.port,
@@ -6722,8 +7222,10 @@ async function wait_handler(args) {
6722
7222
  budgetMs,
6723
7223
  elapsedMs: Date.now() - start,
6724
7224
  ...runtimeErrors.length ? {
6725
- runtimeErrors,
6726
- warning: `Compiled and attached, but the extension is throwing at runtime (${runtimeErrors.length} recent error event${1 === runtimeErrors.length ? "" : "s"} above). Check extension_logs (level: error) or extension_doctor before trusting this session.`
7225
+ runtimeErrors
7226
+ } : {},
7227
+ ...warnings.length ? {
7228
+ warning: warnings.join(" ")
6727
7229
  } : {}
6728
7230
  });
6729
7231
  }
@@ -7422,7 +7924,10 @@ const logout_schema = {
7422
7924
  };
7423
7925
  async function logout_handler() {
7424
7926
  const creds = readCredentials();
7425
- const revokeUrl = creds?.workspaceSlug && creds?.projectSlug ? `https://console.extension.dev/${creds.workspaceSlug}/${creds.projectSlug}/settings/access-tokens` : null;
7927
+ const revokeUrl = creds?.workspaceSlug && creds?.projectSlug ? consoleProjectUrl({
7928
+ workspace: creds.workspaceSlug,
7929
+ project: creds.projectSlug
7930
+ }, "settings/access-tokens") : null;
7426
7931
  const result = clearCredentials();
7427
7932
  return JSON.stringify({
7428
7933
  ok: true,