@extension.dev/mcp 10.1.0 → 10.2.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
@@ -8,6 +8,7 @@ import { extensionCreate } from "extension-create";
8
8
  import node_os from "node:os";
9
9
  import cross_spawn from "cross-spawn";
10
10
  import node_crypto, { createHash } from "node:crypto";
11
+ import { CLOSE_BAD_HELLO, CLOSE_BAD_INSTANCE, CLOSE_CONTROL_UNAVAILABLE, CLOSE_SLOW_CONSUMER, CONTROL_ENVELOPE_VERSION, CONTROL_WS_PATH, buildSummaryPath, logsPath, matchesLogQuery, readLogEvents, readReadyContract, readyContractPath as bridge_readyContractPath, sessionArtifactsRootDir } from "extension-develop/bridge";
11
12
  import { fileURLToPath } from "node:url";
12
13
  import { execFile, execFileSync } from "node:child_process";
13
14
  import ws_0 from "ws";
@@ -431,6 +432,7 @@ __webpack_require__.d(browsers_namespaceObject, {
431
432
  var build_namespaceObject = {};
432
433
  __webpack_require__.r(build_namespaceObject);
433
434
  __webpack_require__.d(build_namespaceObject, {
435
+ BUNDLE_ID_PATTERN: ()=>BUNDLE_ID_PATTERN,
434
436
  handler: ()=>build_handler,
435
437
  schema: ()=>build_schema
436
438
  });
@@ -481,6 +483,7 @@ __webpack_require__.d(list_extensions_namespaceObject, {
481
483
  var logs_namespaceObject = {};
482
484
  __webpack_require__.r(logs_namespaceObject);
483
485
  __webpack_require__.d(logs_namespaceObject, {
486
+ controlRefusal: ()=>controlRefusal,
484
487
  handler: ()=>logs_handler,
485
488
  schema: ()=>logs_schema_schema
486
489
  });
@@ -579,7 +582,7 @@ __webpack_require__.d(wait_namespaceObject, {
579
582
  handler: ()=>wait_handler,
580
583
  schema: ()=>wait_schema
581
584
  });
582
- var package_namespaceObject = JSON.parse('{"rE":"10.1.0","El":{"OP":"4.0.17"}}');
585
+ var package_namespaceObject = JSON.parse('{"rE":"10.2.0","El":{"OP":"4.0.20"}}');
583
586
  const ENVELOPE_SCHEMA = 1;
584
587
  const collectWarnings = (warnings)=>{
585
588
  if (!warnings) return [];
@@ -605,7 +608,7 @@ function envelopeObject(init) {
605
608
  if ("string" == typeof init.hint && init.hint) frame.hint = init.hint;
606
609
  return frame;
607
610
  }
608
- function envelope(init) {
611
+ function envelope_envelope(init) {
609
612
  return JSON.stringify(envelopeObject(init));
610
613
  }
611
614
  function sessionCommandSinceEnvelopeOwnsCommand(contract) {
@@ -769,7 +772,7 @@ async function spawnFailedEnvelope(command, spawned) {
769
772
  await new Promise((resolve)=>setTimeout(resolve, 25));
770
773
  cause = spawned.spawnError?.() ?? null;
771
774
  }
772
- return envelope({
775
+ return envelope_envelope({
773
776
  ok: false,
774
777
  command,
775
778
  status: "spawn-failed",
@@ -865,7 +868,11 @@ function tokenTtlNote(workspaceSlug, projectSlug) {
865
868
  function resolveApiBase(api) {
866
869
  return String(api || process.env.EXTENSION_DEV_API_URL || DEFAULT_API).replace(/\/+$/, "");
867
870
  }
868
- function safeApiBase(raw) {
871
+ function isExtensionDevHost(hostname) {
872
+ const host = hostname.toLowerCase().replace(/\.+$/, "");
873
+ return "extension.dev" === host || host.endsWith(".extension.dev");
874
+ }
875
+ function safeApiBase(raw, callerSupplied) {
869
876
  let parsed;
870
877
  try {
871
878
  parsed = new URL(raw);
@@ -880,6 +887,11 @@ function safeApiBase(raw) {
880
887
  ok: false,
881
888
  message: `Refusing to send the access token to ${raw}: use https (http is allowed only for localhost).`
882
889
  };
890
+ const fromCaller = String(callerSupplied || "").trim();
891
+ if (fromCaller && !isLocalhost && !isExtensionDevHost(parsed.hostname)) return {
892
+ ok: false,
893
+ message: `Refusing to send the access token to ${raw}: an api argument may only name an extension.dev host or a local dev server. To use a self-hosted platform, set EXTENSION_DEV_API_URL where the server is launched instead of passing it per call.`
894
+ };
883
895
  return {
884
896
  ok: true,
885
897
  base: `${parsed.origin}${parsed.pathname}`.replace(/\/+$/, "")
@@ -1081,7 +1093,7 @@ class RegistryAccessTokens {
1081
1093
  status: "no-credential"
1082
1094
  };
1083
1095
  }
1084
- const check = safeApiBase(resolveApiBase(apiHint || creds?.api));
1096
+ const check = safeApiBase(resolveApiBase(apiHint || creds?.api), apiHint);
1085
1097
  if (!check.ok) return {
1086
1098
  status: "denied",
1087
1099
  message: check.message
@@ -1415,7 +1427,7 @@ async function create_handler(args) {
1415
1427
  error: capture("error")
1416
1428
  }
1417
1429
  });
1418
- const failure = (err, transient)=>transient ? envelope({
1430
+ const failure = (err, transient)=>transient ? envelope_envelope({
1419
1431
  ok: false,
1420
1432
  command: COMMAND,
1421
1433
  status: "template-fetch-failed",
@@ -1428,7 +1440,7 @@ async function create_handler(args) {
1428
1440
  duration: Date.now() - start,
1429
1441
  log: logTail()
1430
1442
  }
1431
- }) : envelope({
1443
+ }) : envelope_envelope({
1432
1444
  ok: false,
1433
1445
  command: COMMAND,
1434
1446
  status: "scaffold-failed",
@@ -1455,7 +1467,7 @@ async function create_handler(args) {
1455
1467
  }
1456
1468
  }
1457
1469
  const hasManifest = node_fs.existsSync(node_path.join(result.projectPath, "manifest.json")) || node_fs.existsSync(node_path.join(result.projectPath, "src", "manifest.json"));
1458
- if (!hasManifest) return envelope({
1470
+ if (!hasManifest) return envelope_envelope({
1459
1471
  ok: false,
1460
1472
  command: COMMAND,
1461
1473
  status: "scaffold-incomplete",
@@ -1483,7 +1495,7 @@ async function create_handler(args) {
1483
1495
  const deployUrl = `${wwwOrigin}${(0, paths_0.wwwNewPath)({
1484
1496
  template: result.template
1485
1497
  })}`;
1486
- return envelope({
1498
+ return envelope_envelope({
1487
1499
  ok: true,
1488
1500
  command: COMMAND,
1489
1501
  status: "created",
@@ -1525,7 +1537,7 @@ async function create_handler(args) {
1525
1537
  }
1526
1538
  const DEFAULT_MEDIA_ORIGIN = "https://media.extension.land";
1527
1539
  const DEFAULT_CHANNEL = "latest";
1528
- const PINNED_COMMIT = "2d2ed9668cca002148d9eecd953a08b54d0bad9d";
1540
+ const PINNED_COMMIT = "520a8ede767fe1937c7184f4857408caf9c5934f";
1529
1541
  const CHANNEL_CACHE_TTL_MS = 300000;
1530
1542
  function mediaOrigin() {
1531
1543
  return (process.env.EXTENSION_MEDIA_ORIGIN || "").trim() || DEFAULT_MEDIA_ORIGIN;
@@ -1733,7 +1745,7 @@ async function searchTemplates(args) {
1733
1745
  repositoryUrl: t.repositoryUrl,
1734
1746
  downloads: t.downloads
1735
1747
  }));
1736
- return envelope({
1748
+ return envelope_envelope({
1737
1749
  ok: true,
1738
1750
  command: "extension_templates",
1739
1751
  status: "listed",
@@ -1746,7 +1758,7 @@ async function searchTemplates(args) {
1746
1758
  const get_template_source_COMMAND = "extension_templates";
1747
1759
  async function readTemplateSource(args) {
1748
1760
  const template = await getTemplateBySlug(args.slug);
1749
- if (!template) return envelope({
1761
+ if (!template) return envelope_envelope({
1750
1762
  ok: false,
1751
1763
  command: get_template_source_COMMAND,
1752
1764
  status: "template-not-found",
@@ -1766,7 +1778,7 @@ async function readTemplateSource(args) {
1766
1778
  keyFiles: template.keyFiles,
1767
1779
  repositoryUrl: template.repositoryUrl
1768
1780
  };
1769
- if (!args.files?.length) return envelope({
1781
+ if (!args.files?.length) return envelope_envelope({
1770
1782
  ok: true,
1771
1783
  command: get_template_source_COMMAND,
1772
1784
  status: "file-list",
@@ -1791,7 +1803,7 @@ async function readTemplateSource(args) {
1791
1803
  } catch {}
1792
1804
  errors.push(`${filePath}: ${lastStatus || "fetch failed"}`);
1793
1805
  }));
1794
- return envelope({
1806
+ return envelope_envelope({
1795
1807
  ok: 0 === errors.length,
1796
1808
  command: get_template_source_COMMAND,
1797
1809
  status: errors.length ? "partial" : "read",
@@ -1873,7 +1885,7 @@ const templates_schema = {
1873
1885
  };
1874
1886
  async function templates_handler(args) {
1875
1887
  if ((args.action ?? "list") === "source" || !args.action && args.slug) {
1876
- if (!args.slug) return envelope({
1888
+ if (!args.slug) return envelope_envelope({
1877
1889
  ok: false,
1878
1890
  command: "extension_templates",
1879
1891
  status: "bad-request",
@@ -1957,14 +1969,239 @@ const LAUNCH_BROWSER = {
1957
1969
  enum: LAUNCHABLE_BROWSERS,
1958
1970
  default: "chrome"
1959
1971
  };
1972
+ const OUTPUT_JSON_FLOOR = {
1973
+ act: "3.18.1",
1974
+ doctor: "4.0.11",
1975
+ dev: "4.0.17",
1976
+ build: "4.0.17"
1977
+ };
1978
+ const VERDICT_TTL_MS = 60000;
1979
+ const PROBE_TIMEOUT_MS = 20000;
1980
+ const SEMVER = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
1981
+ function decompose(version) {
1982
+ const match = SEMVER.exec(version.trim());
1983
+ if (!match) return null;
1984
+ return {
1985
+ release: [
1986
+ Number(match[1]),
1987
+ Number(match[2]),
1988
+ Number(match[3])
1989
+ ],
1990
+ prerelease: match[4] ? match[4].split(".") : []
1991
+ };
1992
+ }
1993
+ function parseVersion(text) {
1994
+ for (const line of text.split("\n")){
1995
+ const candidate = line.trim();
1996
+ if (candidate) {
1997
+ if (decompose(candidate)) return candidate.replace(/^v/, "");
1998
+ }
1999
+ }
2000
+ return null;
2001
+ }
2002
+ function compareNumericIdentifiers(a, b) {
2003
+ const left = a.replace(/^0+(?=\d)/, "");
2004
+ const right = b.replace(/^0+(?=\d)/, "");
2005
+ if (left.length !== right.length) return left.length < right.length ? -1 : 1;
2006
+ if (left === right) return 0;
2007
+ return left < right ? -1 : 1;
2008
+ }
2009
+ function compareVersions(a, b) {
2010
+ const left = decompose(a);
2011
+ const right = decompose(b);
2012
+ if (!left || !right) return null;
2013
+ for(let i = 0; i < 3; i += 1)if (left.release[i] !== right.release[i]) return left.release[i] < right.release[i] ? -1 : 1;
2014
+ if (!left.prerelease.length && !right.prerelease.length) return 0;
2015
+ if (!left.prerelease.length) return 1;
2016
+ if (!right.prerelease.length) return -1;
2017
+ const length = Math.max(left.prerelease.length, right.prerelease.length);
2018
+ for(let i = 0; i < length; i += 1){
2019
+ const one = left.prerelease[i];
2020
+ const other = right.prerelease[i];
2021
+ if (void 0 === one) return -1;
2022
+ if (void 0 === other) return 1;
2023
+ const oneIsNumeric = /^\d+$/.test(one);
2024
+ const otherIsNumeric = /^\d+$/.test(other);
2025
+ if (oneIsNumeric && otherIsNumeric) {
2026
+ const ordering = compareNumericIdentifiers(one, other);
2027
+ if (0 !== ordering) return ordering;
2028
+ continue;
2029
+ }
2030
+ if (oneIsNumeric !== otherIsNumeric) return oneIsNumeric ? -1 : 1;
2031
+ if (one !== other) return one < other ? -1 : 1;
2032
+ }
2033
+ return 0;
2034
+ }
2035
+ const verdicts = new Map();
2036
+ function invocationKey(command, prefixArgs) {
2037
+ return [
2038
+ command,
2039
+ ...prefixArgs
2040
+ ].join("\0");
2041
+ }
2042
+ const NPX_PIN = /^extension@(.+)$/;
2043
+ function parseCapabilities(stdout) {
2044
+ for (const line of stdout.split("\n")){
2045
+ const candidate = line.trim();
2046
+ if (!candidate.startsWith("{")) continue;
2047
+ let frame;
2048
+ try {
2049
+ frame = JSON.parse(candidate);
2050
+ } catch {
2051
+ continue;
2052
+ }
2053
+ if (!frame || "object" != typeof frame) continue;
2054
+ const envelope = frame;
2055
+ if (1 !== envelope.schema) continue;
2056
+ if (true !== envelope.ok) continue;
2057
+ if ("capabilities" !== envelope.command) continue;
2058
+ const value = envelope.value;
2059
+ if (!value || "object" != typeof value) continue;
2060
+ const version = "string" == typeof value.version ? parseVersion(value.version) : null;
2061
+ const roster = value.outputJsonCommands;
2062
+ if (version && Array.isArray(roster)) {
2063
+ if (roster.every((name)=>"string" == typeof name)) return {
2064
+ version,
2065
+ outputJsonCommands: roster
2066
+ };
2067
+ }
2068
+ }
2069
+ return null;
2070
+ }
2071
+ async function resolvedEngineFacts(projectPath) {
2072
+ const { command, prefixArgs } = resolveExtensionInvocation(projectPath);
2073
+ const key = invocationKey(command, prefixArgs);
2074
+ const cached = verdicts.get(key);
2075
+ if (cached && cached.expiresAt > Date.now()) return {
2076
+ version: cached.version,
2077
+ outputJsonCommands: cached.outputJsonCommands
2078
+ };
2079
+ const remember = (found)=>{
2080
+ verdicts.set(key, {
2081
+ ...found,
2082
+ expiresAt: Date.now() + VERDICT_TTL_MS
2083
+ });
2084
+ return found;
2085
+ };
2086
+ for (const arg of prefixArgs){
2087
+ const pin = NPX_PIN.exec(arg);
2088
+ const pinned = pin ? parseVersion(pin[1]) : null;
2089
+ if (pinned) return remember({
2090
+ version: pinned,
2091
+ outputJsonCommands: null
2092
+ });
2093
+ }
2094
+ try {
2095
+ const probe = await runExtensionCli([
2096
+ "capabilities"
2097
+ ], {
2098
+ cwd: projectPath,
2099
+ timeoutMs: PROBE_TIMEOUT_MS
2100
+ });
2101
+ if (0 === probe.code) {
2102
+ const answered = parseCapabilities(probe.stdout ?? "");
2103
+ if (answered) return remember(answered);
2104
+ }
2105
+ } catch {}
2106
+ try {
2107
+ const probe = await runExtensionCli([
2108
+ "--version"
2109
+ ], {
2110
+ cwd: projectPath,
2111
+ timeoutMs: PROBE_TIMEOUT_MS
2112
+ });
2113
+ if (0 !== probe.code) return remember({
2114
+ version: null,
2115
+ outputJsonCommands: null
2116
+ });
2117
+ return remember({
2118
+ version: parseVersion(probe.stdout ?? "") ?? parseVersion(probe.stderr ?? ""),
2119
+ outputJsonCommands: null
2120
+ });
2121
+ } catch {
2122
+ return remember({
2123
+ version: null,
2124
+ outputJsonCommands: null
2125
+ });
2126
+ }
2127
+ }
2128
+ const ROSTER_NAMES = {
2129
+ act: [
2130
+ "eval",
2131
+ "inspect",
2132
+ "open",
2133
+ "reload",
2134
+ "storage"
2135
+ ],
2136
+ doctor: [
2137
+ "doctor"
2138
+ ],
2139
+ dev: [
2140
+ "dev"
2141
+ ],
2142
+ build: [
2143
+ "build"
2144
+ ]
2145
+ };
2146
+ async function outputJsonVerdict(command, projectPath) {
2147
+ const floor = OUTPUT_JSON_FLOOR[command];
2148
+ let engine;
2149
+ try {
2150
+ engine = await resolvedEngineFacts(projectPath);
2151
+ } catch {
2152
+ return {
2153
+ supported: null,
2154
+ version: null,
2155
+ floor
2156
+ };
2157
+ }
2158
+ const roster = engine.outputJsonCommands;
2159
+ if (roster) return {
2160
+ supported: ROSTER_NAMES[command].every((name)=>roster.includes(name)),
2161
+ version: engine.version,
2162
+ floor
2163
+ };
2164
+ const version = engine.version;
2165
+ if (null === version) return {
2166
+ supported: null,
2167
+ version: null,
2168
+ floor
2169
+ };
2170
+ const ordering = compareVersions(version, floor);
2171
+ if (null === ordering) return {
2172
+ supported: null,
2173
+ version,
2174
+ floor
2175
+ };
2176
+ return {
2177
+ supported: ordering >= 0,
2178
+ version,
2179
+ floor
2180
+ };
2181
+ }
2182
+ const UNKNOWN_OUTPUT_FLAG = /unknown option[^\n]*--output\b/i;
2183
+ function refusedTheOutputFlag(stderr) {
2184
+ return UNKNOWN_OUTPUT_FLAG.test(stderr);
2185
+ }
2186
+ async function outputFlagRefusalMessage(command, cliName, projectPath) {
2187
+ const verdict = await outputJsonVerdict(command, projectPath);
2188
+ const preamble = `The Extension.js resolved for this project refused \`--output json\` on \`extension ${cliName}\`.`;
2189
+ const why = "You did not pass that flag: this server adds it so it can read a structured result instead of parsing a report written for a human, and unlike `build` there is no second source it can fall back to here, so it cannot simply drop it.";
2190
+ if (false === verdict.supported) return `${preamble} It reports ${verdict.version}, and that flag only reached \`extension ${cliName}\` in ${verdict.floor}. ${why} Upgrade the project's Extension.js to ${verdict.floor} or newer and run this again.`;
2191
+ if (true === verdict.supported) return `${preamble} It reports ${verdict.version}, which is at or above ${verdict.floor}, the release where that flag reached \`extension ${cliName}\`, so the binary being run is not the version it claims to be. ${why} Check the project's node_modules/.bin/extension and reinstall it, rather than upgrading a version that already looks new enough.`;
2192
+ return `${preamble} Its version could not be read, so the cause cannot be confirmed, but that flag only reached \`extension ${cliName}\` in ${verdict.floor} and a refusal is what an engine below that does. ${why} Check the project's Extension.js install and bring it to ${verdict.floor} or newer.`;
2193
+ }
1960
2194
  const sessions = new Map();
1961
2195
  const registrationStamps = new Map();
1962
2196
  function sessionKey(projectPath, browser) {
1963
2197
  return `${node_path.resolve(projectPath)}::${browser}`;
1964
2198
  }
1965
- function markerDir() {
2199
+ function sessionStateDir() {
1966
2200
  return process.env.EXTENSION_MCP_SESSION_DIR || node_path.join(node_os.tmpdir(), "extension-dev-mcp-sessions");
1967
2201
  }
2202
+ function markerDir() {
2203
+ return sessionStateDir();
2204
+ }
1968
2205
  function markerPath(projectPath, browser) {
1969
2206
  const digest = node_crypto.createHash("sha1").update(sessionKey(projectPath, browser)).digest("hex").slice(0, 16);
1970
2207
  return node_path.join(markerDir(), `${digest}.json`);
@@ -2047,8 +2284,29 @@ function removeSession(projectPath, browser, pid) {
2047
2284
  function listSessions() {
2048
2285
  return Array.from(sessions.values());
2049
2286
  }
2287
+ function sessionPathHint(file) {
2288
+ return `Looked at ${file} (the session-state layout this MCP's pinned engine publishes). If the project runs an older Extension.js, its layout may differ and this path will never appear.`;
2289
+ }
2290
+ function profilesRootDir(projectPath) {
2291
+ return node_path.join(sessionArtifactsRootDir(projectPath), "profiles");
2292
+ }
2293
+ function browserProfileRootDir(projectPath, browser) {
2294
+ return node_path.join(profilesRootDir(projectPath), `${browser}-profile`);
2295
+ }
2296
+ const PERSISTED_PROFILE_DIR_NAME = "dev";
2297
+ function profileRemediation(input) {
2298
+ const { projectPath, browser, profile } = input;
2299
+ const raw = "string" == typeof profile ? profile.trim() : "";
2300
+ if ("false" === raw.toLowerCase()) return `This session was launched with profile:"false", so it runs your real ${browser} profile and there is no Extension.js profile directory to remove. Quit the ${browser} window that holds it and start again.`;
2301
+ if (raw.length > 0) {
2302
+ const explicit = node_path.isAbsolute(raw) ? raw : node_path.resolve(projectPath, raw);
2303
+ return `This session was launched against the profile you passed, ${explicit}. Close whatever still holds it, or remove that directory, before retrying.`;
2304
+ }
2305
+ const root = browserProfileRootDir(projectPath, browser);
2306
+ return `The engine keeps this session's profile in a directory inside ${root}, one per run: "${PERSISTED_PROFILE_DIR_NAME}" when the profile is persisted, otherwise three random words drawn fresh on every start, which no caller can predict. List ${root} to see which run directories exist and remove the one the stuck browser holds, or remove ${root} entirely once no session is running.`;
2307
+ }
2050
2308
  function contractSightings(projectPath) {
2051
- const root = node_path.resolve(projectPath, "dist", "extension-js");
2309
+ const root = sessionArtifactsRootDir(projectPath);
2052
2310
  let dirs;
2053
2311
  try {
2054
2312
  dirs = node_fs.readdirSync(root);
@@ -2057,7 +2315,7 @@ function contractSightings(projectPath) {
2057
2315
  }
2058
2316
  const sightings = [];
2059
2317
  for (const dir of dirs){
2060
- const readyPath = node_path.join(root, dir, "ready.json");
2318
+ const readyPath = bridge_readyContractPath(projectPath, dir);
2061
2319
  try {
2062
2320
  const stat = node_fs.statSync(readyPath);
2063
2321
  const contract = JSON.parse(node_fs.readFileSync(readyPath, "utf8"));
@@ -2126,7 +2384,7 @@ function deadReadySession(projectPath) {
2126
2384
  return null;
2127
2385
  }
2128
2386
  function contractBoundPort(projectPath, browser, since) {
2129
- const readyPath = node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json");
2387
+ const readyPath = bridge_readyContractPath(projectPath, browser);
2130
2388
  try {
2131
2389
  const stat = node_fs.statSync(readyPath);
2132
2390
  if (stat.mtimeMs < since) return null;
@@ -2189,9 +2447,18 @@ function isChromiumFamily(browser) {
2189
2447
  function isGeckoFamily(browser) {
2190
2448
  return GECKO_FAMILY.has(browser);
2191
2449
  }
2450
+ function insideRepository(projectPath) {
2451
+ let current = node_path.resolve(projectPath);
2452
+ for(;;){
2453
+ if (node_fs.existsSync(node_path.join(current, ".git"))) return true;
2454
+ const parent = node_path.dirname(current);
2455
+ if (parent === current) return false;
2456
+ current = parent;
2457
+ }
2458
+ }
2192
2459
  function ensureProjectIgnored(projectPath, options) {
2193
2460
  const entry = options.entry;
2194
- if (!node_fs.existsSync(node_path.join(projectPath, ".git"))) return {
2461
+ if (!insideRepository(projectPath)) return {
2195
2462
  state: "not-a-repo",
2196
2463
  entry
2197
2464
  };
@@ -2223,6 +2490,56 @@ function ensureProjectIgnored(projectPath, options) {
2223
2490
  };
2224
2491
  }
2225
2492
  }
2493
+ const placedHere = new Set();
2494
+ function recordDir() {
2495
+ return node_path.join(sessionStateDir(), "carriers");
2496
+ }
2497
+ function recordPath(resolved) {
2498
+ const digest = node_crypto.createHash("sha1").update(resolved).digest("hex").slice(0, 16);
2499
+ return node_path.join(recordDir(), `${digest}.json`);
2500
+ }
2501
+ function rememberCarrier(projectPath) {
2502
+ const resolved = node_path.resolve(projectPath);
2503
+ placedHere.add(resolved);
2504
+ try {
2505
+ node_fs.mkdirSync(recordDir(), {
2506
+ recursive: true
2507
+ });
2508
+ node_fs.writeFileSync(recordPath(resolved), `${JSON.stringify({
2509
+ projectPath: resolved,
2510
+ pid: process.pid,
2511
+ placedAt: new Date().toISOString()
2512
+ })}\n`);
2513
+ } catch {}
2514
+ }
2515
+ function forgetCarrier(projectPath) {
2516
+ const resolved = node_path.resolve(projectPath);
2517
+ placedHere.delete(resolved);
2518
+ try {
2519
+ node_fs.rmSync(recordPath(resolved), {
2520
+ force: true
2521
+ });
2522
+ } catch {}
2523
+ }
2524
+ function carriersPlacedHere() {
2525
+ return [
2526
+ ...placedHere
2527
+ ];
2528
+ }
2529
+ function rememberedCarriers() {
2530
+ let files;
2531
+ try {
2532
+ files = node_fs.readdirSync(recordDir());
2533
+ } catch {
2534
+ return [];
2535
+ }
2536
+ const out = [];
2537
+ for (const file of files)if (file.endsWith(".json")) try {
2538
+ const parsed = JSON.parse(node_fs.readFileSync(node_path.join(recordDir(), file), "utf8"));
2539
+ if ("string" == typeof parsed.projectPath && parsed.projectPath) out.push(parsed.projectPath);
2540
+ } catch {}
2541
+ return out;
2542
+ }
2226
2543
  const CARRIER_DIR_NAME = "extension-dev-live-preview";
2227
2544
  const CARRIER_EXTENSION_ID = "ibppeifnekhjjjmpjfiobccjlicbmgcb";
2228
2545
  const MARKER_FILE = "managed-by-extension-dev-mcp.json";
@@ -2241,16 +2558,78 @@ function deriveCarrierId(source) {
2241
2558
  function carrierPath(projectPath) {
2242
2559
  return node_path.join(projectPath, "extensions", CARRIER_DIR_NAME);
2243
2560
  }
2561
+ function relativeFiles(dir, base = dir, depth = 0) {
2562
+ if (depth > 4) return null;
2563
+ let entries;
2564
+ try {
2565
+ entries = node_fs.readdirSync(dir, {
2566
+ withFileTypes: true
2567
+ });
2568
+ } catch {
2569
+ return null;
2570
+ }
2571
+ const out = [];
2572
+ for (const entry of entries){
2573
+ const full = node_path.join(dir, entry.name);
2574
+ if (entry.isDirectory()) {
2575
+ const nested = relativeFiles(full, base, depth + 1);
2576
+ if (null === nested) return null;
2577
+ out.push(...nested);
2578
+ continue;
2579
+ }
2580
+ out.push(node_path.relative(base, full));
2581
+ if (out.length > 500) return null;
2582
+ }
2583
+ return out;
2584
+ }
2585
+ function claimCarrier(target) {
2586
+ if (node_fs.existsSync(node_path.join(target, MARKER_FILE))) return {
2587
+ ours: true,
2588
+ how: "marker"
2589
+ };
2590
+ if (deriveCarrierId(target) === CARRIER_EXTENSION_ID) return {
2591
+ ours: true,
2592
+ how: "payload"
2593
+ };
2594
+ if (node_fs.existsSync(node_path.join(target, "manifest.json"))) return {
2595
+ ours: false,
2596
+ how: "foreign"
2597
+ };
2598
+ const source = findBundledCarrier("chromium");
2599
+ const bundled = source ? relativeFiles(source) : null;
2600
+ const present = relativeFiles(target);
2601
+ if (!bundled || !present) return {
2602
+ ours: false,
2603
+ how: "foreign"
2604
+ };
2605
+ const known = new Set(bundled);
2606
+ return present.every((file)=>known.has(file)) ? {
2607
+ ours: true,
2608
+ how: "partial"
2609
+ } : {
2610
+ ours: false,
2611
+ how: "foreign"
2612
+ };
2613
+ }
2614
+ const RECOVERY_NOTE = {
2615
+ payload: `Its ${MARKER_FILE} marker was missing, but its manifest key derives the carrier's own extension id ${CARRIER_EXTENSION_ID}, which only this package's payload has, so it was recognised as ours and taken back.`,
2616
+ partial: `Its ${MARKER_FILE} marker was missing and it holds no manifest, but every file in it belongs to the bundled carrier payload, so it was recognised as a half-written copy of ours and taken back.`
2617
+ };
2618
+ const FOREIGN_NOTE = `extensions/${CARRIER_DIR_NAME} has no ${MARKER_FILE} marker, does not carry the carrier's own manifest key, and holds files this package never ships, so it is not the carrier this tool placed and was left untouched. Nothing here deletes a directory this tool did not write: rename it or move it out of ./extensions yourself if you want the carrier to live at that path.`;
2244
2619
  function removeCarrier(projectPath) {
2245
2620
  const target = carrierPath(projectPath);
2246
- if (!node_fs.existsSync(target)) return {
2247
- removed: false,
2248
- path: target
2249
- };
2250
- if (!node_fs.existsSync(node_path.join(target, MARKER_FILE))) return {
2621
+ if (!node_fs.existsSync(target)) {
2622
+ forgetCarrier(projectPath);
2623
+ return {
2624
+ removed: false,
2625
+ path: target
2626
+ };
2627
+ }
2628
+ const claim = claimCarrier(target);
2629
+ if (!claim.ours) return {
2251
2630
  removed: false,
2252
2631
  path: target,
2253
- note: `extensions/${CARRIER_DIR_NAME} has no ${MARKER_FILE} marker, so it is not the carrier this tool placed and was left untouched.`
2632
+ note: FOREIGN_NOTE
2254
2633
  };
2255
2634
  try {
2256
2635
  node_fs.rmSync(target, {
@@ -2264,13 +2643,17 @@ function removeCarrier(projectPath) {
2264
2643
  note: `Could not remove the carrier: ${error instanceof Error ? error.message : String(error)}`
2265
2644
  };
2266
2645
  }
2646
+ forgetCarrier(projectPath);
2267
2647
  const parent = node_path.join(projectPath, "extensions");
2268
2648
  try {
2269
2649
  if (0 === node_fs.readdirSync(parent).length) node_fs.rmdirSync(parent);
2270
2650
  } catch {}
2271
2651
  return {
2272
2652
  removed: true,
2273
- path: target
2653
+ path: target,
2654
+ ..."marker" === claim.how ? {} : {
2655
+ note: RECOVERY_NOTE[claim.how]
2656
+ }
2274
2657
  };
2275
2658
  }
2276
2659
  function ensureCarrierIgnored(projectPath) {
@@ -2308,10 +2691,14 @@ function materializeCarrier(projectPath, browser) {
2308
2691
  };
2309
2692
  const target = carrierPath(projectPath);
2310
2693
  const marker = node_path.join(target, MARKER_FILE);
2311
- if (node_fs.existsSync(target) && !node_fs.existsSync(marker)) return {
2694
+ const claim = node_fs.existsSync(target) ? claimCarrier(target) : {
2695
+ ours: true,
2696
+ how: "marker"
2697
+ };
2698
+ if (!claim.ours) return {
2312
2699
  loaded: false,
2313
2700
  path: target,
2314
- 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.`
2701
+ note: `A directory already exists at extensions/${CARRIER_DIR_NAME} that this tool did not place: no ${MARKER_FILE} marker, no carrier manifest key, and files this package never ships. It was left untouched. Rename it or move it out of ./extensions to let extension_dev place the carrier there.`
2315
2702
  };
2316
2703
  const carrierId = deriveCarrierId(source);
2317
2704
  try {
@@ -2328,6 +2715,7 @@ function materializeCarrier(projectPath, browser) {
2328
2715
  carrierVersion: manifest.version ?? "unknown",
2329
2716
  note: "Safe to delete; extension_dev recreates it when carrier: true. extension_stop and extension_build remove it for you."
2330
2717
  }, null, 2)}\n`);
2718
+ rememberCarrier(projectPath);
2331
2719
  const ignored = ensureCarrierIgnored(projectPath);
2332
2720
  return {
2333
2721
  loaded: true,
@@ -2335,7 +2723,7 @@ function materializeCarrier(projectPath, browser) {
2335
2723
  ...ignored ? {
2336
2724
  gitignored: ignored
2337
2725
  } : {},
2338
- note: "Live-preview carrier placed in ./extensions; Extension.js loads it as a companion beside your extension. Open https://preview.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.` : "."),
2726
+ note: "Live-preview carrier placed in ./extensions; Extension.js loads it as a companion beside your extension. Open https://preview.extension.dev/ in the dev browser, load a build from this machine, and switch the lane toggle to Real 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.` : "."),
2339
2727
  limitations: [
2340
2728
  "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.",
2341
2729
  "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.",
@@ -2345,7 +2733,7 @@ function materializeCarrier(projectPath, browser) {
2345
2733
  ...carrierId ? {
2346
2734
  bridgeProtocol: {
2347
2735
  carrierExtensionId: carrierId,
2348
- allowedOrigins: "https://preview.extension.dev, https://code.extension.dev, https://themes.extension.dev, http://localhost/*, http://127.0.0.1/*",
2736
+ allowedOrigins: "https://preview.extension.dev, https://code.extension.dev, https://themes.extension.dev, and those same apps' dev servers on http://localhost and http://127.0.0.1 (ports 3103, 3104, 3110, 3111). The carrier checks the sender's origin, so a page on any other localhost port is refused.",
2349
2737
  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.",
2350
2738
  example: [
2351
2739
  `const id = '${carrierId}'`,
@@ -2364,16 +2752,115 @@ function materializeCarrier(projectPath, browser) {
2364
2752
  };
2365
2753
  }
2366
2754
  }
2755
+ const EOCD_SIGNATURE = 0x06054b50;
2756
+ const CENTRAL_SIGNATURE = 0x02014b50;
2757
+ const EOCD_MIN_LENGTH = 22;
2758
+ const MAX_COMMENT_LENGTH = 0xffff;
2759
+ const MAX_DIRECTORY_BYTES = 33554432;
2760
+ const UNREADABLE = {
2761
+ names: [],
2762
+ readable: false
2763
+ };
2764
+ function readZipEntryNames(zipPath) {
2765
+ let handle = null;
2766
+ try {
2767
+ const size = node_fs.statSync(zipPath).size;
2768
+ if (size < EOCD_MIN_LENGTH) return UNREADABLE;
2769
+ handle = node_fs.openSync(zipPath, "r");
2770
+ const tailLength = Math.min(size, MAX_COMMENT_LENGTH + EOCD_MIN_LENGTH);
2771
+ const tail = Buffer.alloc(tailLength);
2772
+ node_fs.readSync(handle, tail, 0, tailLength, size - tailLength);
2773
+ let eocd = -1;
2774
+ for(let at = tail.length - EOCD_MIN_LENGTH; at >= 0; at -= 1)if (tail.readUInt32LE(at) === EOCD_SIGNATURE) {
2775
+ eocd = at;
2776
+ break;
2777
+ }
2778
+ if (eocd < 0) return UNREADABLE;
2779
+ const expected = tail.readUInt16LE(eocd + 10);
2780
+ const directoryBytes = tail.readUInt32LE(eocd + 12);
2781
+ const directoryOffset = tail.readUInt32LE(eocd + 16);
2782
+ if (0xffff === expected || 0xffffffff === directoryBytes || 0xffffffff === directoryOffset || directoryBytes > MAX_DIRECTORY_BYTES || directoryOffset + directoryBytes > size) return UNREADABLE;
2783
+ if (0 === directoryBytes) return {
2784
+ names: [],
2785
+ readable: 0 === expected
2786
+ };
2787
+ const directory = Buffer.alloc(directoryBytes);
2788
+ node_fs.readSync(handle, directory, 0, directoryBytes, directoryOffset);
2789
+ const names = [];
2790
+ let at = 0;
2791
+ while(at + 46 <= directory.length){
2792
+ if (directory.readUInt32LE(at) !== CENTRAL_SIGNATURE) return UNREADABLE;
2793
+ const nameLength = directory.readUInt16LE(at + 28);
2794
+ const extraLength = directory.readUInt16LE(at + 30);
2795
+ const commentLength = directory.readUInt16LE(at + 32);
2796
+ const nameStart = at + 46;
2797
+ if (nameStart + nameLength > directory.length) return UNREADABLE;
2798
+ names.push(directory.subarray(nameStart, nameStart + nameLength).toString("utf8"));
2799
+ at = nameStart + nameLength + extraLength + commentLength;
2800
+ }
2801
+ return {
2802
+ names,
2803
+ readable: names.length === expected
2804
+ };
2805
+ } catch {
2806
+ return UNREADABLE;
2807
+ } finally{
2808
+ if (null !== handle) try {
2809
+ node_fs.closeSync(handle);
2810
+ } catch {}
2811
+ }
2812
+ }
2367
2813
  const build_COMMAND = "extension_build";
2368
2814
  function readBuildSummary(projectPath, browser, since) {
2369
- const file = node_path.resolve(projectPath, "dist", "extension-js", browser, "build-summary.json");
2815
+ const file = buildSummaryPath(projectPath, browser);
2370
2816
  try {
2371
2817
  const stat = node_fs.statSync(file);
2372
- if (stat.mtimeMs < since) return null;
2373
- const summary = JSON.parse(node_fs.readFileSync(file, "utf8"));
2374
- if (summary && "object" == typeof summary) return summary;
2818
+ if (stat.mtimeMs >= since) {
2819
+ const summary = JSON.parse(node_fs.readFileSync(file, "utf8"));
2820
+ if (summary && "object" == typeof summary) return {
2821
+ file,
2822
+ summary
2823
+ };
2824
+ }
2375
2825
  } catch {}
2376
- return null;
2826
+ return {
2827
+ file,
2828
+ summary: null
2829
+ };
2830
+ }
2831
+ function readEngineOutput(stdout, stderr) {
2832
+ let frame = null;
2833
+ const rest = [];
2834
+ for (const line of stdout.split("\n")){
2835
+ const text = line.trim();
2836
+ if (!frame && text.startsWith("{")) {
2837
+ let parsed = null;
2838
+ try {
2839
+ parsed = JSON.parse(text);
2840
+ } catch {}
2841
+ if (isEnvelope(parsed)) {
2842
+ frame = parsed;
2843
+ continue;
2844
+ }
2845
+ }
2846
+ rest.push(line);
2847
+ }
2848
+ const narration = [
2849
+ rest.join("\n").trim(),
2850
+ stderr.trim()
2851
+ ].filter(Boolean).join("\n");
2852
+ return {
2853
+ frame,
2854
+ narration
2855
+ };
2856
+ }
2857
+ function engineSummaries(frame) {
2858
+ const value = frame?.value;
2859
+ if (!value || !Array.isArray(value.summaries)) return [];
2860
+ return value.summaries.filter((entry)=>Boolean(entry) && "object" == typeof entry);
2861
+ }
2862
+ function summaryForBrowser(summaries, browser) {
2863
+ return summaries.find((entry)=>entry.browser === browser) ?? summaries[0] ?? null;
2377
2864
  }
2378
2865
  function builtEntrypoints(distDir) {
2379
2866
  let manifest;
@@ -2462,7 +2949,7 @@ function locateSourceZip(projectPath, browser, since) {
2462
2949
  }
2463
2950
  const build_schema = {
2464
2951
  name: "extension_build",
2465
- description: "Build a browser extension for production. The output lands in dist/<browser>/. Pass zip:true to also package a .zip for store submission. The build refuses a manifest with build-blocking errors unless you pass skipValidation:true, because such a manifest yields a broken bundle the bundler itself never flags.",
2952
+ description: "Build a browser extension for production. The output lands in dist/<browser>/. Pass zip:true to also package a .zip for store submission. With browser:'safari' the build converts the extension into a macOS app through Xcode, and bundleId sets the identifier it ships under. The build refuses a manifest with build-blocking errors unless you pass skipValidation:true, because such a manifest yields a broken bundle the bundler itself never flags.",
2466
2953
  inputSchema: {
2467
2954
  type: "object",
2468
2955
  properties: {
@@ -2506,6 +2993,24 @@ const build_schema = {
2506
2993
  type: "boolean",
2507
2994
  default: false,
2508
2995
  description: "Build even when extension_manifest_validate reports build-blocking errors. The build normally refuses: a manifest error yields a broken bundle the bundler itself never flags."
2996
+ },
2997
+ appName: {
2998
+ type: "string",
2999
+ description: "Safari targets only: name of the generated macOS app, which also names the Xcode scheme and the .app on disk. Defaults to the manifest name."
3000
+ },
3001
+ bundleId: {
3002
+ type: "string",
3003
+ description: "Safari targets only: a reverse-DNS bundle identifier you own, such as com.acme.readinglist. Without one the app is packaged under a generated dev.extensionjs.* identifier derived from the app name, which every project built from the same template shares, and the first team to register it takes it."
3004
+ },
3005
+ macOsOnly: {
3006
+ type: "boolean",
3007
+ default: true,
3008
+ description: "Safari targets only: generate a macOS-only Xcode project. Pass false for a universal project that also targets iOS and iPadOS, which is what you want if the extension ships on iPhone or iPad."
3009
+ },
3010
+ forceRegenerate: {
3011
+ type: "boolean",
3012
+ default: false,
3013
+ description: "Safari targets only: regenerate the Xcode project even when the engine considers it up to date. Use it when an earlier packaging run left the project broken."
2509
3014
  }
2510
3015
  },
2511
3016
  required: [
@@ -2513,6 +3018,11 @@ const build_schema = {
2513
3018
  ]
2514
3019
  }
2515
3020
  };
3021
+ const SAFARI_VENDORS = new Set([
3022
+ "safari",
3023
+ "webkit-based"
3024
+ ]);
3025
+ const BUNDLE_ID_PATTERN = /^[A-Za-z][A-Za-z0-9-]*(\.[A-Za-z][A-Za-z0-9-]*)+$/;
2516
3026
  function manifestDivergence(projectPath, browser) {
2517
3027
  const read = (p)=>{
2518
3028
  try {
@@ -2540,23 +3050,58 @@ function manifestDivergence(projectPath, browser) {
2540
3050
  return notes;
2541
3051
  }
2542
3052
  const MARKER_FILE_NAME = "managed-by-extension-dev-mcp.json";
2543
- function carrierEntriesInDist(distDir, depth = 0) {
2544
- if (depth > 3) return [];
3053
+ function namesCarrier(entryName) {
3054
+ return entryName === CARRIER_DIR_NAME || entryName === MARKER_FILE_NAME;
3055
+ }
3056
+ function carrierEntriesInZip(zipPath) {
3057
+ const listing = readZipEntryNames(zipPath);
3058
+ if (!listing.readable) return {
3059
+ paths: [],
3060
+ unchecked: [
3061
+ zipPath
3062
+ ]
3063
+ };
3064
+ const hits = listing.names.filter((name)=>name.split("/").some(namesCarrier)).map((name)=>`${zipPath} -> ${name}`);
3065
+ return {
3066
+ paths: hits,
3067
+ unchecked: []
3068
+ };
3069
+ }
3070
+ function carrierContamination(dir, depth = 0) {
3071
+ if (depth > 4) return {
3072
+ paths: [],
3073
+ unchecked: []
3074
+ };
2545
3075
  let entries;
2546
3076
  try {
2547
- entries = node_fs.readdirSync(distDir, {
3077
+ entries = node_fs.readdirSync(dir, {
2548
3078
  withFileTypes: true
2549
3079
  });
2550
3080
  } catch {
2551
- return [];
3081
+ return {
3082
+ paths: [],
3083
+ unchecked: []
3084
+ };
2552
3085
  }
2553
- const found = [];
3086
+ const found = {
3087
+ paths: [],
3088
+ unchecked: []
3089
+ };
3090
+ const absorb = (other)=>{
3091
+ found.paths.push(...other.paths);
3092
+ found.unchecked.push(...other.unchecked);
3093
+ };
2554
3094
  for (const entry of entries){
2555
- if (entry.name === CARRIER_DIR_NAME || entry.name === MARKER_FILE_NAME) {
2556
- found.push(node_path.join(distDir, entry.name));
3095
+ const full = node_path.join(dir, entry.name);
3096
+ if (namesCarrier(entry.name)) {
3097
+ found.paths.push(full);
2557
3098
  continue;
2558
3099
  }
2559
- if (entry.isDirectory()) found.push(...carrierEntriesInDist(node_path.join(distDir, entry.name), depth + 1));
3100
+ if (entry.isDirectory()) {
3101
+ absorb(carrierContamination(full, depth + 1));
3102
+ continue;
3103
+ }
3104
+ if (entry.isFile() && entry.name.endsWith(".zip")) absorb(carrierEntriesInZip(full));
2560
3105
  }
2561
3106
  return found;
2562
3107
  }
@@ -2585,12 +3130,61 @@ async function validationPreflight(projectPath, browser) {
2585
3130
  async function build_handler(args) {
2586
3131
  const start = Date.now();
2587
3132
  const browser = args.browser ?? "chrome";
3133
+ const safari = SAFARI_VENDORS.has(browser);
3134
+ const safariOnly = [
3135
+ [
3136
+ "appName",
3137
+ args.appName
3138
+ ],
3139
+ [
3140
+ "bundleId",
3141
+ args.bundleId
3142
+ ],
3143
+ [
3144
+ "macOsOnly",
3145
+ args.macOsOnly
3146
+ ],
3147
+ [
3148
+ "forceRegenerate",
3149
+ true === args.forceRegenerate ? true : void 0
3150
+ ]
3151
+ ].filter(([, value])=>void 0 !== value).map(([name])=>name);
3152
+ if (safariOnly.length > 0 && !safari) return envelope_envelope({
3153
+ ok: false,
3154
+ command: build_COMMAND,
3155
+ status: "safari-only-option",
3156
+ error: {
3157
+ code: "E_SAFARI_ONLY_OPTION",
3158
+ message: `${safariOnly.join(", ")} configure the Safari web-extension conversion and mean nothing for a ${browser} build. Nothing was built, so the options were not silently ignored.`
3159
+ },
3160
+ value: {
3161
+ browser,
3162
+ options: safariOnly,
3163
+ duration: Date.now() - start
3164
+ },
3165
+ hint: `Pass browser: "safari" to package a Safari app, or drop these options to build for ${browser}.`
3166
+ });
3167
+ if (void 0 !== args.bundleId && !BUNDLE_ID_PATTERN.test(args.bundleId)) return envelope_envelope({
3168
+ ok: false,
3169
+ command: build_COMMAND,
3170
+ status: "invalid-bundle-id",
3171
+ error: {
3172
+ code: "E_INVALID_BUNDLE_ID",
3173
+ message: `bundleId ${JSON.stringify(args.bundleId)} is not a reverse-DNS identifier, so Xcode would reject it. Expected two or more dot-separated segments of letters, digits and hyphens, each starting with a letter.`
3174
+ },
3175
+ value: {
3176
+ browser,
3177
+ bundleId: args.bundleId,
3178
+ duration: Date.now() - start
3179
+ },
3180
+ hint: 'Use an identifier under a domain you own, for example "com.acme.readinglist".'
3181
+ });
2588
3182
  const carrierCleanup = removeCarrier(args.projectPath);
2589
3183
  const carrierNotes = [];
2590
3184
  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.");
2591
3185
  else if (carrierCleanup.note) carrierNotes.push(carrierCleanup.note);
2592
3186
  const preflight = args.skipValidation ? null : await validationPreflight(args.projectPath, browser);
2593
- if (preflight?.buildBlocking) return envelope({
3187
+ if (preflight?.buildBlocking) return envelope_envelope({
2594
3188
  ok: false,
2595
3189
  command: build_COMMAND,
2596
3190
  status: "manifest-blocked",
@@ -2623,40 +3217,63 @@ async function build_handler(args) {
2623
3217
  if (args.polyfill) cliArgs.push("--polyfill");
2624
3218
  if (args.silent) cliArgs.push("--silent");
2625
3219
  if (args.mode) cliArgs.push("--mode", args.mode);
2626
- const { code, stdout, stderr } = await runExtensionCli(cliArgs, {
3220
+ if (args.appName) cliArgs.push("--app-name", args.appName);
3221
+ if (args.bundleId) cliArgs.push("--bundle-id", args.bundleId);
3222
+ if (void 0 !== args.macOsOnly) cliArgs.push("--macos-only", String(args.macOsOnly));
3223
+ if (args.forceRegenerate) cliArgs.push("--force-regenerate");
3224
+ const spawn = {
2627
3225
  cwd: args.projectPath,
2628
3226
  timeoutMs: 180000
2629
- });
3227
+ };
3228
+ const verdict = await outputJsonVerdict("build", args.projectPath);
3229
+ const engineKnownTooOld = false === verdict.supported;
3230
+ let attempt = engineKnownTooOld ? await runExtensionCli(cliArgs, spawn) : await runExtensionCli([
3231
+ ...cliArgs,
3232
+ "--output",
3233
+ "json"
3234
+ ], spawn);
3235
+ const engineRefusedJsonOutput = !engineKnownTooOld && 0 !== attempt.code && refusedTheOutputFlag(attempt.stderr ?? "");
3236
+ if (engineRefusedJsonOutput) attempt = await runExtensionCli(cliArgs, spawn);
3237
+ const { code, stdout, stderr } = attempt;
3238
+ if (engineRefusedJsonOutput) warnings.push("The Extension.js installed in this project is older than the one this server expects: it rejected --output json on build, so the build was run a second time without that flag and the result was read from the build summary the engine writes into dist/extension-js/. The extension that came out is exactly the same one. Upgrade the project's Extension.js to get the richer report back, including the Safari app identity and the byte totals from the run that just happened, and to stop paying for the second build.");
3239
+ else if (engineKnownTooOld) warnings.push(`The Extension.js installed in this project is older than the one this server expects: it reports ${verdict.version}, and --output json only reached extension build in ${verdict.floor}, so the build was run without that flag and the result was read from the build summary the engine writes into dist/extension-js/. The extension that came out is exactly the same one, and nothing was built twice. Upgrade the project's Extension.js to get the richer report back, including the Safari app identity and the byte totals from the run that just happened.`);
2630
3240
  const duration = Date.now() - start;
2631
- const out = (stdout ?? "").trim();
3241
+ const engine = readEngineOutput(stdout ?? "", stderr ?? "");
3242
+ const out = engine.narration;
2632
3243
  const lastLines = (text, n)=>text.split("\n").slice(-n).join("\n");
2633
3244
  if (0 === code) {
2634
- const size = out.match(/Size:\s*([\d.]+\s*[kKmMgG]?B)/)?.[1];
2635
- const status = out.match(/Build Status:\s*(\w+)/)?.[1];
2636
- const engineSummary = readBuildSummary(args.projectPath, browser, start);
3245
+ const inlineSummary = summaryForBrowser(engineSummaries(engine.frame), browser);
3246
+ const persisted = inlineSummary ? null : readBuildSummary(args.projectPath, browser, start);
3247
+ const engineSummary = inlineSummary ?? persisted?.summary ?? null;
3248
+ const summaryPathNote = persisted && !persisted.summary ? `This build reported no summary of its own, and no summary from this run was found on disk either, so the byte totals and the engine's structured warnings are missing from the result below. The extension that was built is unaffected. ${sessionPathHint(persisted.file)}` : null;
3249
+ const status = engine.frame?.status;
2637
3250
  const buildWarnings = engineSummary?.warnings?.length ? engineSummary.warnings : [];
2638
3251
  const buildWarningsTruncated = buildWarnings.length && "number" == typeof engineSummary?.warnings_count && engineSummary.warnings_count > buildWarnings.length ? engineSummary.warnings_count : void 0;
2639
3252
  const distDir = node_path.resolve(args.projectPath, "dist", browser);
2640
3253
  const entrypoints = builtEntrypoints(distDir);
2641
- const contamination = carrierEntriesInDist(distDir);
2642
- if (contamination.length) return envelope({
3254
+ const contamination = carrierContamination(node_path.resolve(args.projectPath, "dist"));
3255
+ const uncheckedNote = contamination.unchecked.length ? `Could not read the entry table of ${contamination.unchecked.join(", ")}, so those archives were not checked for the live-preview carrier. Unpack and check them yourself before submitting.` : null;
3256
+ if (contamination.paths.length) return envelope_envelope({
2643
3257
  ok: false,
2644
3258
  command: build_COMMAND,
2645
3259
  status: "carrier-in-dist",
2646
3260
  error: {
2647
3261
  code: "E_CARRIER_IN_DIST",
2648
- message: `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.`
3262
+ message: `The build output contains the Extension.dev live-preview carrier: ${contamination.paths.join(", ")}. That is a local debug companion and must never ship. This artifact is not safe to submit.`
2649
3263
  },
2650
3264
  value: {
2651
3265
  browser,
2652
3266
  buildExitCode: 0,
2653
3267
  duration
2654
3268
  },
2655
- warnings,
2656
- hint: "Delete the listed paths from dist and build again. The carrier normally lives in ./extensions and is removed before every build."
3269
+ warnings: [
3270
+ ...warnings,
3271
+ uncheckedNote
3272
+ ],
3273
+ hint: "Delete the listed paths from dist and build again. The carrier lives in ./extensions and is taken back before every build run through this tool, so an entry inside a zip means that archive was packed by something else, usually 'extension build --zip-source' driven straight at the engine while a dev session had the carrier in place."
2657
3274
  });
2658
3275
  const missing = entrypoints.filter((e)=>!e.present);
2659
- if (missing.length) return envelope({
3276
+ if (missing.length) return envelope_envelope({
2660
3277
  ok: false,
2661
3278
  command: build_COMMAND,
2662
3279
  status: "entrypoint-missing",
@@ -2684,18 +3301,36 @@ async function build_handler(args) {
2684
3301
  const zipSourcePath = args.zipSource ? locateSourceZip(args.projectPath, browser, start) : null;
2685
3302
  if (args.zipSource && !zipSourcePath) zipNotes.push("zipSource: true was requested and the build succeeded, but no *-source.zip file could be located in dist/. The engine may not have packaged it; check the build output below.");
2686
3303
  const divergence = manifestDivergence(args.projectPath, browser);
2687
- return envelope({
3304
+ const safariIdentity = safari ? engineSummary?.safari ?? null : null;
3305
+ const derivedBundleIdNote = safariIdentity?.bundleIdDerived === true ? `The Safari app was packaged under the generated bundle identifier ${safariIdentity.bundleId ?? "the engine derived for you"}, which the engine derived from your app name rather than one you chose. It is fine for running the app locally. Every project built from the same template derives the same identifier, and Apple binds one permanently to the first team that registers it, so whoever submits first takes it and everyone after is locked out. Rebuild with bundleId set to a reverse-DNS identifier under a domain you own, which regenerates the Xcode project, and do it before your first submission: afterwards a new identifier is a new extension carrying none of your users.` : null;
3306
+ const safariIdentityMissingNote = safari && !safariIdentity ? `The build succeeded but reported no Safari app identity, so this run cannot tell you which bundle identifier the app carries. Either the packager did not run (a non-macOS host, or Xcode missing, skips packaging and leaves a plain bundle in dist/${browser}), or the engine installed in this project predates the reporting contract. Check the build output below, and run extension_doctor if you expected an app.` : null;
3307
+ return envelope_envelope({
2688
3308
  ok: true,
2689
3309
  command: build_COMMAND,
2690
3310
  status: "built",
2691
3311
  value: {
2692
3312
  browser,
2693
- ...size ? {
2694
- size
3313
+ ...safariIdentity ? {
3314
+ safariApp: safariIdentity
3315
+ } : {},
3316
+ ..."string" == typeof engineSummary?.output_path ? {
3317
+ outputPath: engineSummary.output_path
3318
+ } : {},
3319
+ ..."number" == typeof engineSummary?.total_bytes ? {
3320
+ totalBytes: engineSummary.total_bytes
3321
+ } : {},
3322
+ ..."number" == typeof engineSummary?.total_assets ? {
3323
+ totalAssets: engineSummary.total_assets
3324
+ } : {},
3325
+ ..."number" == typeof engineSummary?.largest_asset_bytes ? {
3326
+ largestAssetBytes: engineSummary.largest_asset_bytes
2695
3327
  } : {},
2696
3328
  ...status ? {
2697
3329
  engineBuildStatus: status
2698
3330
  } : {},
3331
+ ...engineRefusedJsonOutput ? {
3332
+ engineRejectedJsonOutput: true
3333
+ } : {},
2699
3334
  ...entrypoints.length ? {
2700
3335
  entrypoints
2701
3336
  } : {},
@@ -2719,12 +3354,17 @@ async function build_handler(args) {
2719
3354
  ...warnings,
2720
3355
  ...preflight?.warnings ?? [],
2721
3356
  ...buildWarnings,
2722
- ...zipNotes
3357
+ ...zipNotes,
3358
+ uncheckedNote,
3359
+ derivedBundleIdNote,
3360
+ safariIdentityMissingNote,
3361
+ summaryPathNote
2723
3362
  ]
2724
3363
  });
2725
3364
  }
2726
- const message = stderr.trim() || out || `extension build exited with code ${code}`;
2727
- return envelope({
3365
+ const engineFailure = "string" == typeof engine.frame?.error?.message ? engine.frame.error.message.trim() : "";
3366
+ const message = engineFailure || stderr.trim() || out || `extension build exited with code ${code}`;
3367
+ return envelope_envelope({
2728
3368
  ok: false,
2729
3369
  command: build_COMMAND,
2730
3370
  status: "build-failed",
@@ -2737,7 +3377,7 @@ async function build_handler(args) {
2737
3377
  duration
2738
3378
  },
2739
3379
  warnings,
2740
- hint: "Check that the project has a valid src/manifest.json and its dependencies are installed (extension_dev auto-installs; build does not)."
3380
+ hint: "Check that the project has a valid src/manifest.json. Missing dependencies are installed by the build itself, so a failure here is usually the manifest, a compile error, or a Safari toolchain the host does not have."
2741
3381
  });
2742
3382
  }
2743
3383
  const MERGED_FD_CHATTER = [
@@ -2764,12 +3404,9 @@ function legacyProfileLockScrape(cleanOutput) {
2764
3404
  }
2765
3405
  const LEGACY_FIDELITY_WARNING = "This session ran a CLI that does not stamp the machine contract, so the boot verdict was read from the dev server's output instead of its ready contract. Diagnostics are less precise: compile errors come back as a text tail rather than a list. Upgrade the project's extension dependency to get the precise verdict.";
2766
3406
  const READY_STAMP_PROFILE_LOCKED_CODE = "profile_locked";
2767
- function readyContractPath(projectPath, browser) {
2768
- return node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json");
2769
- }
2770
3407
  function readContract(projectPath, browser, since) {
2771
3408
  try {
2772
- const file = readyContractPath(projectPath, browser);
3409
+ const file = bridge_readyContractPath(projectPath, browser);
2773
3410
  const stat = node_fs.statSync(file);
2774
3411
  const contract = JSON.parse(node_fs.readFileSync(file, "utf8"));
2775
3412
  if (!contract || "object" != typeof contract) return null;
@@ -2893,6 +3530,71 @@ async function pollBootVerdict(projectPath, browser, options) {
2893
3530
  warnings: []
2894
3531
  };
2895
3532
  }
3533
+ function sweepCarriers(projectPaths) {
3534
+ const out = [];
3535
+ const seen = new Set();
3536
+ for (const projectPath of projectPaths){
3537
+ const resolved = node_path.resolve(projectPath);
3538
+ if (!seen.has(resolved)) {
3539
+ seen.add(resolved);
3540
+ try {
3541
+ const removal = removeCarrier(resolved);
3542
+ if (!removal.removed && !removal.note) continue;
3543
+ out.push({
3544
+ projectPath: resolved,
3545
+ ...removal
3546
+ });
3547
+ } catch (error) {
3548
+ out.push({
3549
+ projectPath: resolved,
3550
+ removed: false,
3551
+ path: carrierPath(resolved),
3552
+ note: `Could not remove the carrier: ${error instanceof Error ? error.message : String(error)}`
3553
+ });
3554
+ }
3555
+ }
3556
+ }
3557
+ return out;
3558
+ }
3559
+ function sweepCarriersPlacedHere() {
3560
+ try {
3561
+ return sweepCarriers(carriersPlacedHere());
3562
+ } catch {
3563
+ return [];
3564
+ }
3565
+ }
3566
+ const EXIT_SIGNALS = [
3567
+ "SIGINT",
3568
+ "SIGTERM",
3569
+ "SIGHUP"
3570
+ ];
3571
+ let carrier_exit_installed = [];
3572
+ function installCarrierExitCleanup() {
3573
+ if (carrier_exit_installed.length) return;
3574
+ const onExit = ()=>{
3575
+ sweepCarriersPlacedHere();
3576
+ };
3577
+ process.on("exit", onExit);
3578
+ carrier_exit_installed.push({
3579
+ event: "exit",
3580
+ handler: onExit
3581
+ });
3582
+ for (const signal of EXIT_SIGNALS){
3583
+ const onSignal = ()=>{
3584
+ sweepCarriersPlacedHere();
3585
+ process.removeListener(signal, onSignal);
3586
+ carrier_exit_installed = carrier_exit_installed.filter((entry)=>entry.handler !== onSignal);
3587
+ if (0 === process.listenerCount(signal)) try {
3588
+ process.kill(process.pid, signal);
3589
+ } catch {}
3590
+ };
3591
+ process.on(signal, onSignal);
3592
+ carrier_exit_installed.push({
3593
+ event: signal,
3594
+ handler: onSignal
3595
+ });
3596
+ }
3597
+ }
2896
3598
  const stop_schema = {
2897
3599
  name: "extension_stop",
2898
3600
  description: "Stop a session that extension_dev or extension_start is running: terminate the server and the browser it launched, and remove the live-preview carrier if extension_dev placed one. This covers extension_start build:false too, which the registry records as a preview session. Call it when you are done verifying, so sessions do not accumulate.",
@@ -2907,7 +3609,7 @@ const stop_schema = {
2907
3609
  all: {
2908
3610
  type: "boolean",
2909
3611
  default: false,
2910
- description: "Stop every known session across projects and browsers, found from this server's registry AND the on-disk markers earlier runs left, so it still works after an MCP restart. projectPath/browser are then ignored."
3612
+ description: "Stop every known session across projects and browsers, found from this server's registry AND the on-disk markers earlier runs left, so it still works after an MCP restart. It also takes back every live-preview carrier still recorded on this machine, including one in a project whose session was never stopped. projectPath/browser are then ignored."
2911
3613
  }
2912
3614
  },
2913
3615
  required: []
@@ -2973,7 +3675,7 @@ function sessionProcessPids(projectPath) {
2973
3675
  const escaped = escapeRegex(form);
2974
3676
  const patterns = [
2975
3677
  `extension[^ ]* (dev|start|preview) ${escaped}`,
2976
- `${escaped}${escapeRegex(node_path.sep)}dist${escapeRegex(node_path.sep)}extension-profile-`
3678
+ `${escapeRegex(profilesRootDir(form))}${escapeRegex(node_path.sep)}`
2977
3679
  ];
2978
3680
  for (const pattern of patterns)for (const pid of pgrepPids(pattern))pids.add(pid);
2979
3681
  }
@@ -3009,12 +3711,9 @@ function stop_signal(pid, sig) {
3009
3711
  }
3010
3712
  }
3011
3713
  }
3012
- function readyJsonPath(projectPath, browser) {
3013
- return node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json");
3014
- }
3015
3714
  function pidFromReadyContract(projectPath, browser) {
3016
3715
  try {
3017
- const raw = node_fs.readFileSync(readyJsonPath(projectPath, browser), "utf8");
3716
+ const raw = node_fs.readFileSync(bridge_readyContractPath(projectPath, browser), "utf8");
3018
3717
  const contract = JSON.parse(raw);
3019
3718
  return "number" == typeof contract.pid ? contract.pid : null;
3020
3719
  } catch {
@@ -3051,7 +3750,7 @@ async function stopOne(projectPath, browser) {
3051
3750
  removeSession(projectPath, browser);
3052
3751
  removeSessionMarker(projectPath, browser);
3053
3752
  try {
3054
- node_fs.rmSync(readyJsonPath(projectPath, browser), {
3753
+ node_fs.rmSync(bridge_readyContractPath(projectPath, browser), {
3055
3754
  force: true
3056
3755
  });
3057
3756
  } catch {}
@@ -3077,28 +3776,36 @@ async function stop_handler(args) {
3077
3776
  const key = `${node_path.resolve(m.projectPath)}::${m.browser}`;
3078
3777
  if (!candidates.has(key)) candidates.set(key, m);
3079
3778
  }
3080
- if (0 === candidates.size) return envelope({
3779
+ const outcomes = [];
3780
+ for (const c of candidates.values())outcomes.push(await stopOne(c.projectPath, c.browser));
3781
+ const visited = new Set(outcomes.map((outcome)=>node_path.resolve(outcome.projectPath)));
3782
+ const carriers = sweepCarriers(rememberedCarriers().filter((p)=>!visited.has(node_path.resolve(p))));
3783
+ if (0 === candidates.size && 0 === carriers.length) return envelope_envelope({
3081
3784
  ok: true,
3082
3785
  command: stop_schema.name,
3083
3786
  status: "nothing-to-stop",
3084
3787
  value: {
3085
3788
  stopped: []
3086
3789
  },
3087
- hint: "No sessions registered in this server and no session markers on disk. Nothing to stop."
3790
+ hint: "No sessions registered in this server, no session markers on disk, and no carrier left in any project this machine recorded. Nothing to stop."
3088
3791
  });
3089
- const outcomes = [];
3090
- for (const c of candidates.values())outcomes.push(await stopOne(c.projectPath, c.browser));
3091
- return envelope({
3792
+ return envelope_envelope({
3092
3793
  ok: outcomes.every((o)=>o.stopped),
3093
3794
  command: stop_schema.name,
3094
3795
  status: "stopped-all",
3095
3796
  value: {
3096
- stopped: outcomes
3797
+ stopped: outcomes,
3798
+ ...carriers.length ? {
3799
+ carriersSwept: carriers
3800
+ } : {}
3097
3801
  },
3098
- warnings: outcomes.map((o)=>o.stopped ? null : o.detail)
3802
+ warnings: [
3803
+ ...outcomes.map((o)=>o.stopped ? null : o.detail),
3804
+ ...carriers.map((c)=>c.removed ? `Took the live-preview carrier back out of ${c.projectPath}, which had no session left to stop it.` : `Left the carrier in ${c.projectPath} alone: ${c.note ?? "unknown reason"}`)
3805
+ ]
3099
3806
  });
3100
3807
  }
3101
- if (!args.projectPath) return envelope({
3808
+ if (!args.projectPath) return envelope_envelope({
3102
3809
  ok: false,
3103
3810
  command: stop_schema.name,
3104
3811
  status: "bad-request",
@@ -3109,7 +3816,7 @@ async function stop_handler(args) {
3109
3816
  });
3110
3817
  const { browser } = resolveSessionBrowser(args.projectPath, args.browser);
3111
3818
  const outcome = await stopOne(args.projectPath, browser);
3112
- return envelope({
3819
+ return envelope_envelope({
3113
3820
  ok: outcome.stopped,
3114
3821
  command: stop_schema.name,
3115
3822
  status: null === outcome.pid ? "not-found" : outcome.stopped ? "stopped" : "still-alive",
@@ -3219,7 +3926,7 @@ async function dev_handler(args) {
3219
3926
  if (existing.length > 0) {
3220
3927
  if (!args.replace) {
3221
3928
  const listed = existing.map((s)=>`pid ${s.pid} (${s.browser})`).join(", ");
3222
- return envelope({
3929
+ return envelope_envelope({
3223
3930
  ok: false,
3224
3931
  command: dev_schema.name,
3225
3932
  status: "session-exists",
@@ -3277,6 +3984,9 @@ async function dev_handler(args) {
3277
3984
  child.on("exit", ()=>{
3278
3985
  removeSession(args.projectPath, browser, pid);
3279
3986
  removeSessionMarker(args.projectPath, browser, pid);
3987
+ if (carrier) try {
3988
+ removeCarrier(args.projectPath);
3989
+ } catch {}
3280
3990
  });
3281
3991
  const boot = await pollBootVerdict(args.projectPath, browser, {
3282
3992
  child,
@@ -3294,7 +4004,7 @@ async function dev_handler(args) {
3294
4004
  };
3295
4005
  if ("exited" === boot.verdict.kind) {
3296
4006
  const { exitCode: code, signal } = boot.verdict;
3297
- return envelope({
4007
+ return envelope_envelope({
3298
4008
  ok: false,
3299
4009
  command: dev_schema.name,
3300
4010
  status: "exited",
@@ -3314,7 +4024,7 @@ async function dev_handler(args) {
3314
4024
  }
3315
4025
  if ("compile-failed" === boot.verdict.kind) {
3316
4026
  const { compileErrors } = boot.verdict;
3317
- return envelope({
4027
+ return envelope_envelope({
3318
4028
  ok: false,
3319
4029
  command: dev_schema.name,
3320
4030
  status: "compile-failed",
@@ -3333,10 +4043,16 @@ async function dev_handler(args) {
3333
4043
  warnings: boot.warnings
3334
4044
  });
3335
4045
  }
3336
- const profileDir = node_path.join(args.projectPath, "dist", `extension-profile-${browser}`);
4046
+ const usesManagedProfile = "string" != typeof args.profile || 0 === args.profile.trim().length;
4047
+ const profileRootDir = usesManagedProfile ? browserProfileRootDir(args.projectPath, browser) : null;
4048
+ const profileAdvice = profileRemediation({
4049
+ projectPath: args.projectPath,
4050
+ browser,
4051
+ profile: args.profile
4052
+ });
3337
4053
  if ("profile-locked" === boot.verdict.kind) {
3338
4054
  const { owner, lockedAt } = boot.verdict;
3339
- return envelope({
4055
+ return envelope_envelope({
3340
4056
  ok: false,
3341
4057
  command: dev_schema.name,
3342
4058
  status: "profile-locked",
@@ -3346,18 +4062,20 @@ async function dev_handler(args) {
3346
4062
  },
3347
4063
  value: {
3348
4064
  ...session,
3349
- profileDir,
4065
+ ...profileRootDir ? {
4066
+ profileRootDir
4067
+ } : {},
3350
4068
  owner,
3351
4069
  ...lockedAt ? {
3352
4070
  lockedAt
3353
4071
  } : {},
3354
4072
  output: cleanOutput.slice(0, 2000)
3355
4073
  },
3356
- 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.`,
4074
+ 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, clear it by hand before retrying. ${profileAdvice}`,
3357
4075
  warnings: boot.warnings
3358
4076
  });
3359
4077
  }
3360
- if ("browser-exited" === boot.verdict.kind) return envelope({
4078
+ if ("browser-exited" === boot.verdict.kind) return envelope_envelope({
3361
4079
  ok: false,
3362
4080
  command: dev_schema.name,
3363
4081
  status: "browser-exited",
@@ -3370,7 +4088,7 @@ async function dev_handler(args) {
3370
4088
  ...boot.verdict.stamp,
3371
4089
  output: cleanOutput.slice(0, 2000)
3372
4090
  },
3373
- 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.`,
4091
+ 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, clear it by hand before retrying. ${profileAdvice}`,
3374
4092
  warnings: boot.warnings
3375
4093
  });
3376
4094
  const controlVerbs = "storage, reload, open, dom_snapshot";
@@ -3397,7 +4115,7 @@ async function dev_handler(args) {
3397
4115
  requestedPort: args.port ?? 8080
3398
4116
  };
3399
4117
  const portNote = null !== boundPort ? void 0 !== args.port && args.port !== boundPort ? `Requested port ${args.port} was not available; the dev server bound ${boundPort} (read from the engine's ready.json contract, the same source extension_wait reports).` : null : "The engine has not stamped its ready.json contract yet, so the bound port is not known at response time (a taken port makes the server bind the next free one). extension_wait reports the bound port from that contract once it lands; requestedPort above is only what was asked for.";
3400
- return envelope({
4118
+ return envelope_envelope({
3401
4119
  ok: true,
3402
4120
  command: dev_schema.name,
3403
4121
  status: "started",
@@ -3473,6 +4191,7 @@ async function start_handler(args) {
3473
4191
  if (void 0 !== args.port) cliArgs.push("--port", String(args.port));
3474
4192
  if (args.noBrowser) cliArgs.push("--no-browser");
3475
4193
  cliArgs.push(...launchFlagArgs(args));
4194
+ const stale = removeCarrier(args.projectPath);
3476
4195
  const spawnedAt = Date.now();
3477
4196
  const spawned = spawnExtensionCli(cliArgs, {
3478
4197
  projectDir: args.projectPath
@@ -3506,7 +4225,7 @@ async function start_handler(args) {
3506
4225
  };
3507
4226
  if ("exited" === boot.verdict.kind) {
3508
4227
  const { exitCode: code, signal } = boot.verdict;
3509
- return envelope({
4228
+ return envelope_envelope({
3510
4229
  ok: false,
3511
4230
  command: start_schema.name,
3512
4231
  status: "exited",
@@ -3526,7 +4245,7 @@ async function start_handler(args) {
3526
4245
  }
3527
4246
  if ("compile-failed" === boot.verdict.kind) {
3528
4247
  const { compileErrors } = boot.verdict;
3529
- return envelope({
4248
+ return envelope_envelope({
3530
4249
  ok: false,
3531
4250
  command: start_schema.name,
3532
4251
  status: "compile-failed",
@@ -3547,7 +4266,7 @@ async function start_handler(args) {
3547
4266
  }
3548
4267
  if ("browser-exited" === boot.verdict.kind || "profile-locked" === boot.verdict.kind) {
3549
4268
  const stamp = "browser-exited" === boot.verdict.kind ? boot.verdict.stamp : {};
3550
- return envelope({
4269
+ return envelope_envelope({
3551
4270
  ok: false,
3552
4271
  command: start_schema.name,
3553
4272
  status: boot.verdict.kind,
@@ -3564,7 +4283,7 @@ async function start_handler(args) {
3564
4283
  warnings: boot.warnings
3565
4284
  });
3566
4285
  }
3567
- return envelope({
4286
+ return envelope_envelope({
3568
4287
  ok: true,
3569
4288
  command: start_schema.name,
3570
4289
  status: building ? "started" : "launched",
@@ -3575,7 +4294,10 @@ async function start_handler(args) {
3575
4294
  logPath
3576
4295
  },
3577
4296
  hint: building ? "Use extension_wait to check when the build and browser launch are complete. When you are done, call extension_stop to shut down the session." : "Call extension_stop when you are done to close the preview browser.",
3578
- warnings: boot.warnings
4297
+ warnings: [
4298
+ ...boot.warnings,
4299
+ stale.removed && "Removed a Live Preview carrier left behind by an earlier dev session, so it was not loaded beside your extension here."
4300
+ ]
3579
4301
  });
3580
4302
  }
3581
4303
  function toMcpSpeak(text) {
@@ -3685,8 +4407,19 @@ async function runActVerb(args, projectPath, timeoutMs, tool) {
3685
4407
  }
3686
4408
  if (frame && "object" == typeof frame) return JSON.stringify(wrapLegacyFrame(frame, command));
3687
4409
  } catch {}
4410
+ if (refusedTheOutputFlag(stderr ?? "")) return envelope_envelope({
4411
+ ok: false,
4412
+ command,
4413
+ status: "engine-too-old",
4414
+ error: {
4415
+ code: "E_ENGINE_TOO_OLD",
4416
+ name: "CliError",
4417
+ message: await outputFlagRefusalMessage("act", args[0], projectPath)
4418
+ },
4419
+ hint: "extension_doctor reports the project's engine version next to the one this server pins."
4420
+ });
3688
4421
  const message = stderr.trim() || `extension exited with code ${code}`;
3689
- return envelope({
4422
+ return envelope_envelope({
3690
4423
  ok: false,
3691
4424
  command,
3692
4425
  status: "cli-failed",
@@ -4140,7 +4873,7 @@ class CDPClient extends CDPConnection {
4140
4873
  async function resolveContractPort(projectPath, browser, field, options) {
4141
4874
  const waitMs = options?.waitMs ?? 20000;
4142
4875
  const graceMs = options?.graceMs ?? 2500;
4143
- const readyPath = node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json");
4876
+ const readyPath = bridge_readyContractPath(projectPath, browser);
4144
4877
  const deadline = Date.now() + waitMs;
4145
4878
  let contractSeen = false;
4146
4879
  let contractSeenAt = null;
@@ -4209,6 +4942,10 @@ const ENGINE_COMPANION_IDS = new Set([
4209
4942
  CARRIER_EXTENSION_ID
4210
4943
  ]);
4211
4944
  const EXTENSION_URL = /^chrome-extension:\/\/([a-p]{32})\//i;
4945
+ function isEngineCompanionUrl(url) {
4946
+ const match = EXTENSION_URL.exec(String(url ?? ""));
4947
+ return match ? ENGINE_COMPANION_IDS.has(match[1].toLowerCase()) : false;
4948
+ }
4212
4949
  async function verifyGuestLoaded(projectPath, browser, options) {
4213
4950
  let cdpPort;
4214
4951
  try {
@@ -4339,7 +5076,7 @@ async function navigateToUrlViaBridge(projectPath, browser, url, timeout, tool =
4339
5076
  return raw;
4340
5077
  }
4341
5078
  const settled = await pollForBridgeTab(projectPath, browser, url, null != timeout ? Math.min(timeout, 6000) : 6000);
4342
- if (!settled) return envelope({
5079
+ if (!settled) return envelope_envelope({
4343
5080
  ok: false,
4344
5081
  command: tool,
4345
5082
  status: "navigate-failed",
@@ -4350,7 +5087,7 @@ async function navigateToUrlViaBridge(projectPath, browser, url, timeout, tool =
4350
5087
  },
4351
5088
  hint: "Confirm the URL, or discover open tabs with extension_dom_snapshot listTabs: true. For an extension page, the path must match the BUILT manifest."
4352
5089
  });
4353
- return envelope({
5090
+ return envelope_envelope({
4354
5091
  ok: true,
4355
5092
  command: tool,
4356
5093
  status: "navigated",
@@ -4422,7 +5159,7 @@ function isDisposableTab(tabUrl, destination) {
4422
5159
  async function navigateToUrl(projectPath, browser, url, timeout) {
4423
5160
  if (!isChromiumFamily(browser)) return navigateToUrlViaBridge(projectPath, browser, url, timeout);
4424
5161
  const resolved = await resolveCdpPort(projectPath, browser);
4425
- if (!resolved) return envelope({
5162
+ if (!resolved) return envelope_envelope({
4426
5163
  ok: false,
4427
5164
  command: open_schema.name,
4428
5165
  status: "no-session",
@@ -4458,7 +5195,7 @@ async function navigateToUrl(projectPath, browser, url, timeout) {
4458
5195
  const settled = await pollForTarget(resolved.port, url, 6000, navigatedTargetId);
4459
5196
  if (!settled) {
4460
5197
  const isExtensionPage = url.startsWith("chrome-extension://");
4461
- return envelope({
5198
+ return envelope_envelope({
4462
5199
  ok: false,
4463
5200
  command: open_schema.name,
4464
5201
  status: "navigate-failed",
@@ -4470,7 +5207,7 @@ async function navigateToUrl(projectPath, browser, url, timeout) {
4470
5207
  hint: isExtensionPage ? "Confirm the path exists in the built dist (extension_build / extension_analyze 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."
4471
5208
  });
4472
5209
  }
4473
- return envelope({
5210
+ return envelope_envelope({
4474
5211
  ok: true,
4475
5212
  command: open_schema.name,
4476
5213
  status: "navigated",
@@ -4494,7 +5231,7 @@ async function navigateToUrl(projectPath, browser, url, timeout) {
4494
5231
  hint: "Inspect it with extension_dom_snapshot or extension_inspect using url (context: 'page'), they resolve the tab themselves. `target.targetId` is a CDP target id, NOT a chrome.tabs id: do not pass it as `tab`. If you need a numeric tab id, call extension_dom_snapshot with listTabs: true."
4495
5232
  });
4496
5233
  } catch (e) {
4497
- return envelope({
5234
+ return envelope_envelope({
4498
5235
  ok: false,
4499
5236
  command: open_schema.name,
4500
5237
  status: "navigate-failed",
@@ -4569,7 +5306,7 @@ function declaredCommands(projectPath, browser) {
4569
5306
  }
4570
5307
  function readDistPath(projectPath, browser) {
4571
5308
  try {
4572
- const file = node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json");
5309
+ const file = bridge_readyContractPath(projectPath, browser);
4573
5310
  const contract = JSON.parse(node_fs.readFileSync(file, "utf8"));
4574
5311
  return "string" == typeof contract?.distPath ? contract.distPath : null;
4575
5312
  } catch {
@@ -4624,7 +5361,7 @@ function declaredSurfaces(projectPath, browser) {
4624
5361
  }
4625
5362
  function missingSurfaceError(projectPath, browser, surface, consequence) {
4626
5363
  const declared = declaredSurfaces(projectPath, browser);
4627
- if (null === declared) return envelope({
5364
+ if (null === declared) return envelope_envelope({
4628
5365
  ok: false,
4629
5366
  command: open_schema.name,
4630
5367
  status: "no-manifest",
@@ -4638,7 +5375,7 @@ function missingSurfaceError(projectPath, browser, surface, consequence) {
4638
5375
  const key = SURFACE_MANIFEST_KEYS[surface] ?? surface;
4639
5376
  const others = declared.filter((s)=>s !== surface);
4640
5377
  const nextVerb = "popup" === surface ? 'To exercise the toolbar button of a popup-less extension, call extension_open with surface: "action", which replays chrome.action.onClicked. To give the extension a popup, set action.default_popup in the manifest and rebuild.' : `To add one, set ${key} in the manifest and rebuild.`;
4641
- return envelope({
5378
+ return envelope_envelope({
4642
5379
  ok: false,
4643
5380
  command: open_schema.name,
4644
5381
  status: "no-surface",
@@ -4716,7 +5453,7 @@ async function openSurfaceAsTab(projectPath, browser, surface) {
4716
5453
  let extensionId = null;
4717
5454
  if (isChromiumFamily(browser)) {
4718
5455
  extensionId = await resolveExtensionId(projectPath, browser);
4719
- if (!extensionId) return envelope({
5456
+ if (!extensionId) return envelope_envelope({
4720
5457
  ok: false,
4721
5458
  command: open_schema.name,
4722
5459
  status: "no-extension-id",
@@ -4730,7 +5467,7 @@ async function openSurfaceAsTab(projectPath, browser, surface) {
4730
5467
  url = `chrome-extension://${extensionId}/${doc}`;
4731
5468
  } else {
4732
5469
  const base = await resolveBridgeBaseUrl(projectPath, browser);
4733
- if (!base) return envelope({
5470
+ if (!base) return envelope_envelope({
4734
5471
  ok: false,
4735
5472
  command: open_schema.name,
4736
5473
  status: "no-extension-id",
@@ -4793,7 +5530,7 @@ async function confirmSurfaceTarget(projectPath, browser, surface, raw) {
4793
5530
  });
4794
5531
  return actFrameJson(parsed);
4795
5532
  }
4796
- return envelope({
5533
+ return envelope_envelope({
4797
5534
  ok: false,
4798
5535
  command: open_schema.name,
4799
5536
  status: "surface-did-not-open",
@@ -4867,7 +5604,7 @@ async function open_handler(args) {
4867
5604
  "bookmarks"
4868
5605
  ];
4869
5606
  if (args.asTab && args.surface && AS_TAB_SURFACES.includes(args.surface)) return openSurfaceAsTab(args.projectPath, browser, args.surface);
4870
- if (!args.surface) return envelope({
5607
+ if (!args.surface) return envelope_envelope({
4871
5608
  ok: false,
4872
5609
  command: open_schema.name,
4873
5610
  status: "bad-request",
@@ -4879,7 +5616,7 @@ async function open_handler(args) {
4879
5616
  });
4880
5617
  if ("command" === args.surface) {
4881
5618
  const declared = declaredCommands(args.projectPath, browser);
4882
- if (declared && args.name && !declared.includes(args.name)) return envelope({
5619
+ if (declared && args.name && !declared.includes(args.name)) return envelope_envelope({
4883
5620
  ok: false,
4884
5621
  command: open_schema.name,
4885
5622
  status: "unknown-command",
@@ -4916,7 +5653,7 @@ async function open_handler(args) {
4916
5653
  const parsed = JSON.parse(raw);
4917
5654
  const msg = String(parsed?.error?.message ?? "");
4918
5655
  const code = "string" == typeof parsed?.error?.code ? parsed.error.code : "";
4919
- const refusedWindow = "E_NO_TARGET" === code || /active browser window|no active|headless|user gesture/i.test(msg);
5656
+ const refusedWindow = "E_TARGET_NOT_FOUND" === code || /active browser window|no active|headless|user gesture/i.test(msg);
4920
5657
  if (parsed?.ok === false && refusedWindow) {
4921
5658
  if (AS_TAB_SURFACES.includes(args.surface)) {
4922
5659
  const fallback = await openSurfaceAsTab(args.projectPath, browser, args.surface);
@@ -4950,7 +5687,7 @@ async function publish(options = {}) {
4950
5687
  }
4951
5688
  };
4952
5689
  const doFetch = options.fetchImpl ?? fetch;
4953
- const apiCheck = safeApiBase(resolveApiBase(options.api));
5690
+ const apiCheck = safeApiBase(resolveApiBase(options.api), options.api);
4954
5691
  if (!apiCheck.ok) return {
4955
5692
  ok: false,
4956
5693
  error: {
@@ -5003,15 +5740,207 @@ async function publish(options = {}) {
5003
5740
  data
5004
5741
  };
5005
5742
  }
5743
+ const ARTIFACT_ID = /^gen_(?:[0-9a-f]{32}|[0-9a-f]{64})$/;
5744
+ const ARTIFACT_ID_CANDIDATE = /gen_[0-9a-f]+/;
5745
+ function parseArtifactRef(input) {
5746
+ const raw = String(input ?? "").trim();
5747
+ if (!raw) return null;
5748
+ if (ARTIFACT_ID.test(raw)) return raw;
5749
+ let parsed = null;
5750
+ try {
5751
+ parsed = new URL(raw);
5752
+ } catch {
5753
+ parsed = null;
5754
+ }
5755
+ if (parsed) {
5756
+ const fromQuery = parsed.searchParams.get("preview");
5757
+ if (fromQuery && ARTIFACT_ID.test(fromQuery.trim())) return fromQuery.trim();
5758
+ const segments = parsed.pathname.split("/").filter(Boolean);
5759
+ for(let i = segments.length - 1; i >= 0; i -= 1){
5760
+ const raw = segments[i];
5761
+ if (!raw) continue;
5762
+ const segment = decodeURIComponent(raw);
5763
+ if (ARTIFACT_ID.test(segment)) return segment;
5764
+ }
5765
+ }
5766
+ const loose = ARTIFACT_ID_CANDIDATE.exec(raw);
5767
+ return loose && ARTIFACT_ID.test(loose[0]) ? loose[0] : null;
5768
+ }
5769
+ function wwwRevokeUrl(value) {
5770
+ let parsed;
5771
+ try {
5772
+ parsed = new URL(value);
5773
+ } catch {
5774
+ return value;
5775
+ }
5776
+ if ("extension.dev" !== parsed.hostname.toLowerCase()) return value;
5777
+ parsed.hostname = "www.extension.dev";
5778
+ return parsed.toString();
5779
+ }
5780
+ function authError(name) {
5781
+ return {
5782
+ ok: false,
5783
+ error: {
5784
+ name,
5785
+ message: "No token. Run extension_auth (action: login), or set EXTENSION_DEV_TOKEN (create one in the extension.dev dashboard)."
5786
+ }
5787
+ };
5788
+ }
5789
+ async function readBody(res) {
5790
+ const text = await res.text();
5791
+ try {
5792
+ return JSON.parse(text);
5793
+ } catch {
5794
+ return {
5795
+ message: text
5796
+ };
5797
+ }
5798
+ }
5799
+ async function listArtifacts(options = {}) {
5800
+ const token = options.token ?? resolveToken();
5801
+ if (!token) return authError("SharesAuthError");
5802
+ const apiCheck = safeApiBase(resolveApiBase(options.api), options.api);
5803
+ if (!apiCheck.ok) return {
5804
+ ok: false,
5805
+ error: {
5806
+ name: "SharesConfigError",
5807
+ message: apiCheck.message
5808
+ }
5809
+ };
5810
+ const url = new URL(`${apiCheck.base}/api/artifacts`);
5811
+ if (null != options.limit) url.searchParams.set("limit", String(options.limit));
5812
+ if (options.liveOnly) url.searchParams.set("status", "live");
5813
+ const doFetch = options.fetchImpl ?? fetch;
5814
+ let res;
5815
+ try {
5816
+ res = await doFetch(url.toString(), {
5817
+ headers: {
5818
+ authorization: `Bearer ${token}`,
5819
+ accept: "application/json",
5820
+ ...identityHeaders("extension_shares")
5821
+ }
5822
+ });
5823
+ } catch (err) {
5824
+ return {
5825
+ ok: false,
5826
+ error: {
5827
+ name: "SharesNetworkError",
5828
+ message: `Could not reach ${url.toString()}: ${err?.message || err}`
5829
+ }
5830
+ };
5831
+ }
5832
+ const data = await readBody(res);
5833
+ if (401 === res.status) return authError("SharesAuthError");
5834
+ if (!res.ok) return {
5835
+ ok: false,
5836
+ error: {
5837
+ name: "SharesListError",
5838
+ status: res.status,
5839
+ message: `Listing shares failed (${res.status}): ${data?.message || "unknown error"}`
5840
+ }
5841
+ };
5842
+ const artifacts = Array.isArray(data.artifacts) ? data.artifacts.map((artifact)=>artifact && "string" == typeof artifact.revokeUrl ? {
5843
+ ...artifact,
5844
+ revokeUrl: wwwRevokeUrl(artifact.revokeUrl)
5845
+ } : artifact) : [];
5846
+ const num = (value, fallback)=>"number" == typeof value && Number.isFinite(value) ? value : fallback;
5847
+ return {
5848
+ ok: true,
5849
+ data: {
5850
+ artifacts,
5851
+ count: num(data.count, artifacts.length),
5852
+ matched: num(data.matched, artifacts.length),
5853
+ limit: num(data.limit, artifacts.length),
5854
+ truncated: true === data.truncated,
5855
+ truncatedReported: "boolean" == typeof data.truncated,
5856
+ scanned: num(data.scanned, 0)
5857
+ }
5858
+ };
5859
+ }
5860
+ async function revokeArtifact(options) {
5861
+ const token = options.token ?? resolveToken();
5862
+ if (!token) return authError("SharesAuthError");
5863
+ const apiCheck = safeApiBase(resolveApiBase(options.api), options.api);
5864
+ if (!apiCheck.ok) return {
5865
+ ok: false,
5866
+ error: {
5867
+ name: "SharesConfigError",
5868
+ message: apiCheck.message
5869
+ }
5870
+ };
5871
+ const url = `${apiCheck.base}/api/artifacts/${encodeURIComponent(options.artifactId)}`;
5872
+ const doFetch = options.fetchImpl ?? fetch;
5873
+ let res;
5874
+ try {
5875
+ res = await doFetch(url, {
5876
+ method: "DELETE",
5877
+ headers: {
5878
+ authorization: `Bearer ${token}`,
5879
+ accept: "application/json",
5880
+ ...identityHeaders("extension_shares")
5881
+ }
5882
+ });
5883
+ } catch (err) {
5884
+ return {
5885
+ ok: false,
5886
+ error: {
5887
+ name: "SharesNetworkError",
5888
+ message: `Could not reach ${url}: ${err?.message || err}`
5889
+ }
5890
+ };
5891
+ }
5892
+ const data = await readBody(res);
5893
+ if (401 === res.status) return authError("SharesAuthError");
5894
+ if (404 === res.status) return {
5895
+ ok: false,
5896
+ error: {
5897
+ name: "SharesNotFoundError",
5898
+ status: 404,
5899
+ message: `The platform has no live share ${options.artifactId} for this token. It may already be revoked, already expired, owned by a different project than the one this token is scoped to, or a teammate's personal share, which belongs to that person alone and no project token can revoke.`
5900
+ }
5901
+ };
5902
+ if (!res.ok) return {
5903
+ ok: false,
5904
+ error: {
5905
+ name: "SharesRevokeError",
5906
+ status: res.status,
5907
+ message: `Revoking ${options.artifactId} failed (${res.status}): ${data?.message || "unknown error"}`
5908
+ }
5909
+ };
5910
+ return {
5911
+ ok: true,
5912
+ data: {
5913
+ artifactId: options.artifactId,
5914
+ revoked: true === data.revoked,
5915
+ ..."string" == typeof data.revokedAt ? {
5916
+ revokedAt: data.revokedAt
5917
+ } : {}
5918
+ }
5919
+ };
5920
+ }
5006
5921
  const IGNORED_SEGMENTS = new Set([
5007
5922
  "node_modules",
5008
5923
  ".git",
5009
5924
  ".DS_Store",
5010
5925
  "__MACOSX"
5011
5926
  ]);
5927
+ const IGNORED_FILES = /^\.env(\..*)?$|\.(pem|key|p12|pfx|keystore)$/i;
5012
5928
  const TEXTUAL = /\.(json|js|mjs|cjs|ts|tsx|jsx|html|htm|css|svg|txt|md|map)$/i;
5013
5929
  const MAX_FILES = 2000;
5014
5930
  const MAX_CONTENT_CHARS = 67108864;
5931
+ function encodeFile(relativePath, bytes) {
5932
+ if (TEXTUAL.test(relativePath)) {
5933
+ const text = bytes.toString("utf8");
5934
+ if (Buffer.from(text, "utf8").equals(bytes)) return {
5935
+ content: text,
5936
+ encoding: "utf8"
5937
+ };
5938
+ }
5939
+ return {
5940
+ content: bytes.toString("base64"),
5941
+ encoding: "base64"
5942
+ };
5943
+ }
5015
5944
  function collectDistFiles(distDir) {
5016
5945
  const files = [];
5017
5946
  const walk = (dir)=>{
@@ -5026,12 +5955,13 @@ function collectDistFiles(distDir) {
5026
5955
  continue;
5027
5956
  }
5028
5957
  if (!entry.isFile()) continue;
5958
+ if (IGNORED_FILES.test(entry.name)) continue;
5029
5959
  const relative = node_path.relative(distDir, absolute).split(node_path.sep).join("/");
5030
5960
  const bytes = node_fs.readFileSync(absolute);
5031
- const encoding = TEXTUAL.test(relative) ? "utf8" : "base64";
5961
+ const { content, encoding } = encodeFile(relative, bytes);
5032
5962
  files.push({
5033
5963
  path: relative,
5034
- content: bytes.toString(encoding),
5964
+ content,
5035
5965
  encoding
5036
5966
  });
5037
5967
  }
@@ -5048,7 +5978,7 @@ async function uploadPreview(options) {
5048
5978
  message: "No token. Run extension_auth (action: login), or set EXTENSION_DEV_TOKEN (create one in the extension.dev dashboard)."
5049
5979
  }
5050
5980
  };
5051
- const apiCheck = safeApiBase(resolveApiBase(options.api));
5981
+ const apiCheck = safeApiBase(resolveApiBase(options.api), options.api);
5052
5982
  if (!apiCheck.ok) return {
5053
5983
  ok: false,
5054
5984
  error: {
@@ -5083,13 +6013,17 @@ async function uploadPreview(options) {
5083
6013
  }
5084
6014
  };
5085
6015
  const totalChars = files.reduce((sum, file)=>sum + file.content.length, 0);
5086
- if (totalChars > MAX_CONTENT_CHARS) return {
5087
- ok: false,
5088
- error: {
5089
- name: "PreviewTooLargeError",
5090
- message: `${options.distDir} is too large to share (over ${Math.floor(MAX_CONTENT_CHARS / 1048576)}MB). Build without source maps, or trim the bundled assets.`
5091
- }
5092
- };
6016
+ if (totalChars > MAX_CONTENT_CHARS) {
6017
+ const encodedBytes = files.reduce((sum, file)=>sum + Buffer.byteLength(file.content, file.encoding), 0);
6018
+ const mb = (value)=>(value / 1048576).toFixed(1);
6019
+ return {
6020
+ ok: false,
6021
+ error: {
6022
+ name: "PreviewTooLargeError",
6023
+ message: `${options.distDir} is too large to share: ${mb(encodedBytes)}MB of files. A shared preview holds about ${mb(MAX_CONTENT_CHARS)}MB of text, and roughly ${mb(3 * MAX_CONTENT_CHARS / 4)}MB when the build is mostly images, fonts or wasm, which travel base64-encoded. Trim the bundled assets, or build without source maps if yours are large.`
6024
+ }
6025
+ };
6026
+ }
5093
6027
  const name = "string" == typeof options.manifest.name && options.manifest.name.trim() ? options.manifest.name : node_path.basename(options.distDir);
5094
6028
  const version = "string" == typeof options.manifest.version ? options.manifest.version : void 0;
5095
6029
  const doFetch = options.fetchImpl ?? fetch;
@@ -5100,6 +6034,7 @@ async function uploadPreview(options) {
5100
6034
  headers: {
5101
6035
  authorization: `Bearer ${token}`,
5102
6036
  "content-type": "application/json",
6037
+ "x-extensiondev-origin": "mcp",
5103
6038
  ...identityHeaders("extension_preview_web")
5104
6039
  },
5105
6040
  body: JSON.stringify({
@@ -5153,14 +6088,14 @@ async function uploadPreview(options) {
5153
6088
  artifactId,
5154
6089
  previewUrl,
5155
6090
  zipUrl: "string" == typeof data.zipUrl ? data.zipUrl : void 0,
5156
- revokeUrl: "string" == typeof data.revokeUrl ? data.revokeUrl : void 0,
6091
+ revokeUrl: "string" == typeof data.revokeUrl ? wwwRevokeUrl(data.revokeUrl) : void 0,
5157
6092
  expiresAt: "string" == typeof data.expiresAt ? data.expiresAt : void 0
5158
6093
  }
5159
6094
  };
5160
6095
  }
5161
6096
  const SHARE_STATE_DIR = ".extension.dev";
5162
6097
  const SHARE_RECORD_FILE = "shared-previews.json";
5163
- const FILE_NOTE = "Every preview link shared from this project. revokeUrl is the only handle that kills a link before expiresAt, and the platform mints a new id on every share, so a lost revokeUrl cannot be recovered. Safe to delete once every link below has expired.";
6098
+ const FILE_NOTE = "Every preview link shared from this project. revokeUrl is the handle that kills a link before expiresAt. Losing it is recoverable: run extension_shares to list every share this token owns, each with its revokeUrl. Re-sharing an unchanged build returns the same link rather than a new one, and only a revoked link is replaced. Safe to delete once every link below has expired.";
5164
6099
  const IGNORE_COMMENT = "# Extension.dev local state: revoke handles for shared previews, not part of your extension.";
5165
6100
  function sharedPreviewsPath(projectPath) {
5166
6101
  return node_path.join(projectPath, SHARE_STATE_DIR, SHARE_RECORD_FILE);
@@ -5233,7 +6168,7 @@ function recordSharedPreview(projectPath, entry) {
5233
6168
  return {
5234
6169
  recorded: false,
5235
6170
  path: file,
5236
- note: `Could not write the share record to ${file}: ${error instanceof Error ? error.message : String(error)}. The revokeUrl in this response is then the only copy, so keep it: re-sharing mints a new link and leaves this one live until expiresAt.`
6171
+ note: `Could not write the share record to ${file}: ${error instanceof Error ? error.message : String(error)}. Keep the revokeUrl in this response, or run extension_shares later to list every share this token owns with its revokeUrl.`
5237
6172
  };
5238
6173
  }
5239
6174
  const ignore = ensureProjectIgnored(projectPath, {
@@ -5259,8 +6194,117 @@ function recordSharedPreview(projectPath, entry) {
5259
6194
  } : {}
5260
6195
  };
5261
6196
  }
6197
+ const MAX_HOPS = 4;
6198
+ function allows(allowOrigin, origin) {
6199
+ if (!allowOrigin) return false;
6200
+ const value = allowOrigin.trim();
6201
+ return "*" === value || value.toLowerCase() === origin.toLowerCase();
6202
+ }
6203
+ async function probeShareCors(options) {
6204
+ const doFetch = options.fetchImpl ?? fetch;
6205
+ const origin = options.origin;
6206
+ let url = options.zipUrl;
6207
+ let redirects = 0;
6208
+ const verdict = (extra)=>({
6209
+ checkedUrl: options.zipUrl,
6210
+ origin,
6211
+ finalUrl: url,
6212
+ finalStatus: 0,
6213
+ redirects,
6214
+ allowOrigin: null,
6215
+ ...extra
6216
+ });
6217
+ for(let hop = 0; hop < MAX_HOPS; hop++){
6218
+ const controller = new AbortController();
6219
+ let res;
6220
+ try {
6221
+ res = await doFetch(url, {
6222
+ method: "GET",
6223
+ redirect: "manual",
6224
+ signal: controller.signal,
6225
+ headers: {
6226
+ origin,
6227
+ "sec-fetch-mode": "cors"
6228
+ }
6229
+ });
6230
+ } catch (err) {
6231
+ return verdict({
6232
+ ok: false,
6233
+ reason: `Could not reach ${url}: ${err instanceof Error ? err.message : String(err)}`
6234
+ });
6235
+ }
6236
+ try {
6237
+ await res.body?.cancel();
6238
+ } catch {
6239
+ controller.abort();
6240
+ }
6241
+ const status = res.status;
6242
+ const allowOrigin = res.headers.get("access-control-allow-origin");
6243
+ if (status >= 300 && status < 400) {
6244
+ const location = res.headers.get("location");
6245
+ if (!location) return verdict({
6246
+ ok: false,
6247
+ finalStatus: status,
6248
+ allowOrigin,
6249
+ reason: `${url} answered ${status} with no Location, so the download goes nowhere.`
6250
+ });
6251
+ url = new URL(location, url).toString();
6252
+ redirects += 1;
6253
+ continue;
6254
+ }
6255
+ if (status >= 400) return verdict({
6256
+ ok: false,
6257
+ finalStatus: status,
6258
+ allowOrigin,
6259
+ reason: `The build's zip answered ${status}, so the link has nothing to render.`
6260
+ });
6261
+ if (!allows(allowOrigin, origin)) return verdict({
6262
+ ok: false,
6263
+ finalStatus: status,
6264
+ allowOrigin,
6265
+ reason: `${url} answered ${status} but with ` + (allowOrigin ? `access-control-allow-origin: ${allowOrigin}, which does not cover ${origin}` : "no access-control-allow-origin header") + `. A browser at ${origin} will refuse to read it, so the link opens to an error even though this fetch succeeded.` + (redirects > 0 ? " The header has to be on this response, not on the redirect that led here." : "")
6266
+ });
6267
+ return verdict({
6268
+ ok: true,
6269
+ finalStatus: status,
6270
+ allowOrigin,
6271
+ reason: `A browser at ${origin} can read the build's zip: the final response after ${redirects} redirect(s) answered ${status} with access-control-allow-origin: ${allowOrigin}.`
6272
+ });
6273
+ }
6274
+ return verdict({
6275
+ ok: false,
6276
+ reason: `The build's zip redirected more than ${MAX_HOPS} times, so nothing could be read from it.`
6277
+ });
6278
+ }
5262
6279
  const preview_web_COMMAND = "extension_preview_web";
5263
6280
  const DEFAULT_PREVIEW_DEV_URL = "http://localhost:3110";
6281
+ const LOCAL_HOSTS = new Set([
6282
+ "localhost",
6283
+ "127.0.0.1",
6284
+ "[::1]",
6285
+ "::1"
6286
+ ]);
6287
+ function safeHostBase(raw) {
6288
+ const trimmed = String(raw || "").replace(/\/+$/, "");
6289
+ let parsed;
6290
+ try {
6291
+ parsed = new URL(trimmed);
6292
+ } catch {
6293
+ return {
6294
+ ok: false,
6295
+ message: `hostUrl is not a URL: ${raw}. Leave it unset to use ${DEFAULT_PREVIEW_DEV_URL}.`
6296
+ };
6297
+ }
6298
+ const isLocal = LOCAL_HOSTS.has(parsed.hostname) || parsed.hostname.endsWith(".localhost") || "preview.extension.dev" === parsed.hostname;
6299
+ if (!isLocal) return {
6300
+ ok: false,
6301
+ message: `Refusing to use ${raw} as the preview host: hostUrl may only name a local preview dev server. Leave it unset to use ${DEFAULT_PREVIEW_DEV_URL}, or pass share:true for a link that needs no local server.`
6302
+ };
6303
+ return {
6304
+ ok: true,
6305
+ base: trimmed
6306
+ };
6307
+ }
5264
6308
  const SURFACE = {
5265
6309
  defaultOrigin: DEFAULT_PREVIEW_DEV_URL,
5266
6310
  scheme: (encoded)=>`preview://build/${encoded}`,
@@ -5268,7 +6312,58 @@ const SURFACE = {
5268
6312
  devCommand: "pnpm --filter preview.extension.dev dev",
5269
6313
  label: "preview.extension.dev"
5270
6314
  };
5271
- async function buildShare(projectPath, distDir, manifest, browser) {
6315
+ const PREVIEW_APP_LOCATIONS = [
6316
+ [
6317
+ "apps",
6318
+ "web",
6319
+ "preview.extension.dev"
6320
+ ],
6321
+ [
6322
+ "apps",
6323
+ "preview.extension.dev"
6324
+ ],
6325
+ [
6326
+ "preview.extension.dev"
6327
+ ]
6328
+ ];
6329
+ function previewDevCheckout(startPaths) {
6330
+ const seen = new Set();
6331
+ for (const start of startPaths){
6332
+ let dir;
6333
+ try {
6334
+ dir = node_path.resolve(start);
6335
+ } catch {
6336
+ continue;
6337
+ }
6338
+ for(let depth = 0; depth < 8; depth++){
6339
+ if (seen.has(dir)) break;
6340
+ seen.add(dir);
6341
+ for (const location of PREVIEW_APP_LOCATIONS){
6342
+ const manifest = node_path.join(dir, ...location, "package.json");
6343
+ try {
6344
+ const parsed = JSON.parse(node_fs.readFileSync(manifest, "utf8"));
6345
+ if (parsed.name === SURFACE.label) return dir;
6346
+ } catch {}
6347
+ }
6348
+ const parent = node_path.dirname(dir);
6349
+ if (parent === dir) break;
6350
+ dir = parent;
6351
+ }
6352
+ }
6353
+ return null;
6354
+ }
6355
+ function localLaneRemedy(checkout) {
6356
+ const shareOut = "Pass share:true for a link that needs no local server and opens for anyone.";
6357
+ return checkout ? `Start it with '${SURFACE.devCommand}' in ${checkout}. ${shareOut}` : `${SURFACE.label} is a private app of the extension.dev monorepo and no npm install of this server can start it, so the default lane cannot resolve on this machine. ${shareOut}`;
6358
+ }
6359
+ function previewOriginOf(previewUrl) {
6360
+ try {
6361
+ return new URL(previewUrl).origin;
6362
+ } catch {
6363
+ return null;
6364
+ }
6365
+ }
6366
+ async function buildShare(projectPath, distDir, manifest, browser, verifyInBrowserTerms) {
5272
6367
  const result = await uploadPreview({
5273
6368
  distDir,
5274
6369
  manifest,
@@ -5287,6 +6382,11 @@ async function buildShare(projectPath, distDir, manifest, browser) {
5287
6382
  } : {}
5288
6383
  };
5289
6384
  }
6385
+ const origin = previewOriginOf(result.data.previewUrl);
6386
+ const browserCheck = verifyInBrowserTerms && result.data.zipUrl && origin ? await probeShareCors({
6387
+ zipUrl: result.data.zipUrl,
6388
+ origin
6389
+ }) : null;
5290
6390
  const sharedAt = new Date().toISOString();
5291
6391
  const record = recordSharedPreview(projectPath, {
5292
6392
  sharedAt,
@@ -5327,8 +6427,14 @@ async function buildShare(projectPath, distDir, manifest, browser) {
5327
6427
  } : {},
5328
6428
  serves: "uploaded-local-build",
5329
6429
  localBuildUploaded: true,
6430
+ ...browserCheck ? {
6431
+ browserLoadable: browserCheck.ok,
6432
+ browserCheck
6433
+ } : {
6434
+ browserLoadable: null
6435
+ },
5330
6436
  record,
5331
- note: "Anyone with this link can open the build you just made, running in the emulator. No install, no sign-in, no dev server. They can also download the whole build as a zip from zipUrl, so the link hands over the built code. It stays live until expiresAt; DELETE revokeUrl with the same token to kill it sooner, and a revoked link stays dead. revokeUrl is the only handle that pulls this link early and re-sharing mints a different one, so " + (record.recorded ? `it was also written to ${record.path} (record.path), which lists every share from this project.` : `keep it: ${record.note}`) + " To find this link again later, or to pull it back once it has left this conversation, run extension_shares: it lists every link this token has shared with its live or dead state, and revokes one by artifactId or by pasting any of its URLs." + (record.warning ? ` ${record.warning}` : "")
6437
+ note: "Anyone with this link can open the build you just made, running in the emulator. No install, no sign-in, no dev server. They can also download the whole build as a zip from zipUrl, so the link hands over the built code. It stays live until expiresAt; DELETE revokeUrl with the same token to kill it sooner, and a revoked link stays dead. revokeUrl is the handle that pulls this link early. Re-sharing an unchanged build returns this same link rather than a second one, and only a revoked link is replaced by a different one, so " + (record.recorded ? `it was also written to ${record.path} (record.path), which lists every share from this project.` : `keep it: ${record.note}`) + " To find this link again later, or to pull it back once it has left this conversation, run extension_shares: it lists every link this token has shared with its live or dead state, and revokes one by artifactId or by pasting any of its URLs." + (record.warning ? ` ${record.warning}` : "")
5332
6438
  };
5333
6439
  }
5334
6440
  function detectSurfaces(manifest) {
@@ -5351,7 +6457,7 @@ function detectSurfaces(manifest) {
5351
6457
  }
5352
6458
  const preview_web_schema = {
5353
6459
  name: "extension_preview_web",
5354
- description: "Preview an in-progress extension in the web emulator, with no real browser. This builds the project (unless build:false), points preview.extension.dev at dist/<browser> over the dev-only preview://build scheme, and returns a deep link plus a loadability check. Use it as the author's door for a local build: it renders your build and carries the Emulated/Real lane toggle and the Trace tab, but the deep link resolves only on this machine. Pass share:true to get a public link that reaches anyone. Call extension_shares to list and revoke every link shared this way, so one never vanishes with this response.",
6460
+ description: "Preview an in-progress extension in the web emulator, with no real browser. This builds the project (unless build:false) and previews dist/<browser>. Pass share:true unless you are working inside the extension.dev monorepo: it uploads the build and returns a link anyone can open, with no install, sign-in or dev server, and it is the only lane that works from an npm install of this server. Sharing also serves the build as a zip, so it hands over the built code; read the share property before using it. The default lane instead returns a deep link over the dev-only preview://build scheme, which resolves only against a preview.extension.dev dev server on this machine, so it is for people developing extension.dev itself. Call extension_shares to list and revoke every link shared this way, so one never vanishes with this response.",
5355
6461
  inputSchema: {
5356
6462
  type: "object",
5357
6463
  properties: {
@@ -5378,7 +6484,7 @@ const preview_web_schema = {
5378
6484
  probe: {
5379
6485
  type: "boolean",
5380
6486
  default: true,
5381
- description: "Fetch the surface's dev middleware first to confirm the artifact loads."
6487
+ description: "Fetch the surface's dev middleware first to confirm the artifact loads on the local host. With share:true it also checks the shared link the way a browser would, following the zip's redirects and asserting the final response allows the preview origin, and reports that as share.browserLoadable."
5382
6488
  },
5383
6489
  open: {
5384
6490
  type: "boolean",
@@ -5393,7 +6499,7 @@ const preview_web_schema = {
5393
6499
  share: {
5394
6500
  type: "boolean",
5395
6501
  default: false,
5396
- description: "Upload the built dist and return a public link (share.previewUrl) that renders those exact bytes for anyone: no install, sign-in or dev server. It also serves the build as a zip (share.zipUrl), so sharing hands over the code. Needs a token scoped to an extension.dev project (extension_auth or EXTENSION_DEV_TOKEN); without one you get a login hint and the local preview still succeeds. Live until share.expiresAt; DELETE share.revokeUrl to kill it sooner. Revocation is permanent and re-sharing mints a different link, so each share is also appended to the project's gitignored .extension.dev/shared-previews.json."
6502
+ description: "Upload the built dist and return a public link (share.previewUrl) that renders those exact bytes for anyone: no install, sign-in or dev server. It also serves the build as a zip (share.zipUrl), so sharing hands over the code. Needs a token scoped to an extension.dev project (extension_auth or EXTENSION_DEV_TOKEN); without one you get a login hint and the local preview still succeeds. Live until share.expiresAt; DELETE share.revokeUrl to kill it sooner. Revocation is permanent, and re-sharing an unchanged build returns the same link unless it was revoked, so each share is also appended to the project's gitignored .extension.dev/shared-previews.json."
5397
6503
  }
5398
6504
  },
5399
6505
  required: [
@@ -5403,7 +6509,21 @@ const preview_web_schema = {
5403
6509
  };
5404
6510
  async function preview_web_handler(args) {
5405
6511
  const browser = args.browser ?? "chrome";
5406
- const hostBase = (args.hostUrl ?? SURFACE.defaultOrigin).replace(/\/+$/, "");
6512
+ const host = safeHostBase(args.hostUrl ?? SURFACE.defaultOrigin);
6513
+ if (!host.ok) return envelope_envelope({
6514
+ ok: false,
6515
+ command: preview_web_COMMAND,
6516
+ status: "bad-host-url",
6517
+ error: {
6518
+ code: "E_BAD_HOST_URL",
6519
+ message: host.message
6520
+ },
6521
+ value: {
6522
+ stage: "resolve-host",
6523
+ hostUrl: args.hostUrl
6524
+ }
6525
+ });
6526
+ const hostBase = host.base;
5407
6527
  const shouldBuild = args.distPath ? false : false !== args.build;
5408
6528
  let buildResult = null;
5409
6529
  if (shouldBuild) {
@@ -5419,7 +6539,7 @@ async function preview_web_handler(args) {
5419
6539
  raw
5420
6540
  };
5421
6541
  }
5422
- if (!buildResult || true !== buildResult.ok) return envelope({
6542
+ if (!buildResult || true !== buildResult.ok) return envelope_envelope({
5423
6543
  ok: false,
5424
6544
  command: preview_web_COMMAND,
5425
6545
  status: "build-failed",
@@ -5435,7 +6555,7 @@ async function preview_web_handler(args) {
5435
6555
  }
5436
6556
  const distDir = args.distPath ? node_path.resolve(args.distPath) : node_path.resolve(args.projectPath, "dist", browser);
5437
6557
  const manifestPath = node_path.join(distDir, "manifest.json");
5438
- if (!node_fs.existsSync(manifestPath)) return envelope({
6558
+ if (!node_fs.existsSync(manifestPath)) return envelope_envelope({
5439
6559
  ok: false,
5440
6560
  command: preview_web_COMMAND,
5441
6561
  status: "no-dist",
@@ -5452,7 +6572,7 @@ async function preview_web_handler(args) {
5452
6572
  try {
5453
6573
  manifest = JSON.parse(node_fs.readFileSync(manifestPath, "utf8"));
5454
6574
  } catch (err) {
5455
- return envelope({
6575
+ return envelope_envelope({
5456
6576
  ok: false,
5457
6577
  command: preview_web_COMMAND,
5458
6578
  status: "bad-dist-manifest",
@@ -5484,7 +6604,12 @@ async function preview_web_handler(args) {
5484
6604
  built: false
5485
6605
  }
5486
6606
  };
5487
- const hint = `Open deepLink in a browser to see the extension render in ${SURFACE.label}'s emulator. It must be running (${SURFACE.devCommand}). Once it renders, the Trace tab shows every chrome.* call it makes, and the lane toggle switches between the emulated backend and a real carrier-equipped browser.`;
6607
+ const checkout = previewDevCheckout([
6608
+ args.projectPath,
6609
+ process.cwd()
6610
+ ]);
6611
+ const remedy = localLaneRemedy(checkout);
6612
+ const hint = `Open deepLink in a browser to see the extension render in ${SURFACE.label}'s emulator, where the Trace tab shows every chrome.* call it makes and the lane toggle switches between the emulated backend and a real carrier-equipped browser. It needs that surface's dev server running on this machine. ${remedy}`;
5488
6613
  const previewWarnings = [];
5489
6614
  if (args.open) {
5490
6615
  const sessionBrowser = args.openIn ?? browser;
@@ -5494,250 +6619,122 @@ async function preview_web_handler(args) {
5494
6619
  opened = JSON.parse(navRaw);
5495
6620
  } catch {
5496
6621
  opened = {
5497
- ok: false,
5498
- raw: navRaw
5499
- };
5500
- }
5501
- result.opened = opened;
5502
- result.openedIn = sessionBrowser;
5503
- if (true !== opened.ok) previewWarnings.push("Could not open the preview in a browser. This needs a live dev session (run extension_dev, then extension_wait for ready). The deepLink above still works if you open it yourself.");
5504
- }
5505
- if (args.share) result.share = await buildShare(args.projectPath, distDir, manifest, browser);
5506
- if (false === args.probe) return envelope({
5507
- ok: true,
5508
- command: preview_web_COMMAND,
5509
- status: "previewed",
5510
- value: result,
5511
- hint,
5512
- warnings: previewWarnings
5513
- });
5514
- const probeUrl = `${hostBase}${SURFACE.fetchPath}?url=${encodeURIComponent(internalUrl)}`;
5515
- try {
5516
- const res = await fetch(probeUrl, {
5517
- headers: {
5518
- accept: "application/json"
5519
- }
5520
- });
5521
- const contentType = res.headers.get("content-type") ?? "";
5522
- if (!res.ok || !contentType.includes("application/json")) return envelope({
5523
- ok: true,
5524
- command: preview_web_COMMAND,
5525
- status: "host-not-serving-preview",
5526
- value: {
5527
- ...result,
5528
- hostReachable: true,
5529
- previewLoadable: false,
5530
- probe: {
5531
- status: res.status,
5532
- contentType
5533
- }
5534
- },
5535
- hint,
5536
- warnings: [
5537
- ...previewWarnings,
5538
- `${SURFACE.label} answered but not with a preview payload. On the deployed host ${SURFACE.fetchPath} does not exist (dev-only); run a local dev server (${SURFACE.devCommand}) to use web preview.`
5539
- ]
5540
- });
5541
- const payload = await res.json();
5542
- return envelope({
5543
- ok: true,
5544
- command: preview_web_COMMAND,
5545
- status: "previewed",
5546
- value: {
5547
- ...result,
5548
- hostReachable: true,
5549
- previewLoadable: true,
5550
- probe: {
5551
- identifier: payload.identifier,
5552
- loadedName: payload.manifest?.name,
5553
- loadedVersion: payload.version,
5554
- fileCount: Array.isArray(payload.files) ? payload.files.length : 0
5555
- }
5556
- },
5557
- hint,
5558
- warnings: previewWarnings
5559
- });
5560
- } catch (err) {
5561
- return envelope({
5562
- ok: true,
5563
- command: preview_web_COMMAND,
5564
- status: "host-unreachable",
5565
- value: {
5566
- ...result,
5567
- hostReachable: false,
5568
- previewLoadable: false,
5569
- probe: {
5570
- error: err instanceof Error ? err.message : String(err)
5571
- }
5572
- },
5573
- hint,
5574
- warnings: [
5575
- ...previewWarnings,
5576
- `Could not reach ${SURFACE.label} at ${hostBase}. Start it with '${SURFACE.devCommand}', then open deepLink.`
5577
- ]
5578
- });
5579
- }
5580
- }
5581
- const ARTIFACT_ID = /^gen_[0-9a-f]{32}$/;
5582
- const ARTIFACT_ID_ANYWHERE = /gen_[0-9a-f]{32}/;
5583
- function parseArtifactRef(input) {
5584
- const raw = String(input ?? "").trim();
5585
- if (!raw) return null;
5586
- if (ARTIFACT_ID.test(raw)) return raw;
5587
- let parsed = null;
5588
- try {
5589
- parsed = new URL(raw);
5590
- } catch {
5591
- parsed = null;
5592
- }
5593
- if (parsed) {
5594
- const fromQuery = parsed.searchParams.get("preview");
5595
- if (fromQuery && ARTIFACT_ID.test(fromQuery.trim())) return fromQuery.trim();
5596
- const segments = parsed.pathname.split("/").filter(Boolean);
5597
- for(let i = segments.length - 1; i >= 0; i -= 1){
5598
- const segment = decodeURIComponent(segments[i]);
5599
- if (ARTIFACT_ID.test(segment)) return segment;
5600
- }
5601
- }
5602
- const loose = ARTIFACT_ID_ANYWHERE.exec(raw);
5603
- return loose ? loose[0] : null;
5604
- }
5605
- function authError(name) {
5606
- return {
5607
- ok: false,
5608
- error: {
5609
- name,
5610
- message: "No token. Run extension_auth (action: login), or set EXTENSION_DEV_TOKEN (create one in the extension.dev dashboard)."
5611
- }
5612
- };
5613
- }
5614
- async function readBody(res) {
5615
- const text = await res.text();
5616
- try {
5617
- return JSON.parse(text);
5618
- } catch {
5619
- return {
5620
- message: text
5621
- };
5622
- }
5623
- }
5624
- async function listArtifacts(options = {}) {
5625
- const token = options.token ?? resolveToken();
5626
- if (!token) return authError("SharesAuthError");
5627
- const apiCheck = safeApiBase(resolveApiBase(options.api));
5628
- if (!apiCheck.ok) return {
5629
- ok: false,
5630
- error: {
5631
- name: "SharesConfigError",
5632
- message: apiCheck.message
5633
- }
5634
- };
5635
- const url = new URL(`${apiCheck.base}/api/artifacts`);
5636
- if (null != options.limit) url.searchParams.set("limit", String(options.limit));
5637
- if (options.liveOnly) url.searchParams.set("status", "live");
5638
- const doFetch = options.fetchImpl ?? fetch;
5639
- let res;
5640
- try {
5641
- res = await doFetch(url.toString(), {
5642
- headers: {
5643
- authorization: `Bearer ${token}`,
5644
- accept: "application/json",
5645
- ...identityHeaders("extension_shares")
5646
- }
5647
- });
5648
- } catch (err) {
5649
- return {
5650
- ok: false,
5651
- error: {
5652
- name: "SharesNetworkError",
5653
- message: `Could not reach ${url.toString()}: ${err?.message || err}`
5654
- }
5655
- };
5656
- }
5657
- const data = await readBody(res);
5658
- if (401 === res.status) return authError("SharesAuthError");
5659
- if (!res.ok) return {
5660
- ok: false,
5661
- error: {
5662
- name: "SharesListError",
5663
- status: res.status,
5664
- message: `Listing shares failed (${res.status}): ${data?.message || "unknown error"}`
5665
- }
5666
- };
5667
- const artifacts = Array.isArray(data.artifacts) ? data.artifacts : [];
5668
- const num = (value, fallback)=>"number" == typeof value && Number.isFinite(value) ? value : fallback;
5669
- return {
5670
- ok: true,
5671
- data: {
5672
- artifacts,
5673
- count: num(data.count, artifacts.length),
5674
- matched: num(data.matched, artifacts.length),
5675
- limit: num(data.limit, artifacts.length),
5676
- truncated: true === data.truncated,
5677
- scanned: num(data.scanned, 0)
5678
- }
5679
- };
5680
- }
5681
- async function revokeArtifact(options) {
5682
- const token = options.token ?? resolveToken();
5683
- if (!token) return authError("SharesAuthError");
5684
- const apiCheck = safeApiBase(resolveApiBase(options.api));
5685
- if (!apiCheck.ok) return {
5686
- ok: false,
5687
- error: {
5688
- name: "SharesConfigError",
5689
- message: apiCheck.message
6622
+ ok: false,
6623
+ raw: navRaw
6624
+ };
5690
6625
  }
5691
- };
5692
- const url = `${apiCheck.base}/api/artifacts/${encodeURIComponent(options.artifactId)}`;
5693
- const doFetch = options.fetchImpl ?? fetch;
5694
- let res;
6626
+ result.opened = opened;
6627
+ result.openedIn = sessionBrowser;
6628
+ if (true !== opened.ok) previewWarnings.push("Could not open the preview in a browser. This needs a live dev session (run extension_dev, then extension_wait for ready). The deepLink above still works if you open it yourself.");
6629
+ }
6630
+ if (args.share) {
6631
+ const share = await buildShare(args.projectPath, distDir, manifest, browser, false !== args.probe);
6632
+ result.share = share;
6633
+ if (true === share.ok && false === share.browserLoadable) {
6634
+ const check = share.browserCheck;
6635
+ previewWarnings.push(`The link uploaded, but it will not render for anyone: ${check?.reason ?? "its zip is not readable from the preview origin."} Do not hand this link out as working until that is fixed.`);
6636
+ }
6637
+ }
6638
+ if (false === args.probe) return envelope_envelope({
6639
+ ok: true,
6640
+ command: preview_web_COMMAND,
6641
+ status: "previewed",
6642
+ value: result,
6643
+ hint,
6644
+ warnings: previewWarnings
6645
+ });
6646
+ const probeUrl = `${hostBase}${SURFACE.fetchPath}?url=${encodeURIComponent(internalUrl)}`;
5695
6647
  try {
5696
- res = await doFetch(url, {
5697
- method: "DELETE",
6648
+ const res = await fetch(probeUrl, {
5698
6649
  headers: {
5699
- authorization: `Bearer ${token}`,
5700
- accept: "application/json",
5701
- ...identityHeaders("extension_shares")
6650
+ accept: "application/json"
5702
6651
  }
5703
6652
  });
6653
+ const contentType = res.headers.get("content-type") ?? "";
6654
+ if (!res.ok || !contentType.includes("application/json")) return envelope_envelope({
6655
+ ok: true,
6656
+ command: preview_web_COMMAND,
6657
+ status: "host-not-serving-preview",
6658
+ value: {
6659
+ ...result,
6660
+ hostReachable: true,
6661
+ previewLoadable: false,
6662
+ probe: {
6663
+ status: res.status,
6664
+ contentType
6665
+ }
6666
+ },
6667
+ hint,
6668
+ warnings: [
6669
+ ...previewWarnings,
6670
+ `${SURFACE.label} answered but not with a preview payload. On the deployed host ${SURFACE.fetchPath} does not exist, because it is dev-only. ${remedy}`
6671
+ ]
6672
+ });
6673
+ const payload = await res.json();
6674
+ return envelope_envelope({
6675
+ ok: true,
6676
+ command: preview_web_COMMAND,
6677
+ status: "previewed",
6678
+ value: {
6679
+ ...result,
6680
+ hostReachable: true,
6681
+ previewLoadable: true,
6682
+ previewLoadableLane: "local-dev-host",
6683
+ probe: {
6684
+ identifier: payload.identifier,
6685
+ loadedName: payload.manifest?.name,
6686
+ loadedVersion: payload.version,
6687
+ fileCount: Array.isArray(payload.files) ? payload.files.length : 0,
6688
+ method: "server-fetch",
6689
+ provesBrowserLoad: false
6690
+ }
6691
+ },
6692
+ hint,
6693
+ warnings: previewWarnings
6694
+ });
5704
6695
  } catch (err) {
5705
- return {
6696
+ const shared = result.share;
6697
+ if (args.share && shared?.ok === true) return envelope_envelope({
6698
+ ok: true,
6699
+ command: preview_web_COMMAND,
6700
+ status: "shared",
6701
+ value: {
6702
+ ...result,
6703
+ hostReachable: false,
6704
+ previewLoadable: false,
6705
+ probe: {
6706
+ error: err instanceof Error ? err.message : String(err)
6707
+ }
6708
+ },
6709
+ hint: "share.previewUrl is live and needs no local server. The deepLink lane is separate: it only resolves against a preview.extension.dev dev server on this machine, and none answered.",
6710
+ warnings: [
6711
+ ...previewWarnings,
6712
+ `The share link works; only the local ${SURFACE.label} dev lane at ${hostBase} is unreachable, which is expected outside the extension.dev monorepo.`
6713
+ ]
6714
+ });
6715
+ return envelope_envelope({
5706
6716
  ok: false,
6717
+ command: preview_web_COMMAND,
6718
+ status: "host-unreachable",
5707
6719
  error: {
5708
- name: "SharesNetworkError",
5709
- message: `Could not reach ${url}: ${err?.message || err}`
5710
- }
5711
- };
6720
+ code: "E_PREVIEW_HOST_UNREACHABLE",
6721
+ message: `Nothing is serving ${SURFACE.label} at ${hostBase}, so deepLink has nothing to open. ${remedy}`
6722
+ },
6723
+ value: {
6724
+ ...result,
6725
+ hostReachable: false,
6726
+ previewLoadable: false,
6727
+ probe: {
6728
+ error: err instanceof Error ? err.message : String(err)
6729
+ }
6730
+ },
6731
+ hint,
6732
+ warnings: [
6733
+ ...previewWarnings,
6734
+ `Could not reach ${SURFACE.label} at ${hostBase}. ${remedy}`
6735
+ ]
6736
+ });
5712
6737
  }
5713
- const data = await readBody(res);
5714
- if (401 === res.status) return authError("SharesAuthError");
5715
- if (404 === res.status) return {
5716
- ok: false,
5717
- error: {
5718
- name: "SharesNotFoundError",
5719
- status: 404,
5720
- message: `The platform has no live share ${options.artifactId} for this token. It may already be revoked, already expired, owned by a different project than the one this token is scoped to, or a teammate's personal share, which belongs to that person alone and no project token can revoke.`
5721
- }
5722
- };
5723
- if (!res.ok) return {
5724
- ok: false,
5725
- error: {
5726
- name: "SharesRevokeError",
5727
- status: res.status,
5728
- message: `Revoking ${options.artifactId} failed (${res.status}): ${data?.message || "unknown error"}`
5729
- }
5730
- };
5731
- return {
5732
- ok: true,
5733
- data: {
5734
- artifactId: options.artifactId,
5735
- revoked: true === data.revoked,
5736
- ..."string" == typeof data.revokedAt ? {
5737
- revokedAt: data.revokedAt
5738
- } : {}
5739
- }
5740
- };
5741
6738
  }
5742
6739
  const LOGIN_HINT = "Run extension_auth (action: login), or set EXTENSION_DEV_TOKEN (create one in the extension.dev dashboard).";
5743
6740
  const shares_schema = {
@@ -5839,8 +6836,9 @@ function localIndex(entries) {
5839
6836
  }
5840
6837
  return index;
5841
6838
  }
5842
- function localOnlyStatus(entry, truncated, liveFiltered, now) {
5843
- if (truncated) return "unknown: the platform list was cut short, so this share may simply be past the returned window.";
6839
+ function localOnlyStatus(entry, completeness, liveFiltered, now) {
6840
+ if ("cut" === completeness) return "unknown: the platform list was cut short, so this share may simply be past the returned window.";
6841
+ if ("unsaid" === completeness) return "unknown: the platform did not say whether that list was whole, so this share may simply be past the returned window.";
5844
6842
  const expiresAt = entry.expiresAt ? Date.parse(entry.expiresAt) : NaN;
5845
6843
  if (Number.isFinite(expiresAt) && expiresAt <= now) return "expired: its own expiresAt has passed, and the platform no longer lists it.";
5846
6844
  if (liveFiltered) return 'not in this live-only listing: the platform was asked for live shares only, so this one may be expired or revoked rather than not owned. Rerun with status:"all" to tell.';
@@ -5871,7 +6869,7 @@ async function listShares(args) {
5871
6869
  });
5872
6870
  if (!listing.ok) {
5873
6871
  const isAuth = "SharesAuthError" === listing.error.name;
5874
- return envelope({
6872
+ return envelope_envelope({
5875
6873
  ok: true,
5876
6874
  command: "extension_shares",
5877
6875
  status: "listed-local-only",
@@ -5887,7 +6885,10 @@ async function listShares(args) {
5887
6885
  },
5888
6886
  shares: [],
5889
6887
  localOnly: (local?.entries ?? []).map((entry)=>({
5890
- ...entry
6888
+ ...entry,
6889
+ ...entry.revokeUrl ? {
6890
+ revokeUrl: wwwRevokeUrl(entry.revokeUrl)
6891
+ } : {}
5891
6892
  })),
5892
6893
  localRecord
5893
6894
  },
@@ -5921,9 +6922,13 @@ async function listShares(args) {
5921
6922
  } : {}
5922
6923
  };
5923
6924
  });
6925
+ const completeness = listing.data.truncatedReported ? listing.data.truncated ? "cut" : "whole" : "unsaid";
5924
6926
  const localOnly = (local?.entries ?? []).filter((entry)=>!seen.has(entry.artifactId)).map((entry)=>({
5925
6927
  ...entry,
5926
- status: localOnlyStatus(entry, listing.data.truncated, liveFiltered, now)
6928
+ ...entry.revokeUrl ? {
6929
+ revokeUrl: wwwRevokeUrl(entry.revokeUrl)
6930
+ } : {},
6931
+ status: localOnlyStatus(entry, completeness, liveFiltered, now)
5927
6932
  }));
5928
6933
  const liveCount = shares.filter((share)=>share.live).length;
5929
6934
  const ownership = {
@@ -5931,8 +6936,8 @@ async function listShares(args) {
5931
6936
  personal: shares.filter((s)=>"personal" === s.attribution.ownership).length,
5932
6937
  unknown: shares.filter((s)=>"unknown" === s.attribution.ownership).length
5933
6938
  };
5934
- const truncatedNote = listing.data.truncated ? `This is not the whole set: ${listing.data.count} of ${listing.data.matched} matched shares came back at limit ${listing.data.limit}. truncated also goes true when the server spent its budget working out which shares you are entitled to see, so matched is a floor and not a total. Raise limit (max 200) or pass status:"live" to narrow it, and do not read a missing share as revoked.` : null;
5935
- return envelope({
6939
+ const truncatedNote = "cut" === completeness ? `This is not the whole set: ${listing.data.count} of ${listing.data.matched} matched shares came back at limit ${listing.data.limit}. truncated also goes true when the server spent its budget working out which shares you are entitled to see, so matched is a floor and not a total. Raise limit (max 200) or pass status:"live" to narrow it, and do not read a missing share as revoked.` : "unsaid" === completeness ? `This listing cannot be called whole: the platform returned no truncated field, so whether ${listing.data.count} shares are all of them is unstated. A share missing from it is unaccounted for rather than dead, and no missing share is reported as revoked or unowned here.` : null;
6940
+ return envelope_envelope({
5936
6941
  ok: true,
5937
6942
  command: "extension_shares",
5938
6943
  status: "listed",
@@ -5944,6 +6949,7 @@ async function listShares(args) {
5944
6949
  matched: listing.data.matched,
5945
6950
  limit: listing.data.limit,
5946
6951
  truncated: listing.data.truncated,
6952
+ truncatedReported: listing.data.truncatedReported,
5947
6953
  scanned: listing.data.scanned,
5948
6954
  ownership,
5949
6955
  ...truncatedNote ? {
@@ -5958,25 +6964,29 @@ async function listShares(args) {
5958
6964
  },
5959
6965
  hint: `${liveCount} of ${shares.length} listed shares still resolve${local ? `; ${localOnly.length} local record ${1 === localOnly.length ? "entry is" : "entries are"} ${liveFiltered ? 'not in this live-only listing (possibly dead rather than not owned; rerun with status:"all" to tell)' : "without an artifact behind them"}` : ""}. Revoke one with action:"revoke" and its artifactId or any of its URLs.`,
5960
6966
  warnings: [
5961
- "previewUrl and zipUrl are null for a share that is no longer live, because a revoked or expired link cannot resolve for anyone. revokeUrl stays on every row. Revocation is permanent: a revoked id is burned and re-sharing the same build mints a different link.",
6967
+ "previewUrl and zipUrl are null for a share that is no longer live, because a revoked or expired link cannot resolve for anyone. revokeUrl stays on every row. Revocation is permanent: a revoked id is burned, and re-sharing that build mints a different link. Re-sharing a build that was NOT revoked returns its existing link instead.",
5962
6968
  "attribution.ownership says who the share belongs to and therefore who may revoke it: project means the owning workspace holds it and any member can pull it back, personal means one person holds it alone. attribution.credit names the publisher and is attribution only, granting and restricting nothing. A credit of \"CLI token ...\" means the platform could not resolve which human minted that token, and a credit of \"not recorded\" means it never knew; neither is a name, and neither should be reported as one.",
5963
6969
  truncatedNote
5964
6970
  ]
5965
6971
  });
5966
6972
  }
5967
6973
  async function revokeShare(args) {
5968
- const ref = parseArtifactRef(args.artifactId || args.url || "");
5969
- if (!ref) return envelope({
6974
+ const supplied = String(args.artifactId || args.url || "").trim();
6975
+ const ref = parseArtifactRef(supplied);
6976
+ if (!ref) return envelope_envelope({
5970
6977
  ok: false,
5971
6978
  command: "extension_shares",
5972
6979
  status: "bad-request",
5973
6980
  value: {
5974
- action: "revoke"
6981
+ action: "revoke",
6982
+ ...supplied ? {
6983
+ ref: supplied
6984
+ } : {}
5975
6985
  },
5976
6986
  error: {
5977
6987
  code: "E_BAD_REQUEST",
5978
6988
  name: "SharesInputError",
5979
- message: "Nothing to revoke. Pass artifactId (a gen_... id) or url (the previewUrl, zipUrl, viewUrl, or revokeUrl of the share). Run action:\"list\" to see both."
6989
+ message: supplied ? `The reference is malformed and nothing was sent to the platform: no share id could be read out of ${JSON.stringify(supplied.length > 200 ? `${supplied.slice(0, 200)}...` : supplied)}. A share id is gen_ followed by exactly 64 lowercase hex characters (32 on shares minted before 2026-07-30), and a url revokes only when it contains one whole. Check the ref for a copy-paste cut, or run action:"list" and copy the exact artifactId.` : 'Nothing to revoke. Pass artifactId (a gen_... id) or url (the previewUrl, zipUrl, viewUrl, or revokeUrl of the share). Run action:"list" to see both.'
5980
6990
  }
5981
6991
  });
5982
6992
  const result = await revokeArtifact({
@@ -5990,7 +7000,7 @@ async function revokeShare(args) {
5990
7000
  const recordNote = local ? entry ? `${local.path} still lists this share as its own append-only history and was not rewritten, so the entry stays with its original sharedAt. The platform is the truth for whether a link resolves.` : `${local.path} has no entry for this share, so it was made from another machine or another checkout.` : void 0;
5991
7001
  if (!result.ok) {
5992
7002
  const isAuth = "SharesAuthError" === result.error.name;
5993
- return envelope({
7003
+ return envelope_envelope({
5994
7004
  ok: false,
5995
7005
  command: "extension_shares",
5996
7006
  status: "revoke-failed",
@@ -6011,7 +7021,7 @@ async function revokeShare(args) {
6011
7021
  ]
6012
7022
  });
6013
7023
  }
6014
- if (true !== result.data.revoked) return envelope({
7024
+ if (true !== result.data.revoked) return envelope_envelope({
6015
7025
  ok: true,
6016
7026
  command: "extension_shares",
6017
7027
  status: "revoke-unconfirmed",
@@ -6028,7 +7038,7 @@ async function revokeShare(args) {
6028
7038
  recordNote
6029
7039
  ]
6030
7040
  });
6031
- return envelope({
7041
+ return envelope_envelope({
6032
7042
  ok: true,
6033
7043
  command: "extension_shares",
6034
7044
  status: "revoked",
@@ -6374,7 +7384,7 @@ async function manifest_validate_handler(args) {
6374
7384
  const errors = [
6375
7385
  args.projectPath ? `No manifest.json found under ${args.projectPath} (looked in the root and src/).` : "Pass manifestPath (path to manifest.json) or projectPath (project root)."
6376
7386
  ];
6377
- return envelope({
7387
+ return envelope_envelope({
6378
7388
  ok: false,
6379
7389
  command: manifest_validate_COMMAND,
6380
7390
  status: "manifest-not-found",
@@ -6400,7 +7410,7 @@ async function manifest_validate_handler(args) {
6400
7410
  const errors = [
6401
7411
  `Cannot read manifest: ${err instanceof Error ? err.message : err}`
6402
7412
  ];
6403
- return envelope({
7413
+ return envelope_envelope({
6404
7414
  ok: false,
6405
7415
  command: manifest_validate_COMMAND,
6406
7416
  status: "manifest-unreadable",
@@ -6535,7 +7545,7 @@ async function manifest_validate_handler(args) {
6535
7545
  else result.warnings.push(`${browser} (not requested, checked by default): ${issues}`);
6536
7546
  }
6537
7547
  result.valid = 0 === result.errors.length;
6538
- return envelope({
7548
+ return envelope_envelope({
6539
7549
  ok: result.valid,
6540
7550
  command: manifest_validate_COMMAND,
6541
7551
  status: result.valid ? "valid" : "invalid",
@@ -7217,7 +8227,7 @@ async function theme_verify_handler(args) {
7217
8227
  try {
7218
8228
  text = await promises.readFile(abs, "utf8");
7219
8229
  } catch {
7220
- return envelope({
8230
+ return envelope_envelope({
7221
8231
  ok: false,
7222
8232
  command: theme_verify_COMMAND,
7223
8233
  status: "bad-input",
@@ -7231,7 +8241,7 @@ async function theme_verify_handler(args) {
7231
8241
  try {
7232
8242
  raw = JSON.parse(text);
7233
8243
  } catch (err) {
7234
- return envelope({
8244
+ return envelope_envelope({
7235
8245
  ok: false,
7236
8246
  command: theme_verify_COMMAND,
7237
8247
  status: "bad-input",
@@ -7243,7 +8253,7 @@ async function theme_verify_handler(args) {
7243
8253
  });
7244
8254
  }
7245
8255
  } else {
7246
- if (!args.manifest) return envelope({
8256
+ if (!args.manifest) return envelope_envelope({
7247
8257
  ok: false,
7248
8258
  command: theme_verify_COMMAND,
7249
8259
  status: "bad-request",
@@ -7260,7 +8270,7 @@ async function theme_verify_handler(args) {
7260
8270
  try {
7261
8271
  ({ manifest, theme } = coerceInput(raw));
7262
8272
  } catch (err) {
7263
- return envelope({
8273
+ return envelope_envelope({
7264
8274
  ok: false,
7265
8275
  command: theme_verify_COMMAND,
7266
8276
  status: "bad-input",
@@ -7364,7 +8374,7 @@ async function theme_verify_handler(args) {
7364
8374
  how: "a Chrome theme is an unpacked extension: extension_dev then extension_logs on the exported theme dir (headless, focus-safe)"
7365
8375
  }
7366
8376
  ];
7367
- return envelope({
8377
+ return envelope_envelope({
7368
8378
  ok: true,
7369
8379
  command: theme_verify_COMMAND,
7370
8380
  status: verdict,
@@ -7526,7 +8536,7 @@ function formatBytes(bytes) {
7526
8536
  async function analyze_handler(args) {
7527
8537
  const browser = args.browser ?? "chrome";
7528
8538
  const distPath = node_path.resolve(args.projectPath, "dist", browser);
7529
- if (!node_fs.existsSync(distPath)) return envelope({
8539
+ if (!node_fs.existsSync(distPath)) return envelope_envelope({
7530
8540
  ok: false,
7531
8541
  command: analyze_COMMAND,
7532
8542
  status: "no-dist",
@@ -7636,7 +8646,7 @@ async function analyze_handler(args) {
7636
8646
  under10MB: totalSize - archiveSize < 10485760
7637
8647
  }
7638
8648
  };
7639
- return envelope({
8649
+ return envelope_envelope({
7640
8650
  ok: true,
7641
8651
  command: analyze_COMMAND,
7642
8652
  status: "analyzed",
@@ -8124,7 +9134,7 @@ async function inspectViaBridge(args, browser, include, maxBytes) {
8124
9134
  }
8125
9135
  const frame = Array.isArray(parsed?.value?.frames) ? parsed.value.frames[0] : null;
8126
9136
  if (parsed?.ok === true && frame && "object" == typeof frame) value = frame;
8127
- else if (parsed?.ok === true) return envelope({
9137
+ else if (parsed?.ok === true) return envelope_envelope({
8128
9138
  ok: false,
8129
9139
  command: TOOL,
8130
9140
  status: "inspect-failed",
@@ -8167,7 +9177,7 @@ async function inspectViaBridge(args, browser, include, maxBytes) {
8167
9177
  const cap = maxBytes > 0 ? maxBytes : 65536;
8168
9178
  await collectGeckoDeepDom(args, browser, urlFilter, cap, result, notes);
8169
9179
  }
8170
- return envelope({
9180
+ return envelope_envelope({
8171
9181
  ok: true,
8172
9182
  command: TOOL,
8173
9183
  status: "inspected",
@@ -8188,7 +9198,7 @@ async function inspect_handler(args) {
8188
9198
  const maxBytes = args.maxBytes ?? 262144;
8189
9199
  if (!isChromiumFamily(browser)) return inspectViaBridge(args, browser, include, maxBytes);
8190
9200
  const resolved = await resolveCdpPort(args.projectPath, browser);
8191
- if (!resolved) return envelope({
9201
+ if (!resolved) return envelope_envelope({
8192
9202
  ok: false,
8193
9203
  command: inspect_schema_schema.name,
8194
9204
  status: "no-session",
@@ -8212,7 +9222,7 @@ async function inspect_handler(args) {
8212
9222
  const pageTargets = allTargets.filter((t)=>"page" === t.type && !t.url.startsWith("devtools://") && (!t.url.startsWith("chrome://") || isOverridePage(t.url)));
8213
9223
  if (0 === pageTargets.length) {
8214
9224
  const chromeOnly = allTargets.some((t)=>"page" === t.type && t.url.startsWith("chrome://"));
8215
- return envelope({
9225
+ return envelope_envelope({
8216
9226
  ok: false,
8217
9227
  command: inspect_schema_schema.name,
8218
9228
  status: "no-inspectable-target",
@@ -8231,7 +9241,17 @@ async function inspect_handler(args) {
8231
9241
  });
8232
9242
  }
8233
9243
  const isExtensionSurface = (u)=>u.startsWith("chrome-extension://") || u.startsWith("moz-extension://");
8234
- const target = args.url ? pageTargets.find((t)=>t.url.includes(args.url)) ?? pageTargets.find((t)=>!isExtensionSurface(t.url)) ?? pageTargets[0] : pageTargets[0];
9244
+ const defaultRank = (u)=>{
9245
+ if (isEngineCompanionUrl(u)) return 3;
9246
+ if (isOverridePage(u)) return 2;
9247
+ if (isExtensionSurface(u)) return 0;
9248
+ return 1;
9249
+ };
9250
+ const target = args.url ? pageTargets.find((t)=>t.url.includes(args.url)) ?? pageTargets.find((t)=>!isExtensionSurface(t.url)) ?? pageTargets[0] : pageTargets.map((t, index)=>({
9251
+ t,
9252
+ index,
9253
+ rank: defaultRank(t.url)
9254
+ })).sort((a, b)=>a.rank - b.rank || a.index - b.index)[0].t;
8235
9255
  const browserWsUrl = await CDPClient.discoverBrowserWsUrl(cdpPort);
8236
9256
  await cdp.connect(browserWsUrl);
8237
9257
  const sessionId = await cdp.attachToTarget(target.id);
@@ -8240,13 +9260,27 @@ async function inspect_handler(args) {
8240
9260
  await cdp.navigate(sessionId, args.url);
8241
9261
  await new Promise((r)=>setTimeout(r, 1500));
8242
9262
  } else await new Promise((r)=>setTimeout(r, 500));
9263
+ let documentUrl = "";
9264
+ let toolchainWarning = null;
9265
+ if (!args.url && defaultRank(target.url) >= 2) {
9266
+ try {
9267
+ const href = await cdp.evaluate(sessionId, "location.href");
9268
+ documentUrl = "string" == typeof href ? href : "";
9269
+ } catch {
9270
+ documentUrl = "";
9271
+ }
9272
+ toolchainWarning = isEngineCompanionUrl(documentUrl) || isEngineCompanionUrl(target.url) ? `Inspected ${target.url}, which is rendered by the Extension.js toolchain's own companion extension, not by this project. Pass url, or open one of the extension's surfaces first (extension_open), then inspect again.` : `No url was given and only override pages were open, so this inspected ${target.url}. Unless this extension provides that override itself, this is the toolchain's welcome surface, not your extension: pass url or open a surface with extension_open.`;
9273
+ }
8243
9274
  const result = {
8244
9275
  cdpPort,
8245
9276
  browser,
8246
9277
  target: {
8247
9278
  id: target.id,
8248
9279
  url: target.url,
8249
- title: target.title
9280
+ title: target.title,
9281
+ ...documentUrl && documentUrl !== target.url ? {
9282
+ documentUrl
9283
+ } : {}
8250
9284
  },
8251
9285
  targets: pageTargets.map((t)=>({
8252
9286
  id: t.id,
@@ -8294,17 +9328,18 @@ async function inspect_handler(args) {
8294
9328
  }
8295
9329
  const probeWarning = result.probeWarning;
8296
9330
  delete result.probeWarning;
8297
- return envelope({
9331
+ return envelope_envelope({
8298
9332
  ok: true,
8299
9333
  command: inspect_schema_schema.name,
8300
9334
  status: "inspected",
8301
9335
  value: result,
8302
9336
  warnings: [
8303
- "string" == typeof probeWarning ? probeWarning : null
9337
+ "string" == typeof probeWarning ? probeWarning : null,
9338
+ toolchainWarning
8304
9339
  ]
8305
9340
  });
8306
9341
  } catch (err) {
8307
- return envelope({
9342
+ return envelope_envelope({
8308
9343
  ok: false,
8309
9344
  command: inspect_schema_schema.name,
8310
9345
  status: "cdp-failed",
@@ -8347,8 +9382,7 @@ function list_extensions_unpackedExtensionId(distPath) {
8347
9382
  function readOwnIdentity(projectPath, browser) {
8348
9383
  let contract;
8349
9384
  try {
8350
- const file = node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json");
8351
- contract = JSON.parse(node_fs.readFileSync(file, "utf8"));
9385
+ contract = JSON.parse(node_fs.readFileSync(bridge_readyContractPath(projectPath, browser), "utf8"));
8352
9386
  } catch {
8353
9387
  return null;
8354
9388
  }
@@ -8379,7 +9413,7 @@ const UNRESOLVED_NOTE = "Identity unresolved: the browser's Extensions CDP domai
8379
9413
  async function list_extensions_handler(args) {
8380
9414
  const { browser } = resolveSessionBrowser(args.projectPath, args.browser, "chrome");
8381
9415
  if (isGeckoFamily(browser)) return listGeckoExtensions(args.projectPath, browser);
8382
- if (!isChromiumFamily(browser)) return envelope({
9416
+ if (!isChromiumFamily(browser)) return envelope_envelope({
8383
9417
  ok: false,
8384
9418
  command: list_extensions_schema.name,
8385
9419
  status: "unsupported-browser",
@@ -8390,7 +9424,7 @@ async function list_extensions_handler(args) {
8390
9424
  hint: "Target a Chromium-family (CDP) or Firefox-family (RDP) dev session."
8391
9425
  });
8392
9426
  const resolved = await resolveCdpPort(args.projectPath, browser);
8393
- if (!resolved) return envelope({
9427
+ if (!resolved) return envelope_envelope({
8394
9428
  ok: false,
8395
9429
  command: list_extensions_schema.name,
8396
9430
  status: "no-session",
@@ -8470,7 +9504,7 @@ async function list_extensions_handler(args) {
8470
9504
  return (a.name ?? a.id).localeCompare(b.name ?? b.id);
8471
9505
  });
8472
9506
  const ownEntry = extensions.find((e)=>e.ownExtension);
8473
- return envelope({
9507
+ return envelope_envelope({
8474
9508
  ok: true,
8475
9509
  command: list_extensions_schema.name,
8476
9510
  status: "listed",
@@ -8486,7 +9520,7 @@ async function list_extensions_handler(args) {
8486
9520
  ]
8487
9521
  });
8488
9522
  } catch (error) {
8489
- return envelope({
9523
+ return envelope_envelope({
8490
9524
  ok: false,
8491
9525
  command: list_extensions_schema.name,
8492
9526
  status: "cdp-failed",
@@ -8501,7 +9535,7 @@ async function list_extensions_handler(args) {
8501
9535
  }
8502
9536
  async function listGeckoExtensions(projectPath, browser) {
8503
9537
  const resolved = await resolveRdpPort(projectPath, browser);
8504
- if (!resolved) return envelope({
9538
+ if (!resolved) return envelope_envelope({
8505
9539
  ok: false,
8506
9540
  command: list_extensions_schema.name,
8507
9541
  status: "no-session",
@@ -8555,7 +9589,7 @@ async function listGeckoExtensions(projectPath, browser) {
8555
9589
  return (a.name ?? a.id).localeCompare(b.name ?? b.id);
8556
9590
  });
8557
9591
  const ownEntry = extensions.find((e)=>e.ownExtension);
8558
- return envelope({
9592
+ return envelope_envelope({
8559
9593
  ok: true,
8560
9594
  command: list_extensions_schema.name,
8561
9595
  status: "listed",
@@ -8571,7 +9605,7 @@ async function listGeckoExtensions(projectPath, browser) {
8571
9605
  ]
8572
9606
  });
8573
9607
  } catch (error) {
8574
- return envelope({
9608
+ return envelope_envelope({
8575
9609
  ok: false,
8576
9610
  command: list_extensions_schema.name,
8577
9611
  status: "rdp-failed",
@@ -8582,57 +9616,23 @@ async function listGeckoExtensions(projectPath, browser) {
8582
9616
  });
8583
9617
  }
8584
9618
  }
8585
- const CONTROL_WS_PATH = "/extjs-control";
8586
- const LEVEL_ORDER = [
8587
- "error",
8588
- "warn",
8589
- "info",
8590
- "debug",
8591
- "trace"
8592
- ];
8593
- function levelRank(level) {
8594
- const l = "log" === level ? "info" : level;
8595
- const i = LEVEL_ORDER.indexOf(l);
8596
- return -1 === i ? LEVEL_ORDER.length : i;
8597
- }
8598
- function makeUrlMatcher(pattern) {
8599
- const hasGlob = pattern.includes("*");
8600
- let re = null;
8601
- if (hasGlob) {
8602
- const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
8603
- re = new RegExp(escaped);
8604
- }
8605
- return (event)=>{
8606
- const candidates = [
8607
- event.url,
8608
- event.hostname
8609
- ].filter((v)=>"string" == typeof v);
8610
- if (0 === candidates.length) return false;
8611
- return candidates.some((c)=>re ? re.test(c) : c.includes(pattern));
8612
- };
8613
- }
9619
+ const CLOSE_REFUSAL_FLOOR = 4000;
9620
+ const DEFAULT_LIMIT = 200;
9621
+ const DEFAULT_FOLLOW_MS = 4000;
9622
+ const MIN_FOLLOW_MS = 500;
9623
+ const MAX_FOLLOW_MS = 15000;
8614
9624
  function makeFilter(args) {
8615
- const minLevel = String(args.level || "all").toLowerCase();
8616
- const rawContexts = Array.isArray(args.context) ? args.context : "string" == typeof args.context ? args.context.split(",") : null;
8617
- const contexts = rawContexts && rawContexts.length ? new Set(rawContexts.map((c)=>c.trim()).filter(Boolean)) : null;
8618
- const sinceSeq = null != args.since ? Number(args.since) : null;
8619
- const urlMatches = args.url ? makeUrlMatcher(args.url) : null;
8620
- const tabId = null != args.tab ? Number(args.tab) : null;
8621
- return (event)=>{
8622
- if (!event || "object" != typeof event) return false;
8623
- if ("header" === event.type) return false;
8624
- if (args.signalsOnly && "dx.signal" !== event.eventType) return false;
8625
- if (contexts && !contexts.has(event.context)) return false;
8626
- if ("off" === minLevel) {
8627
- if ("dx.signal" !== event.eventType) return false;
8628
- } else if ("all" !== minLevel) {
8629
- if (levelRank(event.level) > levelRank(minLevel)) return false;
8630
- }
8631
- if (null != sinceSeq && Number.isFinite(sinceSeq) && "number" == typeof event.seq && event.seq <= sinceSeq) return false;
8632
- if (urlMatches && !urlMatches(event)) return false;
8633
- if (null != tabId && Number.isFinite(tabId) && event.tabId !== tabId) return false;
8634
- return true;
8635
- };
9625
+ const level = String(args.level || "all").toLowerCase();
9626
+ const loggingOff = "off" === level;
9627
+ const query = {
9628
+ context: args.context,
9629
+ level: loggingOff ? "all" : level,
9630
+ signalsOnly: Boolean(args.signalsOnly) || loggingOff,
9631
+ since: args.since,
9632
+ url: args.url,
9633
+ tab: args.tab
9634
+ };
9635
+ return (event)=>matchesLogQuery(event, query);
8636
9636
  }
8637
9637
  const logs_schema_schema = {
8638
9638
  name: "extension_logs",
@@ -8702,12 +9702,12 @@ const logs_schema_schema = {
8702
9702
  },
8703
9703
  followMs: {
8704
9704
  type: "number",
8705
- default: 4000,
8706
- description: "How long to collect live frames when follow=true (clamped 500–15000ms)."
9705
+ default: DEFAULT_FOLLOW_MS,
9706
+ description: `How long to collect live frames when follow=true (clamped ${MIN_FOLLOW_MS}–${MAX_FOLLOW_MS}ms).`
8707
9707
  },
8708
9708
  limit: {
8709
9709
  type: "number",
8710
- default: 200,
9710
+ default: DEFAULT_LIMIT,
8711
9711
  description: "How many of the most recent events to return."
8712
9712
  }
8713
9713
  },
@@ -8717,23 +9717,6 @@ const logs_schema_schema = {
8717
9717
  }
8718
9718
  };
8719
9719
  const logs_TOOL = "extension_logs";
8720
- function logsFilePath(projectPath, browser) {
8721
- return node_path.resolve(projectPath, "dist", "extension-js", browser, "logs.ndjson");
8722
- }
8723
- function readReadyContract(projectPath, browser) {
8724
- const readyPath = node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json");
8725
- try {
8726
- const c = JSON.parse(node_fs.readFileSync(readyPath, "utf8"));
8727
- if ("number" != typeof c.controlPort || !c.instanceId) return null;
8728
- return {
8729
- controlPort: c.controlPort,
8730
- instanceId: String(c.instanceId),
8731
- runId: String(c.runId || "")
8732
- };
8733
- } catch {
8734
- return null;
8735
- }
8736
- }
8737
9720
  function capRecent(events, limit) {
8738
9721
  if (events.length <= limit) return {
8739
9722
  events,
@@ -8747,9 +9730,9 @@ function capRecent(events, limit) {
8747
9730
  function emptyReason(projectPath, browser) {
8748
9731
  let contract;
8749
9732
  try {
8750
- contract = JSON.parse(node_fs.readFileSync(node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json"), "utf8"));
9733
+ contract = JSON.parse(node_fs.readFileSync(bridge_readyContractPath(projectPath, browser), "utf8"));
8751
9734
  } catch {
8752
- return "No ready.json for this project/browser: no dev session has produced a build here, so there is nothing to log. Start one with extension_dev.";
9735
+ return `No ready.json for this project/browser: no dev session has produced a build here, so there is nothing to log. Start one with extension_dev. ${sessionPathHint(bridge_readyContractPath(projectPath, browser))}`;
8753
9736
  }
8754
9737
  if ("error" === contract.status) {
8755
9738
  const errs = contract.errors;
@@ -8767,7 +9750,7 @@ function summarize(events, source, browser, runId, limit, dropped, projectPath,
8767
9750
  const lastSeq = out.length ? out.reduce((m, e)=>"number" == typeof e.seq && e.seq > m ? e.seq : m, -1) : -1;
8768
9751
  const reason = 0 === matched && projectPath ? emptyReason(projectPath, browser) : void 0;
8769
9752
  const stale = Boolean(staleNote) && matched > 0;
8770
- return envelope({
9753
+ return envelope_envelope({
8771
9754
  ok: true,
8772
9755
  command: logs_TOOL,
8773
9756
  status: 0 === matched ? "empty" : stale ? "stale" : "read",
@@ -8792,7 +9775,7 @@ function summarize(events, source, browser, runId, limit, dropped, projectPath,
8792
9775
  function staleFileNote(projectPath, browser, eventsRunId) {
8793
9776
  let contract;
8794
9777
  try {
8795
- contract = JSON.parse(node_fs.readFileSync(node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json"), "utf8"));
9778
+ contract = JSON.parse(node_fs.readFileSync(bridge_readyContractPath(projectPath, browser), "utf8"));
8796
9779
  } catch {
8797
9780
  return "These events survive from a previous session: no ready.json exists for this project/browser now, so nothing current is producing logs.";
8798
9781
  }
@@ -8808,8 +9791,8 @@ function staleFileNote(projectPath, browser, eventsRunId) {
8808
9791
  if (eventsRunId && liveIds.length > 0 && !liveIds.includes(eventsRunId)) return `These events carry runId ${eventsRunId} but the current session is run ${liveIds.join(" / ")}, which has written nothing yet. Do not read these as the current run's output.`;
8809
9792
  }
8810
9793
  async function readFromFile(args, browser, limit) {
8811
- const file = logsFilePath(args.projectPath, browser);
8812
- if (!node_fs.existsSync(file)) return envelope({
9794
+ const file = logsPath(args.projectPath, browser);
9795
+ if (!node_fs.existsSync(file)) return envelope_envelope({
8813
9796
  ok: false,
8814
9797
  command: logs_TOOL,
8815
9798
  status: "no-log-file",
@@ -8817,7 +9800,7 @@ async function readFromFile(args, browser, limit) {
8817
9800
  code: "E_LOGS_MISSING",
8818
9801
  message: `No logs found at ${file}.`
8819
9802
  },
8820
- hint: "Start a dev session first (extension_dev), or pass browser to match it. For live frames before any line is written, use follow:true."
9803
+ hint: `Start a dev session first (extension_dev), or pass browser to match it. For live frames before any line is written, use follow:true. ${sessionPathHint(file)}`
8821
9804
  });
8822
9805
  const matches = makeFilter(args);
8823
9806
  const lines = node_fs.readFileSync(file, "utf8").split("\n").filter(Boolean);
@@ -8838,12 +9821,47 @@ async function readFromFile(args, browser, limit) {
8838
9821
  }
8839
9822
  return summarize(events, "file", browser, runId, limit, 0, args.projectPath, staleFileNote(args.projectPath, browser, runId));
8840
9823
  }
9824
+ function controlRefusal(closeCode, reason, url, sentVersion) {
9825
+ if (!Number.isFinite(closeCode) || closeCode < CLOSE_REFUSAL_FLOOR) return;
9826
+ const said = reason.trim();
9827
+ const preamble = `The dev server refused the control channel at ${url}: close code ${closeCode}${said ? ` ("${said}")` : ""}.`;
9828
+ if (closeCode === CLOSE_BAD_HELLO) return {
9829
+ code: "E_CONTROL_ENVELOPE",
9830
+ status: "control-channel-refused",
9831
+ message: `${preamble} That code is the broker rejecting the hello frame itself. This MCP dialed as a consumer with control envelope version ${sentVersion}, the version its pinned Extension.js speaks, and the role is one the broker always accepts, so the envelope version is the part that differs: the engine installed in this project is older or newer and speaks a different one. The session may be logging normally even though this read returned nothing.`,
9832
+ hint: "Update the project's Extension.js so its control envelope matches this MCP's pinned engine, or read the file instead by calling extension_logs without follow. extension_doctor reports both versions."
9833
+ };
9834
+ if (closeCode === CLOSE_BAD_INSTANCE) return {
9835
+ code: "E_STALE_CONTRACT",
9836
+ status: "control-channel-stale",
9837
+ message: `${preamble} That code means the instanceId this MCP sent belongs to a PREVIOUS dev session: the ready.json it read is stale, so the dev server was replaced after that file was written. Nothing here is a version problem, and the engine is not at fault; the session that is running now has an instance this read never named.`,
9838
+ hint: "Wait for the current session to publish its contract with extension_wait and retry, or start one with extension_dev if none is running. extension_stop clears a contract left behind by a session that has gone away."
9839
+ };
9840
+ if (closeCode === CLOSE_CONTROL_UNAVAILABLE) return {
9841
+ code: "E_NO_CONTROL_CHANNEL",
9842
+ status: "control-channel-unavailable",
9843
+ message: `${preamble} That code means the broker has no control channel to hand out: the session was started without allowControl, so it turns controlling clients away. Following logs is a read, not a control operation, so a session that refuses this connection cannot be streamed at all until it is relaunched.`,
9844
+ hint: "Relaunch the session with extension_dev and allowControl: true, or read the file instead by calling extension_logs without follow."
9845
+ };
9846
+ if (closeCode === CLOSE_SLOW_CONSUMER) return {
9847
+ code: "E_CONTROL_CHANNEL",
9848
+ status: "control-channel-dropped",
9849
+ message: `${preamble} That code means this reader fell far enough behind that the broker dropped it to protect itself. The session, the engine version and the control envelope are all fine: the log volume simply outran this follow window, and whatever came back stops at the moment of the drop rather than at the end of the window.`,
9850
+ hint: "Narrow the query before following again (level, context, url or tab), lower followMs, or read the completed file by calling extension_logs without follow."
9851
+ };
9852
+ return {
9853
+ code: "E_CONTROL_CHANNEL",
9854
+ status: "control-channel-refused",
9855
+ message: `${preamble} That code is an application-level refusal this MCP does not recognise, so the broker's own reason above is the whole diagnosis. It dialed as a consumer with control envelope version ${sentVersion}, the version its pinned Extension.js speaks; an engine newer than the pin can refuse for reasons this release has never seen.`,
9856
+ hint: "Read the file instead by calling extension_logs without follow, and compare the engine versions with extension_doctor."
9857
+ };
9858
+ }
8841
9859
  async function readFromStream(args, browser, limit) {
8842
9860
  const ready = readReadyContract(args.projectPath, browser);
8843
9861
  if (!ready) {
8844
9862
  const running = knownSessionBrowsers(args.projectPath).filter((b)=>b !== browser);
8845
9863
  const retarget = running.length ? `An active session exists for browser(s): ${running.join(", ")}, pass that as \`browser\`. Otherwise run` : "Run";
8846
- return envelope({
9864
+ return envelope_envelope({
8847
9865
  ok: false,
8848
9866
  command: logs_TOOL,
8849
9867
  status: "no-control-channel",
@@ -8854,7 +9872,7 @@ async function readFromStream(args, browser, limit) {
8854
9872
  hint: `${retarget} extension_dev (browser: ${browser}) and wait for it to be ready, then retry. For past logs without a live channel, call without follow.`
8855
9873
  });
8856
9874
  }
8857
- const followMs = Math.min(Math.max(args.followMs ?? 4000, 500), 15000);
9875
+ const followMs = Math.min(Math.max(args.followMs ?? DEFAULT_FOLLOW_MS, MIN_FOLLOW_MS), MAX_FOLLOW_MS);
8858
9876
  const matches = makeFilter(args);
8859
9877
  const events = [];
8860
9878
  let dropped = 0;
@@ -8866,7 +9884,7 @@ async function readFromStream(args, browser, limit) {
8866
9884
  try {
8867
9885
  socket = new ws_0(url);
8868
9886
  } catch (err) {
8869
- resolve(envelope({
9887
+ resolve(envelope_envelope({
8870
9888
  ok: false,
8871
9889
  command: logs_TOOL,
8872
9890
  status: "control-channel-failed",
@@ -8892,7 +9910,7 @@ async function readFromStream(args, browser, limit) {
8892
9910
  try {
8893
9911
  socket.send(JSON.stringify({
8894
9912
  type: "hello",
8895
- v: 1,
9913
+ v: CONTROL_ENVELOPE_VERSION,
8896
9914
  role: "consumer",
8897
9915
  instanceId: ready.instanceId
8898
9916
  }));
@@ -8922,7 +9940,7 @@ async function readFromStream(args, browser, limit) {
8922
9940
  try {
8923
9941
  socket.close();
8924
9942
  } catch {}
8925
- resolve(envelope({
9943
+ resolve(envelope_envelope({
8926
9944
  ok: false,
8927
9945
  command: logs_TOOL,
8928
9946
  status: "control-channel-failed",
@@ -8933,12 +9951,33 @@ async function readFromStream(args, browser, limit) {
8933
9951
  hint: "The dev session may have stopped or the control port changed. Re-check with extension_wait."
8934
9952
  }));
8935
9953
  });
8936
- socket.on("close", finish);
9954
+ socket.on("close", (code, reason)=>{
9955
+ if (settled) return;
9956
+ const refusal = controlRefusal(code, reason?.toString() ?? "", url, CONTROL_ENVELOPE_VERSION);
9957
+ if (!refusal) return void finish();
9958
+ if (events.length > 0 || dropped > 0) {
9959
+ streamNote = refusal.message;
9960
+ finish();
9961
+ return;
9962
+ }
9963
+ settled = true;
9964
+ clearTimeout(timer);
9965
+ resolve(envelope_envelope({
9966
+ ok: false,
9967
+ command: logs_TOOL,
9968
+ status: refusal.status,
9969
+ error: {
9970
+ code: refusal.code,
9971
+ message: refusal.message
9972
+ },
9973
+ hint: refusal.hint
9974
+ }));
9975
+ });
8937
9976
  });
8938
9977
  }
8939
9978
  async function logs_handler(args) {
8940
9979
  const { browser } = resolveSessionBrowser(args.projectPath, args.browser);
8941
- const limit = args.limit && args.limit > 0 ? args.limit : 200;
9980
+ const limit = args.limit && args.limit > 0 ? args.limit : DEFAULT_LIMIT;
8942
9981
  if (args.follow) return readFromStream(args, browser, limit);
8943
9982
  return readFromFile(args, browser, limit);
8944
9983
  }
@@ -9106,7 +10145,7 @@ async function storage_handler(args) {
9106
10145
  if (args.area) cli.push("--area", args.area);
9107
10146
  if (args.key) cli.push("--key", args.key);
9108
10147
  if ("set" === args.action) {
9109
- if (void 0 === args.value) return envelope({
10148
+ if (void 0 === args.value) return envelope_envelope({
9110
10149
  ok: false,
9111
10150
  command: storage_schema.name,
9112
10151
  status: "bad-request",
@@ -9116,7 +10155,7 @@ async function storage_handler(args) {
9116
10155
  message: "storage set requires a value"
9117
10156
  }
9118
10157
  });
9119
- if (void 0 === args.key) return envelope({
10158
+ if (void 0 === args.key) return envelope_envelope({
9120
10159
  ok: false,
9121
10160
  command: storage_schema.name,
9122
10161
  status: "bad-request",
@@ -9271,7 +10310,7 @@ const dom_snapshot_schema = {
9271
10310
  };
9272
10311
  async function cdpPortOrError(projectPath, browser, feature) {
9273
10312
  if (!isChromiumFamily(browser)) return {
9274
- error: envelope({
10313
+ error: envelope_envelope({
9275
10314
  ok: false,
9276
10315
  command: dom_snapshot_schema.name,
9277
10316
  status: "unsupported-browser",
@@ -9284,7 +10323,7 @@ async function cdpPortOrError(projectPath, browser, feature) {
9284
10323
  };
9285
10324
  const resolved = await resolveCdpPort(projectPath, browser);
9286
10325
  if (!resolved) return {
9287
- error: envelope({
10326
+ error: envelope_envelope({
9288
10327
  ok: false,
9289
10328
  command: dom_snapshot_schema.name,
9290
10329
  status: "no-session",
@@ -9305,7 +10344,7 @@ async function dom_snapshot_handler(args) {
9305
10344
  const { browser } = resolveSessionBrowser(args.projectPath, args.browser);
9306
10345
  if (isGeckoFamily(browser)) {
9307
10346
  const resolved = await resolveRdpPort(args.projectPath, browser);
9308
- if (!resolved) return envelope({
10347
+ if (!resolved) return envelope_envelope({
9309
10348
  ok: false,
9310
10349
  command: dom_snapshot_schema.name,
9311
10350
  status: "no-session",
@@ -9317,7 +10356,7 @@ async function dom_snapshot_handler(args) {
9317
10356
  });
9318
10357
  try {
9319
10358
  const tabs = await rdpListTabs(resolved.port);
9320
- return envelope({
10359
+ return envelope_envelope({
9321
10360
  ok: true,
9322
10361
  command: dom_snapshot_schema.name,
9323
10362
  status: "listed-targets",
@@ -9339,7 +10378,7 @@ async function dom_snapshot_handler(args) {
9339
10378
  ]
9340
10379
  });
9341
10380
  } catch (e) {
9342
- return envelope({
10381
+ return envelope_envelope({
9343
10382
  ok: false,
9344
10383
  command: dom_snapshot_schema.name,
9345
10384
  status: "rdp-failed",
@@ -9356,7 +10395,7 @@ async function dom_snapshot_handler(args) {
9356
10395
  if ("error" in cdp) return cdp.error;
9357
10396
  try {
9358
10397
  const targets = await listPageTargets(cdp.port);
9359
- return envelope({
10398
+ return envelope_envelope({
9360
10399
  ok: true,
9361
10400
  command: dom_snapshot_schema.name,
9362
10401
  status: "listed-targets",
@@ -9369,7 +10408,7 @@ async function dom_snapshot_handler(args) {
9369
10408
  ]
9370
10409
  });
9371
10410
  } catch (e) {
9372
- return envelope({
10411
+ return envelope_envelope({
9373
10412
  ok: false,
9374
10413
  command: dom_snapshot_schema.name,
9375
10414
  status: "cdp-failed",
@@ -9397,7 +10436,7 @@ async function dom_snapshot_handler(args) {
9397
10436
  let targetTab = args.tab;
9398
10437
  let resolvedTarget = null;
9399
10438
  if (args.tabUrl) {
9400
- if (null != args.tab || args.url) return envelope({
10439
+ if (null != args.tab || args.url) return envelope_envelope({
9401
10440
  ok: false,
9402
10441
  command: dom_snapshot_schema.name,
9403
10442
  status: "bad-request",
@@ -9415,7 +10454,7 @@ async function dom_snapshot_handler(args) {
9415
10454
  try {
9416
10455
  targets = await listPageTargets(cdp.port);
9417
10456
  } catch (e) {
9418
- return envelope({
10457
+ return envelope_envelope({
9419
10458
  ok: false,
9420
10459
  command: dom_snapshot_schema.name,
9421
10460
  status: "cdp-failed",
@@ -9428,7 +10467,7 @@ async function dom_snapshot_handler(args) {
9428
10467
  });
9429
10468
  }
9430
10469
  const matches = matchTargetsByUrl(targets, args.tabUrl);
9431
- if (0 === matches.length) return envelope({
10470
+ if (0 === matches.length) return envelope_envelope({
9432
10471
  ok: false,
9433
10472
  command: dom_snapshot_schema.name,
9434
10473
  status: "no-matching-target",
@@ -9442,7 +10481,7 @@ async function dom_snapshot_handler(args) {
9442
10481
  },
9443
10482
  hint: `Pick one from availableTargets and retry with a \`tabUrl\` substring of its url, or open the page first (extension_open with \`url\`). ${TARGET_ID_NOTE}`
9444
10483
  });
9445
- if (matches.length > 1) return envelope({
10484
+ if (matches.length > 1) return envelope_envelope({
9446
10485
  ok: false,
9447
10486
  command: dom_snapshot_schema.name,
9448
10487
  status: "ambiguous-target",
@@ -9464,7 +10503,7 @@ async function dom_snapshot_handler(args) {
9464
10503
  const listed = await listBridgeTabs(args.projectPath, browser, args.timeout);
9465
10504
  if ("error" in listed) return listed.error;
9466
10505
  const matches = matchTabsByUrl(listed.tabs, args.tabUrl);
9467
- if (0 === matches.length) return envelope({
10506
+ if (0 === matches.length) return envelope_envelope({
9468
10507
  ok: false,
9469
10508
  command: dom_snapshot_schema.name,
9470
10509
  status: "no-matching-target",
@@ -9478,7 +10517,7 @@ async function dom_snapshot_handler(args) {
9478
10517
  },
9479
10518
  hint: "Pick one from availableTabs and retry with a `tabUrl` substring of its url, or open the page first (extension_open with `url`)."
9480
10519
  });
9481
- if (matches.length > 1) return envelope({
10520
+ if (matches.length > 1) return envelope_envelope({
9482
10521
  ok: false,
9483
10522
  command: dom_snapshot_schema.name,
9484
10523
  status: "ambiguous-target",
@@ -9546,7 +10585,7 @@ const publish_schema = {
9546
10585
  }
9547
10586
  };
9548
10587
  function fail(name, message, status, code) {
9549
- return envelope({
10588
+ return envelope_envelope({
9550
10589
  ok: false,
9551
10590
  command: "extension_publish",
9552
10591
  status,
@@ -9573,16 +10612,22 @@ async function publish_handler(args) {
9573
10612
  api: args.api,
9574
10613
  token
9575
10614
  });
9576
- if (!result.ok) return envelope({
9577
- ok: false,
9578
- command: "extension_publish",
9579
- status: "publish-failed",
9580
- error: {
9581
- code: "E_PLATFORM",
9582
- name: result.error.name,
9583
- message: result.error.message
9584
- }
9585
- });
10615
+ if (!result.ok) {
10616
+ const projectMissing = /\(404\)/.test(result.error.message);
10617
+ return envelope_envelope({
10618
+ ok: false,
10619
+ command: "extension_publish",
10620
+ status: "publish-failed",
10621
+ error: {
10622
+ code: "E_PLATFORM",
10623
+ name: result.error.name,
10624
+ message: result.error.message
10625
+ },
10626
+ ...projectMissing ? {
10627
+ hint: "The token's project does not exist on the host this call targeted. Run extension_auth (action: status) to see which workspace/project the token is scoped to, create that project first (import a repo or a template at extension.dev/new), or run extension_auth (action: login) against a project that exists there."
10628
+ } : {}
10629
+ });
10630
+ }
9586
10631
  const data = result.data;
9587
10632
  let note = null;
9588
10633
  if (null != args.ttlHours && "public" === data.visibility) note = "ttlHours was ignored: this is a public project, whose share URL is its canonical public page.";
@@ -9627,7 +10672,7 @@ async function publish_handler(args) {
9627
10672
  }
9628
10673
  }
9629
10674
  }
9630
- return envelope({
10675
+ return envelope_envelope({
9631
10676
  ok: true,
9632
10677
  command: "extension_publish",
9633
10678
  status: "published",
@@ -9680,7 +10725,7 @@ const release_promote_schema = {
9680
10725
  }
9681
10726
  };
9682
10727
  function release_promote_fail(name, message, status, code) {
9683
- return envelope({
10728
+ return envelope_envelope({
9684
10729
  ok: false,
9685
10730
  command: "extension_release_promote",
9686
10731
  status,
@@ -9697,7 +10742,7 @@ async function release_promote_handler(args) {
9697
10742
  const buildId = String(args.buildId || "").trim();
9698
10743
  const channel = String(args.channel || "").trim();
9699
10744
  if (!buildId || !channel) return release_promote_fail("ReleaseInputError", "buildId and channel are required.", "bad-request", "E_BAD_REQUEST");
9700
- const apiCheck = safeApiBase(resolveApiBase(args.api));
10745
+ const apiCheck = safeApiBase(resolveApiBase(args.api), args.api);
9701
10746
  if (!apiCheck.ok) return release_promote_fail("ReleaseConfigError", apiCheck.message, "bad-config", "E_CONFIG");
9702
10747
  const url = `${apiCheck.base}/api/cli/release/promote`;
9703
10748
  const body = {
@@ -9754,7 +10799,7 @@ async function release_promote_handler(args) {
9754
10799
  }
9755
10800
  }
9756
10801
  }
9757
- return envelope({
10802
+ return envelope_envelope({
9758
10803
  ok: false,
9759
10804
  command: "extension_release_promote",
9760
10805
  status: "promote-failed",
@@ -9782,7 +10827,7 @@ async function release_promote_handler(args) {
9782
10827
  publicBuildUrl
9783
10828
  } : {}
9784
10829
  } : data;
9785
- return envelope({
10830
+ return envelope_envelope({
9786
10831
  ok: true,
9787
10832
  command: "extension_release_promote",
9788
10833
  status: "promoted",
@@ -9790,7 +10835,7 @@ async function release_promote_handler(args) {
9790
10835
  });
9791
10836
  }
9792
10837
  function release_list_fail(name, message, status, code, extra) {
9793
- return envelope({
10838
+ return envelope_envelope({
9794
10839
  ok: false,
9795
10840
  command: "extension_release_status",
9796
10841
  status,
@@ -9847,7 +10892,7 @@ async function readReleases(args) {
9847
10892
  const publicUrlNote = publicProjectUrl ? isPrivate ? "publicUrl links open only for workspace members. This project is private, so an outside recipient needs a share link from extension_publish." : "publicUrl links are the public build pages: no login needed, and they carry the per-browser downloads and the run locally instructions." : null;
9848
10893
  const channelsUnavailable = channelsRes.ok ? null : `channels.json unreadable: ${channelsRes.message}`;
9849
10894
  const buildsUnavailable = buildsRes.ok ? null : `builds/index.json unreadable: ${buildsRes.message}`;
9850
- return envelope({
10895
+ return envelope_envelope({
9851
10896
  ok: true,
9852
10897
  command: "extension_release_status",
9853
10898
  status: "read",
@@ -9961,7 +11006,7 @@ function normalizeStoresStatus(json) {
9961
11006
  return out;
9962
11007
  }
9963
11008
  function store_status_fail(name, message, status, code, extra) {
9964
- return envelope({
11009
+ return envelope_envelope({
9965
11010
  ok: false,
9966
11011
  command: "extension_release_status",
9967
11012
  status,
@@ -10053,7 +11098,7 @@ async function readStores(args) {
10053
11098
  const healthUnavailable = healthRes.ok ? null : `stores/health.json unreadable: ${healthRes.message}`;
10054
11099
  const statusUnavailable = statusRes.ok ? null : `stores/status.json unreadable: ${statusRes.message}`;
10055
11100
  const submissionsUnavailable = submissionsRes.ok || 404 === submissionsRes.status ? null : `stores/submissions.json unreadable: ${submissionsRes.message}`;
10056
- return envelope({
11101
+ return envelope_envelope({
10057
11102
  ok: true,
10058
11103
  command: "extension_release_status",
10059
11104
  status: "read",
@@ -10151,7 +11196,7 @@ async function release_status_handler(args) {
10151
11196
  stores
10152
11197
  ].filter(Boolean);
10153
11198
  const ok = sections.some((section)=>true === section.ok);
10154
- return envelope({
11199
+ return envelope_envelope({
10155
11200
  ok,
10156
11201
  command: "extension_release_status",
10157
11202
  status: ok ? "read" : "unavailable",
@@ -10240,7 +11285,7 @@ const submit_schema = {
10240
11285
  }
10241
11286
  };
10242
11287
  function submit_fail(name, message, status, code) {
10243
- return envelope({
11288
+ return envelope_envelope({
10244
11289
  ok: false,
10245
11290
  command: "extension_submit",
10246
11291
  status,
@@ -10258,7 +11303,7 @@ async function submit_handler(args) {
10258
11303
  if (0 === browsers.length) return submit_fail("SubmitInputError", 'browsers is required (e.g. ["chrome","firefox","edge","safari"]).', "bad-request", "E_BAD_REQUEST");
10259
11304
  const buildSha = String(args.buildSha || "").trim();
10260
11305
  if (!buildSha) return submit_fail("SubmitInputError", "buildSha is required (the built commit to submit).", "bad-request", "E_BAD_REQUEST");
10261
- const apiCheck = safeApiBase(resolveApiBase(args.api));
11306
+ const apiCheck = safeApiBase(resolveApiBase(args.api), args.api);
10262
11307
  if (!apiCheck.ok) return submit_fail("SubmitConfigError", apiCheck.message, "bad-config", "E_CONFIG");
10263
11308
  const url = `${apiCheck.base}/api/cli/stores/submit`;
10264
11309
  const dryRun = false !== args.dryRun;
@@ -10390,7 +11435,7 @@ async function submit_handler(args) {
10390
11435
  message = summaryParts.join(" ");
10391
11436
  }
10392
11437
  if (!dryRun) statusNote = "Track this submission with extension_release_status: it reads the recorded outcome, per-store credential health, and review state from the public registry.";
10393
- return envelope({
11438
+ return envelope_envelope({
10394
11439
  ok,
10395
11440
  command: "extension_submit",
10396
11441
  status: dryRun ? "preflight" : "submitted",
@@ -10403,9 +11448,9 @@ async function submit_handler(args) {
10403
11448
  ]
10404
11449
  });
10405
11450
  }
10406
- function doctor_readReadyContract(projectPath, browser) {
11451
+ function readContractForDiagnosis(projectPath, browser) {
10407
11452
  try {
10408
- const raw = node_fs.readFileSync(node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json"), "utf8");
11453
+ const raw = node_fs.readFileSync(bridge_readyContractPath(projectPath, browser), "utf8");
10409
11454
  return JSON.parse(raw);
10410
11455
  } catch {
10411
11456
  return null;
@@ -10457,7 +11502,7 @@ async function environmentPreflight() {
10457
11502
  detail: cacheExists ? `Template catalog cached at ${cacheFile}` : "Template catalog not cached yet (extension_templates will fetch it)"
10458
11503
  });
10459
11504
  const healthy = checks.every((c)=>"fail" !== c.status);
10460
- return envelope({
11505
+ return envelope_envelope({
10461
11506
  ok: healthy,
10462
11507
  command: doctor_schema.name,
10463
11508
  status: healthy ? "healthy" : "unhealthy",
@@ -10477,22 +11522,11 @@ function safeStringify(value) {
10477
11522
  }
10478
11523
  }
10479
11524
  function recentErrorLogs(projectPath, browser, max = 5) {
10480
- const file = node_path.resolve(projectPath, "dist", "extension-js", browser, "logs.ndjson");
10481
- let lines;
10482
- try {
10483
- lines = node_fs.readFileSync(file, "utf8").split("\n").filter(Boolean);
10484
- } catch {
10485
- return [];
10486
- }
10487
11525
  const errs = [];
10488
- for (const line of lines){
10489
- let ev;
10490
- try {
10491
- ev = JSON.parse(line);
10492
- } catch {
10493
- continue;
10494
- }
10495
- if (!ev || "error" !== ev.level) continue;
11526
+ for (const event of readLogEvents(projectPath, browser, {
11527
+ level: "error"
11528
+ })){
11529
+ const ev = event;
10496
11530
  const parts = Array.isArray(ev.messageParts) ? ev.messageParts : Array.isArray(ev.args) ? ev.args : null;
10497
11531
  let msg = parts ? parts.map((p)=>"string" == typeof p ? p : safeStringify(p)).join(" ") : ev.message || ev.text || "";
10498
11532
  if (!msg && ev.errorName) msg = ev.stack ? `${ev.errorName}: ${ev.stack}` : ev.errorName;
@@ -10528,6 +11562,17 @@ async function doctor_handler(args) {
10528
11562
  ], {
10529
11563
  cwd: projectPath
10530
11564
  });
11565
+ if (refusedTheOutputFlag(stderr ?? "")) return envelope_envelope({
11566
+ ok: false,
11567
+ command: doctor_schema.name,
11568
+ status: "engine-too-old",
11569
+ error: {
11570
+ code: "E_ENGINE_TOO_OLD",
11571
+ name: "CliError",
11572
+ message: await outputFlagRefusalMessage("doctor", "doctor", projectPath)
11573
+ },
11574
+ hint: "Until then, extension_logs without follow still reads this project's log file, and the session's ready.json still records how the last build ended."
11575
+ });
10531
11576
  const out = stdout.trim();
10532
11577
  try {
10533
11578
  const parsed = JSON.parse(out);
@@ -10538,7 +11583,7 @@ async function doctor_handler(args) {
10538
11583
  if ("string" == typeof check.remediation) check.remediation = toMcpSpeak(check.remediation);
10539
11584
  }
10540
11585
  let healthy = 0 === code;
10541
- const contract = doctor_readReadyContract(projectPath, browser);
11586
+ const contract = readContractForDiagnosis(projectPath, browser);
10542
11587
  if (contract?.status === "error") {
10543
11588
  healthy = false;
10544
11589
  const browserExited = "browser_exited" === contract.code || void 0 !== contract.browserExitCode;
@@ -10574,7 +11619,7 @@ async function doctor_handler(args) {
10574
11619
  } : {}
10575
11620
  });
10576
11621
  }
10577
- return envelope({
11622
+ return envelope_envelope({
10578
11623
  ok: healthy,
10579
11624
  command: doctor_schema.name,
10580
11625
  status: healthy ? "healthy" : "unhealthy",
@@ -10588,7 +11633,8 @@ async function doctor_handler(args) {
10588
11633
  });
10589
11634
  } catch {
10590
11635
  const message = stderr.trim() || `extension exited with code ${code}`;
10591
- return envelope({
11636
+ const cliReport = toMcpSpeak(out).trim().slice(0, 4000);
11637
+ return envelope_envelope({
10592
11638
  ok: false,
10593
11639
  command: doctor_schema.name,
10594
11640
  status: "cli-failed",
@@ -10597,7 +11643,13 @@ async function doctor_handler(args) {
10597
11643
  name: "CliError",
10598
11644
  message: toMcpSpeak(message)
10599
11645
  },
10600
- hint: "extension doctor requires a recent extension CLI, the project's local install may predate it."
11646
+ value: {
11647
+ browser,
11648
+ ...cliReport ? {
11649
+ cliReport
11650
+ } : {}
11651
+ },
11652
+ hint: cliReport ? "cliReport is the doctor's own output, unparsed: read the failing checks and remediations there." : "extension doctor requires a recent extension CLI, the project's local install may predate it."
10601
11653
  });
10602
11654
  }
10603
11655
  }
@@ -10641,7 +11693,7 @@ async function wait_handler(args) {
10641
11693
  const requested = args.timeoutMs ?? args.timeout ?? DEFAULT_TIMEOUT_MS;
10642
11694
  const budgetMs = Math.min(Math.max(requested, MIN_TIMEOUT_MS), SAFE_CEILING_MS);
10643
11695
  const clamped = requested > SAFE_CEILING_MS;
10644
- const readyPath = node_path.resolve(args.projectPath, "dist", "extension-js", browser, "ready.json");
11696
+ const readyPath = bridge_readyContractPath(args.projectPath, browser);
10645
11697
  const buildOnly = findSessionInfo(args.projectPath, browser)?.noBrowser === true;
10646
11698
  const since = sessionSinceMs(args.projectPath, browser);
10647
11699
  const start = Date.now();
@@ -10663,7 +11715,7 @@ async function wait_handler(args) {
10663
11715
  }
10664
11716
  lastContractStatus = contract.status;
10665
11717
  if ("ready" === contract.status) {
10666
- if ("number" == typeof contract.pid && !wait_isAlive(contract.pid)) return envelope({
11718
+ if ("number" == typeof contract.pid && !wait_isAlive(contract.pid)) return envelope_envelope({
10667
11719
  ok: false,
10668
11720
  command: wait_schema.name,
10669
11721
  status: "stale",
@@ -10679,7 +11731,7 @@ async function wait_handler(args) {
10679
11731
  }
10680
11732
  });
10681
11733
  const attached = "attached" === contract.runtime || "string" == typeof contract.executorAttachedAt;
10682
- if (!attached && buildOnly) return envelope({
11734
+ if (!attached && buildOnly) return envelope_envelope({
10683
11735
  ok: true,
10684
11736
  command: wait_schema.name,
10685
11737
  status: "ready",
@@ -10710,7 +11762,7 @@ async function wait_handler(args) {
10710
11762
  const warnings = [];
10711
11763
  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.`);
10712
11764
  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.");
10713
- return envelope({
11765
+ return envelope_envelope({
10714
11766
  ok: true,
10715
11767
  command: wait_schema.name,
10716
11768
  status: "ready",
@@ -10740,7 +11792,7 @@ async function wait_handler(args) {
10740
11792
  warnings
10741
11793
  });
10742
11794
  }
10743
- if ("error" === contract.status) return envelope({
11795
+ if ("error" === contract.status) return envelope_envelope({
10744
11796
  ok: false,
10745
11797
  command: wait_schema.name,
10746
11798
  status: "contract-error",
@@ -10759,7 +11811,7 @@ async function wait_handler(args) {
10759
11811
  } catch {}
10760
11812
  await new Promise((resolve)=>setTimeout(resolve, pollInterval));
10761
11813
  }
10762
- if (sawCompiledButUnattached) return envelope({
11814
+ if (sawCompiledButUnattached) return envelope_envelope({
10763
11815
  ok: false,
10764
11816
  command: wait_schema.name,
10765
11817
  status: "compiled-not-attached",
@@ -10776,7 +11828,7 @@ async function wait_handler(args) {
10776
11828
  },
10777
11829
  hint: "This is usually transient: call extension_wait again. If it persists, stop and restart the session with extension_dev (a restart reliably reattaches); extension_doctor reports the executor leg."
10778
11830
  });
10779
- return envelope({
11831
+ return envelope_envelope({
10780
11832
  ok: false,
10781
11833
  command: wait_schema.name,
10782
11834
  status: "timeout",
@@ -10954,7 +12006,7 @@ async function add_feature_handler(args) {
10954
12006
  const projectPath = node_path.resolve(args.projectPath);
10955
12007
  const srcDir = node_path.join(projectPath, "src");
10956
12008
  const manifestPath = node_path.join(srcDir, "manifest.json");
10957
- if (!node_fs.existsSync(manifestPath)) return envelope({
12009
+ if (!node_fs.existsSync(manifestPath)) return envelope_envelope({
10958
12010
  ok: false,
10959
12011
  command: add_feature_COMMAND,
10960
12012
  status: "manifest-not-found",
@@ -10965,7 +12017,7 @@ async function add_feature_handler(args) {
10965
12017
  hint: "Ensure projectPath points to an extension project root with src/manifest.json"
10966
12018
  });
10967
12019
  const templateSlug = FEATURE_TEMPLATE_MAP[args.feature]?.[framework];
10968
- if (!templateSlug) return envelope({
12020
+ if (!templateSlug) return envelope_envelope({
10969
12021
  ok: false,
10970
12022
  command: add_feature_COMMAND,
10971
12023
  status: "no-reference-template",
@@ -11048,7 +12100,7 @@ async function add_feature_handler(args) {
11048
12100
  });
11049
12101
  const conflicts = filesToCreate.filter((f)=>node_fs.existsSync(node_path.join(projectPath, f.path)));
11050
12102
  const conflictHint = `Warning: ${conflicts.length} file(s) already exist and would be overwritten.`;
11051
- return envelope({
12103
+ return envelope_envelope({
11052
12104
  ok: true,
11053
12105
  command: add_feature_COMMAND,
11054
12106
  status: conflicts.length ? "planned-with-conflicts" : "planned",
@@ -11191,7 +12243,7 @@ const FIRST_CALL_BUDGET_MS = 8000;
11191
12243
  const RESUME_BUDGET_MS = 22000;
11192
12244
  const PENDING_TTL_NOTE = "Once authorized, the minted token lives at most 7 days (server-enforced); CI must re-mint before expiry (console: project settings -> Access tokens).";
11193
12245
  function login_fail(name, message, status, code) {
11194
- return envelope({
12246
+ return envelope_envelope({
11195
12247
  ok: false,
11196
12248
  command: "extension_auth",
11197
12249
  status,
@@ -11204,7 +12256,7 @@ function login_fail(name, message, status, code) {
11204
12256
  }
11205
12257
  function success(creds) {
11206
12258
  const expiresAt = creds.expiresAt ? new Date(1000 * creds.expiresAt).toISOString() : null;
11207
- return envelope({
12259
+ return envelope_envelope({
11208
12260
  ok: true,
11209
12261
  command: "extension_auth",
11210
12262
  status: "logged-in",
@@ -11223,7 +12275,7 @@ function login_pending(start) {
11223
12275
  const complete = String(start.verificationUriComplete || "").trim();
11224
12276
  const hasCompleteLink = complete.length > 0 && complete !== start.verificationUri;
11225
12277
  const message = hasCompleteLink ? `Open ${complete} and approve (code ${start.userCode} is pre-filled), then call extension_auth (action: login) again with this deviceCode and the same project. If the page asks for a code, enter ${start.userCode} at ${start.verificationUri}.` : `Open ${start.verificationUri} and enter code ${start.userCode}, then call extension_auth (action: login) again with this deviceCode and the same project.`;
11226
- return envelope({
12278
+ return envelope_envelope({
11227
12279
  ok: false,
11228
12280
  command: "extension_auth",
11229
12281
  status: "authorization-pending",
@@ -11248,7 +12300,7 @@ function login_pending(start) {
11248
12300
  }
11249
12301
  function resumePending(deviceCode, verificationUri) {
11250
12302
  const message = `Still waiting for authorization. The one-click link and code from the previous response are still valid: open that link (or enter the code at ${verificationUri}), then call extension_auth (action: login) again with this same deviceCode and the same project.`;
11251
- return envelope({
12303
+ return envelope_envelope({
11252
12304
  ok: false,
11253
12305
  command: "extension_auth",
11254
12306
  status: "authorization-pending",
@@ -11270,7 +12322,7 @@ function resumePending(deviceCode, verificationUri) {
11270
12322
  async function loginToProject(args) {
11271
12323
  const project = String(args.project || "").trim();
11272
12324
  if (!/^[^/]+\/[^/]+$/.test(project)) return login_fail("BadRequest", "project must be in the form '<workspace>/<project>'.", "bad-request", "E_BAD_REQUEST");
11273
- const apiCheck = safeApiBase(resolveApiBase(args.api));
12325
+ const apiCheck = safeApiBase(resolveApiBase(args.api), args.api);
11274
12326
  if (!apiCheck.ok) return login_fail("LoginConfigError", apiCheck.message, "login-failed", "E_AUTH_FAILED");
11275
12327
  const apiBase = apiCheck.base;
11276
12328
  let config;
@@ -11323,9 +12375,92 @@ async function loginToProject(args) {
11323
12375
  verificationUriComplete: start.verificationUriComplete
11324
12376
  });
11325
12377
  }
11326
- async function readIdentity() {
12378
+ const server_identity_DEFAULT_TIMEOUT_MS = 5000;
12379
+ async function askServerIdentity(args) {
12380
+ const fetchImpl = args.fetchImpl ?? fetch;
12381
+ const controller = new AbortController();
12382
+ const timer = setTimeout(()=>controller.abort(), args.timeoutMs ?? server_identity_DEFAULT_TIMEOUT_MS);
12383
+ try {
12384
+ const res = await fetchImpl(`${args.apiBase.replace(/\/+$/, "")}/api/cli/whoami`, {
12385
+ headers: {
12386
+ authorization: `Bearer ${args.token}`,
12387
+ accept: "application/json"
12388
+ },
12389
+ signal: controller.signal
12390
+ });
12391
+ if (200 === res.status) {
12392
+ const data = await res.json().catch(()=>null);
12393
+ const login = String(data?.login || "").trim();
12394
+ if (!login) return {
12395
+ kind: "unavailable",
12396
+ detail: "the server answered 200 without a login"
12397
+ };
12398
+ return {
12399
+ kind: "confirmed",
12400
+ login,
12401
+ live: data?.live === true
12402
+ };
12403
+ }
12404
+ if (401 === res.status) return {
12405
+ kind: "refused"
12406
+ };
12407
+ return {
12408
+ kind: "unavailable",
12409
+ detail: `the server answered ${res.status}, which is not a verdict on this credential`
12410
+ };
12411
+ } catch (error) {
12412
+ return {
12413
+ kind: "unavailable",
12414
+ detail: error?.message || "network error"
12415
+ };
12416
+ } finally{
12417
+ clearTimeout(timer);
12418
+ }
12419
+ }
12420
+ function describeServer(check, api) {
12421
+ if ("confirmed" === check.kind) return {
12422
+ status: "logged-in",
12423
+ note: `The server at ${api} confirms this token: it resolves to ${check.login} and is live there.`,
12424
+ warning: null,
12425
+ value: {
12426
+ verdict: "confirmed",
12427
+ api,
12428
+ login: check.login,
12429
+ live: check.live
12430
+ }
12431
+ };
12432
+ if ("refused" === check.kind) return {
12433
+ status: "refused-by-server",
12434
+ note: `The server at ${api} refused this credential: it does not resolve to an identity there (expired, revoked, or minted for another environment). The workspace/project above is only what the local file claims. Run extension_auth (action: login) to re-authenticate.`,
12435
+ warning: null,
12436
+ value: {
12437
+ verdict: "refused",
12438
+ api
12439
+ }
12440
+ };
12441
+ if ("unavailable" === check.kind) return {
12442
+ status: "logged-in",
12443
+ note: null,
12444
+ warning: `Could not verify this login with the server at ${api} (${check.detail}). The identity above is the local file's claim only, not server-confirmed.`,
12445
+ value: {
12446
+ verdict: "unavailable",
12447
+ api,
12448
+ detail: check.detail
12449
+ }
12450
+ };
12451
+ return {
12452
+ status: "expired",
12453
+ note: null,
12454
+ warning: null,
12455
+ value: {
12456
+ verdict: "not-asked",
12457
+ detail: check.detail
12458
+ }
12459
+ };
12460
+ }
12461
+ async function readIdentity(deps) {
11327
12462
  const creds = readCredentials();
11328
- if (!creds) return envelope({
12463
+ if (!creds) return envelope_envelope({
11329
12464
  ok: true,
11330
12465
  command: "extension_auth",
11331
12466
  status: "logged-out",
@@ -11337,19 +12472,39 @@ async function readIdentity() {
11337
12472
  const recordedApi = String(creds.api || "").trim();
11338
12473
  const effectiveDefaultApi = resolveApiBase();
11339
12474
  const apiDiverges = Boolean(recordedApi) && recordedApi !== effectiveDefaultApi;
12475
+ const askApi = recordedApi || effectiveDefaultApi;
11340
12476
  const envTokenSet = Boolean(String(process.env.EXTENSION_DEV_TOKEN || "").trim());
12477
+ let check;
12478
+ if (expired) check = {
12479
+ kind: "not-asked",
12480
+ detail: "the stored token has already expired locally"
12481
+ };
12482
+ else {
12483
+ const safe = safeApiBase(askApi);
12484
+ check = safe.ok ? await askServerIdentity({
12485
+ apiBase: safe.base,
12486
+ token: creds.token,
12487
+ fetchImpl: deps?.fetchImpl
12488
+ }) : {
12489
+ kind: "unavailable",
12490
+ detail: safe.message
12491
+ };
12492
+ }
12493
+ const server = describeServer(check, askApi);
11341
12494
  const identityNote = expired ? "The stored token has expired. Run extension_auth (action: login) to refresh it." : `Logged in as ${creds.workspaceSlug}/${creds.projectSlug}, per the token extension_auth stored on this machine. That token is what scopes the identity: it does not follow the current working directory or project folder.`;
11342
12495
  const apiDivergesNote = apiDiverges ? `This login was minted via ${recordedApi}: access grants for private registry reads use that recorded base when no api argument is given, while other authenticated tools target ${effectiveDefaultApi} unless given one.` : null;
11343
12496
  const envTokenNote = envTokenSet ? "EXTENSION_DEV_TOKEN is set and takes precedence over this stored login for authenticated tools; this report describes only the stored login." : null;
11344
12497
  const message = [
11345
12498
  identityNote,
12499
+ server.note,
12500
+ server.warning,
11346
12501
  apiDivergesNote,
11347
12502
  envTokenNote
11348
12503
  ].filter(Boolean).join(" ");
11349
- return envelope({
12504
+ return envelope_envelope({
11350
12505
  ok: true,
11351
12506
  command: "extension_auth",
11352
- status: expired ? "expired" : "logged-in",
12507
+ status: expired ? "expired" : server.status,
11353
12508
  value: {
11354
12509
  workspaceSlug: creds.workspaceSlug,
11355
12510
  projectSlug: creds.projectSlug,
@@ -11360,11 +12515,13 @@ async function readIdentity() {
11360
12515
  provider: creds.provider ?? "extensiondev",
11361
12516
  expiresAt: creds.expiresAt ? new Date(1000 * creds.expiresAt).toISOString() : null,
11362
12517
  expiresInSeconds: creds.expiresAt ? creds.expiresAt - now : null,
11363
- expired
12518
+ expired,
12519
+ server: server.value
11364
12520
  },
11365
12521
  hint: message,
11366
12522
  warnings: [
11367
12523
  tokenTtlNote(creds.workspaceSlug, creds.projectSlug),
12524
+ server.warning,
11368
12525
  apiDivergesNote,
11369
12526
  envTokenNote
11370
12527
  ]
@@ -11377,7 +12534,7 @@ async function clearLocalCredentials() {
11377
12534
  project: creds.projectSlug
11378
12535
  }, "settings/access-tokens") : null;
11379
12536
  const result = clearCredentials();
11380
- return envelope({
12537
+ return envelope_envelope({
11381
12538
  ok: true,
11382
12539
  command: "extension_auth",
11383
12540
  status: result.cleared ? "logged-out" : "nothing-to-clear",
@@ -11390,7 +12547,7 @@ async function clearLocalCredentials() {
11390
12547
  }
11391
12548
  const auth_schema = {
11392
12549
  name: "extension_auth",
11393
- description: "Sign this machine in to extension.dev, report that login, or clear it. Pass action:'status' (the default) to name the workspace and project the stored token is scoped to and when it expires, never the token itself; that identity comes from the stored token alone, and does not change with the current working directory or whichever project folder you are in. Pass action:'login' for a two-phase flow: call with `project` to get a code plus a URL the user authorizes at extension.dev/device, then call again with the returned `deviceCode`. GitHub federation happens server-side, so no GitHub token lands on this machine. Minted tokens live at most 7 days, server-enforced, so CI must re-mint before expiry on the console's project settings, Access tokens page. Pass action:'logout' to delete the local credentials only; the token stays valid server-side until it is revoked at the URL the response returns.",
12550
+ description: "Sign this machine in to extension.dev, report that login, or clear it. Pass action:'status' (the default) to name the workspace and project the stored token is scoped to and when it expires, never the token itself; that identity comes from the stored token alone, and does not change with the current working directory or whichever project folder you are in. Status also asks the platform's whoami endpoint whether that credential actually resolves there: the answer is reported as confirmed, refused-by-server, or unverified when the server cannot be reached, so a local file claiming a login the server would refuse is never reported as simply logged in. Pass action:'login' for a two-phase flow: call with `project` to get a code plus a URL the user authorizes at extension.dev/device, then call again with the returned `deviceCode`. GitHub federation happens server-side, so no GitHub token lands on this machine. Minted tokens live at most 7 days, server-enforced, so CI must re-mint before expiry on the console's project settings, Access tokens page. Pass action:'logout' to delete the local credentials only; the token stays valid server-side until it is revoked at the URL the response returns.",
11394
12551
  inputSchema: {
11395
12552
  type: "object",
11396
12553
  properties: {
@@ -11710,7 +12867,7 @@ async function detectBrowsers(browsers) {
11710
12867
  }
11711
12868
  const available = detected.filter((d)=>"not_found" !== d.source);
11712
12869
  const missing = detected.filter((d)=>"not_found" === d.source);
11713
- return envelope({
12870
+ return envelope_envelope({
11714
12871
  ok: true,
11715
12872
  command: "extension_browsers",
11716
12873
  status: "detected",
@@ -11767,7 +12924,7 @@ async function listManagedBrowsers() {
11767
12924
  });
11768
12925
  }
11769
12926
  }
11770
- return envelope({
12927
+ return envelope_envelope({
11771
12928
  ok: true,
11772
12929
  command: "extension_browsers",
11773
12930
  status: "listed",
@@ -11786,7 +12943,7 @@ async function installManagedBrowser(browser) {
11786
12943
  await extensionInstall({
11787
12944
  browser
11788
12945
  });
11789
- return envelope({
12946
+ return envelope_envelope({
11790
12947
  ok: true,
11791
12948
  command: "extension_browsers",
11792
12949
  status: "installed",
@@ -11797,7 +12954,7 @@ async function installManagedBrowser(browser) {
11797
12954
  hint: `Browser "${browser}" is now available. Use extension_dev or extension_start with browser: "${browser}".`
11798
12955
  });
11799
12956
  } catch (err) {
11800
- return envelope({
12957
+ return envelope_envelope({
11801
12958
  ok: false,
11802
12959
  command: "extension_browsers",
11803
12960
  status: "install-failed",
@@ -11815,7 +12972,7 @@ async function installManagedBrowser(browser) {
11815
12972
  }
11816
12973
  async function uninstallManagedBrowser(args) {
11817
12974
  const start = Date.now();
11818
- if (!args.browser && !args.all) return envelope({
12975
+ if (!args.browser && !args.all) return envelope_envelope({
11819
12976
  ok: false,
11820
12977
  command: "extension_browsers",
11821
12978
  status: "bad-request",
@@ -11829,7 +12986,7 @@ async function uninstallManagedBrowser(args) {
11829
12986
  browser: args.browser,
11830
12987
  all: args.all
11831
12988
  });
11832
- return envelope({
12989
+ return envelope_envelope({
11833
12990
  ok: true,
11834
12991
  command: "extension_browsers",
11835
12992
  status: "uninstalled",
@@ -11840,7 +12997,7 @@ async function uninstallManagedBrowser(args) {
11840
12997
  hint: 'Use extension_browsers with action: "list" to confirm what remains in the managed cache.'
11841
12998
  });
11842
12999
  } catch (err) {
11843
- return envelope({
13000
+ return envelope_envelope({
11844
13001
  ok: false,
11845
13002
  command: "extension_browsers",
11846
13003
  status: "uninstall-failed",
@@ -11897,7 +13054,7 @@ async function browsers_handler(args) {
11897
13054
  const action = args.action ?? "detect";
11898
13055
  if ("list" === action) return listManagedBrowsers();
11899
13056
  if ("install" === action) {
11900
- if (!args.browser) return envelope({
13057
+ if (!args.browser) return envelope_envelope({
11901
13058
  ok: false,
11902
13059
  command: "extension_browsers",
11903
13060
  status: "bad-request",
@@ -12066,7 +13223,7 @@ function describeToolArgs(inputSchema) {
12066
13223
  };
12067
13224
  }
12068
13225
  function inputValidationError(toolName, issues, inputSchema) {
12069
- return envelope({
13226
+ return envelope_envelope({
12070
13227
  ok: false,
12071
13228
  command: toolName,
12072
13229
  status: "invalid-arguments",
@@ -12113,9 +13270,30 @@ const tools = [
12113
13270
  browsers_namespaceObject,
12114
13271
  doctor_namespaceObject
12115
13272
  ];
13273
+ function toolResultFrame(result) {
13274
+ let refused = false;
13275
+ try {
13276
+ const parsed = JSON.parse(result);
13277
+ refused = isEnvelope(parsed) && false === parsed.ok;
13278
+ } catch {
13279
+ refused = false;
13280
+ }
13281
+ return {
13282
+ content: [
13283
+ {
13284
+ type: "text",
13285
+ text: result
13286
+ }
13287
+ ],
13288
+ ...refused ? {
13289
+ isError: true
13290
+ } : {}
13291
+ };
13292
+ }
12116
13293
  const toolMap = new Map();
12117
13294
  for (const tool of tools)toolMap.set(tool.schema.name, tool);
12118
13295
  async function startServer() {
13296
+ installCarrierExitCleanup();
12119
13297
  const server = new Server({
12120
13298
  name: "extension-dev",
12121
13299
  version: package_namespaceObject.rE
@@ -12138,7 +13316,7 @@ async function startServer() {
12138
13316
  content: [
12139
13317
  {
12140
13318
  type: "text",
12141
- text: envelope({
13319
+ text: envelope_envelope({
12142
13320
  ok: false,
12143
13321
  command: name,
12144
13322
  status: "unknown-tool",
@@ -12167,20 +13345,13 @@ async function startServer() {
12167
13345
  };
12168
13346
  try {
12169
13347
  const result = await tool.handler(normalizedArgs);
12170
- return {
12171
- content: [
12172
- {
12173
- type: "text",
12174
- text: result
12175
- }
12176
- ]
12177
- };
13348
+ return toolResultFrame(result);
12178
13349
  } catch (err) {
12179
13350
  return {
12180
13351
  content: [
12181
13352
  {
12182
13353
  type: "text",
12183
- text: envelope({
13354
+ text: envelope_envelope({
12184
13355
  ok: false,
12185
13356
  command: name,
12186
13357
  status: "internal-error",